diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/pom.xml b/connectors-e2e-test/connectors-e2e-test-agentic-ai/pom.xml index d3bfd56563d..ae7b2973d32 100644 --- a/connectors-e2e-test/connectors-e2e-test-agentic-ai/pom.xml +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/pom.xml @@ -88,6 +88,12 @@ camunda-process-test-spring ${version.camunda} + + io.camunda + camunda-process-test-langchain4j + ${version.camunda} + test + diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/DocumentToolCallResultsIT.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/DocumentToolCallResultsIT.java new file mode 100644 index 00000000000..d9f4497dbfa --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/DocumentToolCallResultsIT.java @@ -0,0 +1,435 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.camunda.connector.e2e.agenticai.aiagent; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static io.camunda.process.test.api.CamundaAssert.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import io.camunda.client.CamundaClient; +import io.camunda.connector.e2e.BpmnFile; +import io.camunda.connector.e2e.ElementTemplate; +import io.camunda.connector.e2e.ZeebeTest; +import io.camunda.connector.e2e.agenticai.CamundaDocumentTestConfiguration; +import io.camunda.connector.e2e.app.TestConnectorRuntimeApplication; +import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; +import io.camunda.process.test.api.CamundaSpringProcessTest; +import io.camunda.zeebe.model.bpmn.BpmnModelInstance; +import java.io.File; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ResourceLoader; + +/** + * Cross-provider viability test for document handling in tool call results. + * + *

Validates that real LLM providers can receive and reason about PDF documents extracted from + * tool call results via the synthetic UserMessage with XML correlation tags. + * + *

This test is NOT part of the CI suite. Run it manually to assess provider compatibility. + * Configure API keys via environment variables: + * + *

+ */ +@SpringBootTest( + classes = {TestConnectorRuntimeApplication.class}, + properties = { + "spring.main.allow-bean-definition-overriding=true", + "camunda.connector.webhook.enabled=false", + "camunda.connector.polling.enabled=false", + "camunda.connector.agenticai.tools.process-definition.cache.enabled=false", + // Judge LLM configuration (uses Bedrock Haiku for cost efficiency) + "camunda.process-test.judge.chat-model.provider=amazon-bedrock", + "camunda.process-test.judge.chat-model.model=eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "camunda.process-test.judge.chat-model.region=eu-central-1", + "camunda.process-test.judge.chat-model.credentials.access-key=${AWS_BEDROCK_ACCESS_KEY:NOT_SET}", + "camunda.process-test.judge.chat-model.credentials.secret-key=${AWS_BEDROCK_SECRET_KEY:NOT_SET}", + "camunda.process-test.judge.threshold=0.6" + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@CamundaSpringProcessTest +@WireMockTest +@Import(CamundaDocumentTestConfiguration.class) +@Disabled("Manual viability test - requires real LLM API keys via environment variables") +class DocumentToolCallResultsIT { + + private static final Logger LOG = LoggerFactory.getLogger(DocumentToolCallResultsIT.class); + + private static final String ELEMENT_TEMPLATE_PATH = + "../../connectors/agentic-ai/element-templates/agenticai-aiagent-job-worker.json"; + + private static final String BPMN_RESOURCE = "classpath:document-tool-call-results.bpmn"; + + private static final String DOC_DIR = "document-tool-call-results/"; + private static final String DOC_PROJECT_LAUNCH = DOC_DIR + "project-launch.pdf"; + private static final String DOC_HEADCOUNT_REPORT = DOC_DIR + "headcount-report.pdf"; + private static final String DOC_AUTHOR_INFO = DOC_DIR + "author-info.pdf"; + + private static final String SYSTEM_PROMPT = + "You are a document analyst. Use the available tools to retrieve and analyze documents. " + + "When reporting findings, always quote specific facts, numbers, dates, and names " + + "found in the documents. Be concise."; + + private static final Duration PROCESS_TIMEOUT = Duration.ofMinutes(3); + + @Autowired private CamundaClient camundaClient; + @Autowired private ResourceLoader resourceLoader; + @TempDir private File tempDir; + + @BeforeEach + void clearDocumentStore() { + InMemoryDocumentStore.INSTANCE.clear(); + } + + @BeforeEach + void setupPdfStubs() { + for (var doc : List.of(DOC_PROJECT_LAUNCH, DOC_HEADCOUNT_REPORT, DOC_AUTHOR_INFO)) { + stubFor( + get(urlPathEqualTo("/" + doc)) + .willReturn( + aResponse().withBodyFile(doc).withHeader("Content-Type", "application/pdf"))); + } + } + + // --------------------------------------------------------------------------- + // Scenario 1: Single document from tool call result + // --------------------------------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("providers") + @Disabled + void singleDocumentFromToolCallResult(ProviderConfig provider, WireMockRuntimeInfo wireMock) { + var processInstance = + startProcess( + provider, + "Use the Analyze_Single_Document tool to retrieve a document, then tell me " + + "what project it mentions and when it launched.", + List.of(wireMock.getHttpBaseUrl() + "/" + DOC_PROJECT_LAUNCH)); + + assertThat(processInstance) + .withAssertionTimeout(PROCESS_TIMEOUT) + .isCompleted() + .hasVariableSatisfies( + "agent", Object.class, agent -> logAgentResponse(provider, "singleDocument", agent)) + .hasVariableSatisfiesJudge( + "agent", + """ + The agent called Analyze_Single_Document, received a PDF document, and produced + a response that mentions Project Zypherion and the launch date March 15, 2026."""); + } + + // --------------------------------------------------------------------------- + // Scenario 2: Multiple documents from tool call result + // --------------------------------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("providers") + @Disabled + void multipleDocumentsFromToolCallResult(ProviderConfig provider, WireMockRuntimeInfo wireMock) { + var processInstance = + startProcess( + provider, + "Use the Search_Documents tool to find documents and summarize what each one says.", + List.of( + wireMock.getHttpBaseUrl() + "/" + DOC_PROJECT_LAUNCH, + wireMock.getHttpBaseUrl() + "/" + DOC_HEADCOUNT_REPORT)); + + assertThat(processInstance) + .withAssertionTimeout(PROCESS_TIMEOUT) + .isCompleted() + .hasVariableSatisfies( + "agent", Object.class, agent -> logAgentResponse(provider, "multipleDocuments", agent)) + .hasVariableSatisfiesJudge( + "agent", + """ + The agent called Search_Documents, received two PDF documents, and produced + a response that mentions both: Project Zypherion launching on March 15, 2026, + and a headcount of 847 employees across 12 offices."""); + } + + // --------------------------------------------------------------------------- + // Scenario 3: Documents in nested structure from tool call result + // --------------------------------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("providers") + void nestedStructureDocumentsFromToolCallResult( + ProviderConfig provider, WireMockRuntimeInfo wireMock) { + var processInstance = + startProcess( + provider, + "Use the Fetch_Report tool to get the full report and describe the content " + + "of every document in it, including attachments and the cover page.", + List.of( + wireMock.getHttpBaseUrl() + "/" + DOC_PROJECT_LAUNCH, + wireMock.getHttpBaseUrl() + "/" + DOC_HEADCOUNT_REPORT, + wireMock.getHttpBaseUrl() + "/" + DOC_AUTHOR_INFO)); + + assertThat(processInstance) + .withAssertionTimeout(PROCESS_TIMEOUT) + .isCompleted() + .hasVariableSatisfies( + "agent", Object.class, agent -> logAgentResponse(provider, "nestedStructure", agent)) + .hasVariableSatisfiesJudge( + "agent", + """ + The agent called Fetch_Report, received documents embedded in a nested structure, + and produced a response that references all three documents: + 1. Project Zypherion launching on March 15, 2026 + 2. A headcount of 847 employees across 12 offices + 3. The report was prepared by Dr. Kael Thrennix, Chief Analytics Officer"""); + } + + // --------------------------------------------------------------------------- + // Provider configurations + // --------------------------------------------------------------------------- + + static Stream providers() { + List> modelFilters = new ArrayList<>(); + + // sample filter + // modelFilters.add(p -> p.label().contains("gpt-4.1")); + + return Stream.of( + // OpenAI + openai("gpt-4.1"), + openai("gpt-5.4"), + // Anthropic + anthropic("claude-sonnet-4-6"), + anthropic("claude-haiku-4-5-20251001"), + // AWS Bedrock (Anthropic models via cross-region inference) + bedrock("eu.anthropic.claude-sonnet-4-20250514-v1:0"), + bedrock("global.anthropic.claude-sonnet-4-6"), + bedrock("eu.anthropic.claude-haiku-4-5-20251001-v1:0"), + // Docker Model Runner (OpenAI-compatible) + dockerModelRunner("ai/gemma4:latest").disabled(), + dockerModelRunner("ai/qwen3.6:latest").disabled(), + // Ollama (OpenAI-compatible) + ollama("qwen3.6:latest").disabled(), + ollama("llama3.1:8b").disabled()) + .filter( + providerConfig -> + modelFilters.isEmpty() + || modelFilters.stream().anyMatch(f -> f.test(providerConfig))) + .filter(ProviderConfig::isEnabled); + } + + // -- OpenAI -- + + static ProviderConfig openai(String model) { + return new ProviderConfig( + "openai/" + model, + "OPENAI_API_KEY", + Map.of( + "provider.type", + "openai", + "provider.openai.authentication.apiKey", + envOrPlaceholder("OPENAI_API_KEY"), + "provider.openai.model.model", + model)); + } + + // -- Anthropic -- + + static ProviderConfig anthropic(String model) { + return new ProviderConfig( + "anthropic/" + model, + "ANTHROPIC_API_KEY", + Map.of( + "provider.type", + "anthropic", + "provider.anthropic.authentication.apiKey", + envOrPlaceholder("ANTHROPIC_API_KEY"), + "provider.anthropic.model.model", + model)); + } + + // -- AWS Bedrock -- + + static ProviderConfig bedrock(String model) { + return new ProviderConfig( + "bedrock/" + model, + "AWS_BEDROCK_ACCESS_KEY", + Map.of( + "provider.type", + "bedrock", + "provider.bedrock.authentication.type", + "credentials", + "provider.bedrock.authentication.accessKey", + envOrPlaceholder("AWS_BEDROCK_ACCESS_KEY"), + "provider.bedrock.authentication.secretKey", + envOrPlaceholder("AWS_BEDROCK_SECRET_KEY"), + "provider.bedrock.region", + "eu-central-1", + "provider.bedrock.model.model", + model)); + } + + // -- Docker Model Runner (OpenAI-compatible) -- + + static ProviderConfig dockerModelRunner(String model) { + var url = + System.getenv() + .getOrDefault("DOCKER_MODEL_RUNNER_URL", "http://localhost:12434/engines/llama.cpp/v1"); + return new ProviderConfig( + "docker-model-runner/" + model, + "DOCKER_MODEL_RUNNER_URL", + Map.of( + "provider.type", "openaiCompatible", + "provider.openaiCompatible.endpoint", url, + "provider.openaiCompatible.model.model", model)); + } + + // -- Ollama (OpenAI-compatible) -- + + static ProviderConfig ollama(String model) { + var url = System.getenv().getOrDefault("OLLAMA_URL", "http://localhost:11434/v1"); + return new ProviderConfig( + "ollama/" + model, + "OLLAMA_URL", + Map.of( + "provider.type", "openaiCompatible", + "provider.openaiCompatible.endpoint", url, + "provider.openaiCompatible.model.model", model)); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private io.camunda.client.api.response.ProcessInstanceEvent startProcess( + ProviderConfig provider, String userPrompt, List downloadUrls) { + var model = buildModel(provider); + + // deploy and wait for process definition to be available + ZeebeTest.with(camundaClient).awaitCompleteTopology().deploy(model); + + return camundaClient + .newCreateInstanceCommand() + .bpmnProcessId("CPT_Document_Tool_Call_Results") + .latestVersion() + .variables( + Map.of( + "userPrompt", userPrompt, + "downloadUrls", downloadUrls)) + .send() + .join(); + } + + private BpmnModelInstance buildModel(ProviderConfig provider) { + var template = ElementTemplate.from(ELEMENT_TEMPLATE_PATH); + + // base properties + template + .property("agentContext", "=agent.context") + .property("data.systemPrompt.prompt", "=\"" + SYSTEM_PROMPT + "\"") + .property( + "data.userPrompt.prompt", + "=if (is defined(followUpUserPrompt)) then followUpUserPrompt else userPrompt") + .property("data.userPrompt.documents", "=[]") + .property("data.memory.storage.type", "in-process") + .property("data.memory.contextWindowSize", "=50") + .property("data.response.includeAssistantMessage", "=true") + .property("data.response.includeAgentContext", "=true"); + + // provider-specific properties + provider.properties().forEach(template::property); + + try { + var templateFile = template.writeTo(new File(tempDir, "template.json")); + var bpmnFile = resourceLoader.getResource(BPMN_RESOURCE).getFile(); + return new BpmnFile(bpmnFile) + .apply(templateFile, "AI_Agent", new File(tempDir, "applied.bpmn")); + } catch (Exception e) { + throw new RuntimeException("Failed to build BPMN model for " + provider.label(), e); + } + } + + private void logAgentResponse(ProviderConfig provider, String scenario, Object agent) { + Object responseText = agent; + if (agent instanceof Map map && map.containsKey("responseText")) { + responseText = map.get("responseText"); + } + LOG.info( + "\n========== [{} / {}] ==========\n{}\n==========", + provider.label(), + scenario, + responseText); + } + + private static String envOrPlaceholder(String envVar) { + return System.getenv().getOrDefault(envVar, "NOT_SET"); + } + + // --------------------------------------------------------------------------- + // Provider config record + // --------------------------------------------------------------------------- + + record ProviderConfig( + String label, String requiredEnvVar, Map properties, boolean enabled) { + + ProviderConfig(String label, String requiredEnvVar, Map properties) { + this(label, requiredEnvVar, properties, true); + } + + ProviderConfig disabled() { + return new ProviderConfig(label, requiredEnvVar, properties, false); + } + + boolean isEnabled() { + if (!enabled) { + return false; + } + // Local providers don't need an API key env var, just the URL + if (requiredEnvVar.equals("DOCKER_MODEL_RUNNER_URL") || requiredEnvVar.equals("OLLAMA_URL")) { + return true; + } + return System.getenv(requiredEnvVar) != null; + } + + @Override + public String toString() { + return label; + } + } +} diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/ToolCallResultDocumentAssertions.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/ToolCallResultDocumentAssertions.java new file mode 100644 index 00000000000..49f7cee3c9e --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/ToolCallResultDocumentAssertions.java @@ -0,0 +1,216 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.camunda.connector.e2e.agenticai.aiagent; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.ImageContent; +import dev.langchain4j.data.message.PdfFileContent; +import dev.langchain4j.data.message.TextContent; +import dev.langchain4j.data.message.UserMessage; +import io.camunda.connector.agenticai.model.message.DocumentXmlTag; +import io.camunda.connector.document.jackson.DocumentReferenceModel; +import io.camunda.connector.document.jackson.DocumentReferenceModel.CamundaDocumentReferenceModel; +import java.util.function.Consumer; + +/** + * Assertion helpers for the synthetic {@link UserMessage} that carries documents extracted from + * tool call results. + * + *

The user message has the structure {@code preamble + (xml-tag, content-block)*}, so it can + * carry documents from one or many tool call results. Use {@link + * #assertExtractedDocumentsUserMessage(ChatMessage, ExtractedDocument...)} to assert against any + * number of documents in one go. + */ +public final class ToolCallResultDocumentAssertions { + + static final String EXTRACTED_DOCUMENTS_PREAMBLE = "Documents extracted from tool call results:"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ToolCallResultDocumentAssertions() {} + + /** + * Locates the first Camunda document reference inside a serialized tool call result and + * deserializes it into the production {@link CamundaDocumentReferenceModel}. Recursively descends + * into objects/arrays until it finds an object carrying the {@code camunda.document.type} + * discriminator (set by {@code DocumentSerializer}). + * + *

Throws {@link AssertionError} if the text cannot be parsed as JSON or no document reference + * is present. + */ + public static CamundaDocumentReferenceModel parseDocumentReference(String toolResultText) { + final JsonNode root; + try { + root = OBJECT_MAPPER.readTree(toolResultText); + } catch (JsonProcessingException e) { + throw new AssertionError("Failed to parse tool result text as JSON: " + toolResultText, e); + } + + final JsonNode docNode = findFirstCamundaDocumentNode(root); + if (docNode == null) { + throw new AssertionError( + "No Camunda document reference found in tool result text: " + toolResultText); + } + + try { + return OBJECT_MAPPER.treeToValue(docNode, CamundaDocumentReferenceModel.class); + } catch (JsonProcessingException e) { + throw new AssertionError("Failed to deserialize Camunda document reference: " + docNode, e); + } + } + + /** + * Asserts that {@code message} is a {@link UserMessage} with the standard "extracted documents" + * structure: a preamble {@link TextContent} followed by an {@code (xml-tag, content-block)} pair + * per expected document. + * + *

The XML tag is built using the production {@link DocumentXmlTag}, so the assertion stays in + * sync with the production format. + */ + public static void assertExtractedDocumentsUserMessage( + ChatMessage message, ExtractedDocument... expectedDocuments) { + assertThat(message).isInstanceOf(UserMessage.class); + final var contents = ((UserMessage) message).contents(); + + assertThat(contents) + .as("expected preamble + 2 contents per document (%d documents)", expectedDocuments.length) + .hasSize(1 + 2 * expectedDocuments.length); + + assertThat(contents.get(0)) + .as("preamble TextContent") + .isInstanceOfSatisfying( + TextContent.class, tc -> assertThat(tc.text()).isEqualTo(EXTRACTED_DOCUMENTS_PREAMBLE)); + + for (int i = 0; i < expectedDocuments.length; i++) { + final var expected = expectedDocuments[i]; + final var expectedXml = expected.expectedXmlTag(); + final var tagIndex = 1 + 2 * i; + final var contentIndex = tagIndex + 1; + + assertThat(contents.get(tagIndex)) + .as("XML tag at index %d (document %d)", tagIndex, i) + .isInstanceOfSatisfying( + TextContent.class, tc -> assertThat(tc.text()).isEqualTo(expectedXml)); + + final var contentBlock = contents.get(contentIndex); + try { + expected.contentBlockAssertion().accept(contentBlock); + } catch (AssertionError e) { + throw new AssertionError( + "Content block at index %d (document %d) failed: %s" + .formatted(contentIndex, i, e.getMessage()), + e); + } + } + } + + /** + * Asserts a content block based on a coarse type/mime classification used by tool calling tests: + * + *

    + *
  • {@code "text"} → {@link TextContent} + *
  • {@code "application/pdf"} → {@link PdfFileContent} with non-blank base64 + *
  • otherwise → {@link ImageContent} with matching mime type and non-blank base64 + *
+ */ + public static void assertDocumentContentBlock( + Content content, String expectedType, String expectedMimeType) { + if ("text".equals(expectedType)) { + assertThat(content).isInstanceOf(TextContent.class); + } else if ("application/pdf".equals(expectedMimeType)) { + assertThat(content) + .isInstanceOfSatisfying( + PdfFileContent.class, pdf -> assertThat(pdf.pdfFile().base64Data()).isNotBlank()); + } else { + assertThat(content) + .isInstanceOfSatisfying( + ImageContent.class, + img -> { + assertThat(img.image().mimeType()).isEqualTo(expectedMimeType); + assertThat(img.image().base64Data()).isNotBlank(); + }); + } + } + + private static JsonNode findFirstCamundaDocumentNode(JsonNode node) { + if (node == null) { + return null; + } + if (node.isObject()) { + if ("camunda".equals(node.path(DocumentReferenceModel.DISCRIMINATOR_KEY).asText(null))) { + return node; + } + final var properties = node.properties(); + for (var property : properties) { + final var found = findFirstCamundaDocumentNode(property.getValue()); + if (found != null) { + return found; + } + } + } else if (node.isArray()) { + for (var element : node) { + final var found = findFirstCamundaDocumentNode(element); + if (found != null) { + return found; + } + } + } + return null; + } + + /** + * Specification of one expected extracted document slot in the synthetic user message: an XML tag + * with optional tool call correlation attributes plus a content block check. + */ + public record ExtractedDocument( + String toolCallId, + String toolName, + CamundaDocumentReferenceModel reference, + Consumer contentBlockAssertion) { + + /** Document extracted from a tool call result. */ + public static ExtractedDocument forToolCall( + String toolCallId, + String toolName, + CamundaDocumentReferenceModel reference, + Consumer contentBlockAssertion) { + return new ExtractedDocument(toolCallId, toolName, reference, contentBlockAssertion); + } + + /** The XML tag string this document is expected to render to in the synthetic user message. */ + String expectedXmlTag() { + return new DocumentXmlTag( + toolCallId, + toolName, + documentShortId(), + reference.metadata() != null ? reference.metadata().fileName() : null) + .toXml(); + } + + private String documentShortId() { + final var documentId = reference.documentId(); + final int dash = documentId.indexOf('-'); + return dash > 0 ? documentId.substring(0, dash) : documentId; + } + } +} diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/BaseL4JAiAgentJobWorkerTest.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/BaseL4JAiAgentJobWorkerTest.java index 59dd1213387..49a72d676a2 100644 --- a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/BaseL4JAiAgentJobWorkerTest.java +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/BaseL4JAiAgentJobWorkerTest.java @@ -38,7 +38,6 @@ import dev.langchain4j.model.output.TokenUsage; import io.camunda.connector.agenticai.adhoctoolsschema.schema.AdHocToolsSchemaResolver; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelFactory; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentResponseModel; import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; import io.camunda.connector.agenticai.aiagent.model.JobWorkerAgentResponse; import io.camunda.connector.e2e.ElementTemplate; @@ -46,6 +45,7 @@ import io.camunda.connector.e2e.agenticai.aiagent.BaseAiAgentJobWorkerTest; import io.camunda.connector.e2e.agenticai.assertj.JobWorkerAgentResponseAssert; import io.camunda.connector.e2e.agenticai.assertj.ToolExecutionRequestEqualsPredicate; +import io.camunda.connector.e2e.agenticai.assertj.ToolExecutionResultMessageEqualsPredicate; import io.camunda.connector.test.utils.annotation.SlowTest; import java.time.Duration; import java.util.ArrayList; @@ -317,6 +317,9 @@ protected void assertLastChatRequest( RecursiveComparisonConfiguration.builder() .withEqualsForType( new ToolExecutionRequestEqualsPredicate(), ToolExecutionRequest.class) + .withEqualsForType( + new ToolExecutionResultMessageEqualsPredicate(), + ToolExecutionResultMessage.class) .build()) .containsExactlyElementsOf( expectedConversation.subList(0, expectedConversation.size() - 1)); @@ -376,6 +379,4 @@ protected static ChatInteraction of( return new ChatInteraction(chatResponse, userFeedback); } } - - protected record DownloadFileToolResult(int status, DocumentToContentResponseModel document) {} } diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerMcpIntegrationTests.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerMcpIntegrationTests.java index 64611813235..03ecba8f73f 100644 --- a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerMcpIntegrationTests.java +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerMcpIntegrationTests.java @@ -17,6 +17,8 @@ package io.camunda.connector.e2e.agenticai.aiagent.langchain4j.jobworker; import static io.camunda.connector.e2e.agenticai.aiagent.AiAgentTestFixtures.HAIKU_TEXT; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.assertExtractedDocumentsUserMessage; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.parseDocumentReference; import static io.camunda.connector.e2e.agenticai.aiagent.langchain4j.Langchain4JAiAgentToolSpecifications.EXPECTED_MCP_TOOL_SPECIFICATIONS; import static io.camunda.connector.e2e.agenticai.mcp.McpSdkToolSpecifications.MCP_TOOL_SPECIFICATIONS; import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +35,7 @@ import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.agent.tool.ToolSpecification; import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ImageContent; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; @@ -49,12 +52,14 @@ import io.camunda.connector.agenticai.mcp.client.model.McpRemoteClientTransportConfiguration; import io.camunda.connector.agenticai.mcp.client.model.McpRemoteClientTransportConfiguration.SseHttpMcpRemoteClientTransportConfiguration; import io.camunda.connector.agenticai.mcp.client.model.McpRemoteClientTransportConfiguration.StreamableHttpMcpRemoteClientTransportConfiguration; +import io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.ExtractedDocument; import io.camunda.connector.e2e.agenticai.assertj.JobWorkerAgentResponseAssert; import io.camunda.connector.test.utils.annotation.SlowTest; import io.camunda.process.test.api.CamundaAssert; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import java.io.IOException; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -342,9 +347,109 @@ void handlesMcpToolCalls() throws IOException { verify(chatModel, times(3)).chat(any(ChatRequest.class)); } + @Test + void extractsDocumentsFromMcpImageToolCallResult() throws IOException { + final var imageBytes = "fake-png-image-data".getBytes(); + final var imageBase64 = Base64.getEncoder().encodeToString(imageBytes); + + when(aMcpClient.callTool(aMcpClientToolExecutionRequestCaptor.capture())) + .thenReturn(mcpCallToolResultWithImage(imageBase64, "image/png")); + + final var initialUserPrompt = "Get me an image from MCP!"; + + final var aiToolCallMessage = + new AiMessage( + "I will call the MCP tool to get an image.", + List.of( + ToolExecutionRequest.builder() + .id("img111") + .name("MCP_A_MCP_Client___toolA") + .arguments("{\"paramA1\": \"getImage\", \"paramA2\": 1}") + .build())); + final var aiFinalResponse = new AiMessage("Here is the image I retrieved from MCP."); + + mockChatInteractions( + ChatInteraction.of( + ChatResponse.builder() + .metadata( + ChatResponseMetadata.builder() + .finishReason(FinishReason.TOOL_EXECUTION) + .tokenUsage(new TokenUsage(10, 20)) + .build()) + .aiMessage(aiToolCallMessage) + .build()), + ChatInteraction.of( + ChatResponse.builder() + .metadata( + ChatResponseMetadata.builder() + .finishReason(FinishReason.STOP) + .tokenUsage(new TokenUsage(100, 200)) + .build()) + .aiMessage(aiFinalResponse) + .build(), + userSatisfiedFeedback())); + + final var zeebeTest = + createProcessInstance(testProcessWithMcp, e -> e, Map.of("userPrompt", initialUserPrompt)) + .waitForProcessCompletion(); + + assertThat(chatRequestCaptor.getAllValues()).hasSize(2); + final var lastMessages = chatRequestCaptor.getValue().messages(); + assertThat(lastMessages).hasSize(5); + + assertThat(lastMessages.get(0)).isInstanceOf(SystemMessage.class); + assertThat(lastMessages.get(1)).isInstanceOf(UserMessage.class); // initial prompt + assertThat(lastMessages.get(2)).isInstanceOf(AiMessage.class); // tool call + + // tool result: document serialized as document reference + var toolResultText = ((ToolExecutionResultMessage) lastMessages.get(3)).text(); + var documentReference = parseDocumentReference(toolResultText); + + assertThat(lastMessages.get(3)) + .isInstanceOfSatisfying( + ToolExecutionResultMessage.class, + msg -> { + assertThat(msg.id()).isEqualTo("img111"); + assertThat(msg.toolName()).isEqualTo("MCP_A_MCP_Client___toolA"); + }); + assertThat(documentReference.metadata().contentType()).isEqualTo("image/png"); + + // document user message: extracted document content + assertExtractedDocumentsUserMessage( + lastMessages.get(4), + ExtractedDocument.forToolCall( + "img111", + "MCP_A_MCP_Client___toolA", + documentReference, + content -> + assertThat(content) + .isInstanceOfSatisfying( + ImageContent.class, + img -> { + assertThat(img.image().mimeType()).isEqualTo("image/png"); + assertThat(img.image().base64Data()).isEqualTo(imageBase64); + }))); + + assertAgentResponse( + zeebeTest, + agentResponse -> + JobWorkerAgentResponseAssert.assertThat(agentResponse) + .isReady() + .hasMetrics(new AgentMetrics(2, new AgentMetrics.TokenUsage(110, 220))) + .hasResponseMessageText(aiFinalResponse.text()) + .hasResponseText(aiFinalResponse.text())); + } + protected McpSchema.CallToolResult mcpCallToolResult(String resultText) { return McpSchema.CallToolResult.builder() .addContent(new McpSchema.TextContent(resultText)) .build(); } + + protected McpSchema.CallToolResult mcpCallToolResultWithImage( + String base64Data, String mimeType) { + return McpSchema.CallToolResult.builder() + .addContent(new McpSchema.ImageContent(null, base64Data, mimeType)) + .build(); + } } diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerToolCallingTests.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerToolCallingTests.java index b856e6d4c37..0b7318377db 100644 --- a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerToolCallingTests.java +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/jobworker/L4JAiAgentJobWorkerToolCallingTests.java @@ -17,7 +17,11 @@ package io.camunda.connector.e2e.agenticai.aiagent.langchain4j.jobworker; import static io.camunda.connector.e2e.agenticai.aiagent.AiAgentTestFixtures.FEEDBACK_LOOP_RESPONSE_TEXT; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.assertDocumentContentBlock; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.assertExtractedDocumentsUserMessage; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.parseDocumentReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import dev.langchain4j.agent.tool.ToolExecutionRequest; @@ -29,8 +33,8 @@ import dev.langchain4j.model.chat.response.ChatResponseMetadata; import dev.langchain4j.model.output.FinishReason; import dev.langchain4j.model.output.TokenUsage; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentResponseModel; import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.ExtractedDocument; import io.camunda.connector.e2e.agenticai.assertj.JobWorkerAgentResponseAssert; import io.camunda.connector.test.utils.annotation.SlowTest; import java.util.List; @@ -77,44 +81,23 @@ void executesAgentWithToolCallingAndUserFeedback() throws Exception { void supportsDocumentResponsesFromToolCalls( String filename, String type, String mimeType, WireMockRuntimeInfo wireMock) throws Exception { - DownloadFileToolResult expectedDownloadFileResult; - if (type.equals("text")) { - expectedDownloadFileResult = - new DownloadFileToolResult( - 200, - new DocumentToContentResponseModel(type, mimeType, testFileContent(filename).get())); - } else { - expectedDownloadFileResult = - new DownloadFileToolResult( - 200, - new DocumentToContentResponseModel( - type, mimeType, testFileContentBase64(filename).get())); - } - final var initialUserPrompt = "Go and download a document!"; - final var expectedConversation = - List.of( - new SystemMessage( - "You are a helpful AI assistant. Answer all the questions, but always be nice. Explain your thinking."), - new UserMessage(initialUserPrompt), - new AiMessage( - "The user asked me to download a document. I will call the Download_A_File tool to do so.", - List.of( - ToolExecutionRequest.builder() - .id("aaa111") - .name("Download_A_File") - .arguments( - "{\"url\": \"%s\"}" - .formatted(wireMock.getHttpBaseUrl() + "/" + filename)) - .build())), - new ToolExecutionResultMessage( - "aaa111", - "Download_A_File", - objectMapper.writeValueAsString(expectedDownloadFileResult)), - new AiMessage( - "I loaded a document and learned that it contains interesting data. Anything specific you want to know?"), - new UserMessage("What is the content type?"), - new AiMessage("The content type is '%s'".formatted(mimeType))); + + // the AI message with tool call and the subsequent responses for the conversation + final var aiToolCallMessage = + new AiMessage( + "The user asked me to download a document. I will call the Download_A_File tool to do so.", + List.of( + ToolExecutionRequest.builder() + .id("aaa111") + .name("Download_A_File") + .arguments( + "{\"url\": \"%s\"}".formatted(wireMock.getHttpBaseUrl() + "/" + filename)) + .build())); + final var aiResponseAfterTool = + new AiMessage( + "I loaded a document and learned that it contains interesting data. Anything specific you want to know?"); + final var aiFinalResponse = new AiMessage("The content type is '%s'".formatted(mimeType)); mockChatInteractions( ChatInteraction.of( @@ -124,7 +107,7 @@ void supportsDocumentResponsesFromToolCalls( .finishReason(FinishReason.TOOL_EXECUTION) .tokenUsage(new TokenUsage(10, 20)) .build()) - .aiMessage((AiMessage) expectedConversation.get(2)) + .aiMessage(aiToolCallMessage) .build()), ChatInteraction.of( ChatResponse.builder() @@ -133,7 +116,7 @@ void supportsDocumentResponsesFromToolCalls( .finishReason(FinishReason.STOP) .tokenUsage(new TokenUsage(100, 200)) .build()) - .aiMessage((AiMessage) expectedConversation.get(4)) + .aiMessage(aiResponseAfterTool) .build(), userFollowUpFeedback("What is the content type?")), ChatInteraction.of( @@ -143,7 +126,7 @@ void supportsDocumentResponsesFromToolCalls( .finishReason(FinishReason.STOP) .tokenUsage(new TokenUsage(11, 22)) .build()) - .aiMessage((AiMessage) expectedConversation.get(6)) + .aiMessage(aiFinalResponse) .build(), userSatisfiedFeedback())); @@ -154,17 +137,55 @@ void supportsDocumentResponsesFromToolCalls( Map.of("userPrompt", initialUserPrompt)) .waitForProcessCompletion(); - assertLastChatRequest(expectedConversation); + await() + .alias("Chat request captured") + .untilAsserted(() -> assertThat(chatRequestCaptor.getValue()).isNotNull()); + + assertThat(chatRequestCaptor.getAllValues()).hasSize(3); + final var lastMessages = chatRequestCaptor.getValue().messages(); + assertThat(lastMessages).hasSize(7); + + assertThat(lastMessages.get(0)).isInstanceOf(SystemMessage.class); + assertThat(lastMessages.get(1)).isInstanceOf(UserMessage.class); // initial prompt + assertThat(lastMessages.get(2)).isInstanceOf(AiMessage.class); // tool call + + // tool result: document serialized as document reference + var toolResultText = ((ToolExecutionResultMessage) lastMessages.get(3)).text(); + var documentReference = parseDocumentReference(toolResultText); + + assertThat(lastMessages.get(3)) + .isInstanceOfSatisfying( + ToolExecutionResultMessage.class, + msg -> { + assertThat(msg.id()).isEqualTo("aaa111"); + assertThat(msg.toolName()).isEqualTo("Download_A_File"); + }); + assertThat(documentReference.metadata().contentType()).isEqualTo(mimeType); + + // document user message: extracted document content + assertExtractedDocumentsUserMessage( + lastMessages.get(4), + ExtractedDocument.forToolCall( + "aaa111", + "Download_A_File", + documentReference, + content -> assertDocumentContentBlock(content, type, mimeType))); + + assertThat(lastMessages.get(5)).isInstanceOf(AiMessage.class); // response after tool + + assertThat(lastMessages.get(6)) + .isInstanceOfSatisfying( + UserMessage.class, + msg -> assertThat(msg.singleText()).isEqualTo("What is the content type?")); - String expectedResponseText = ((AiMessage) expectedConversation.getLast()).text(); assertAgentResponse( zeebeTest, agentResponse -> JobWorkerAgentResponseAssert.assertThat(agentResponse) .isReady() .hasMetrics(new AgentMetrics(3, new AgentMetrics.TokenUsage(121, 242))) - .hasResponseMessageText(expectedResponseText) - .hasResponseText(expectedResponseText)); + .hasResponseMessageText(aiFinalResponse.text()) + .hasResponseText(aiFinalResponse.text())); assertThat(userFeedbackJobWorkerCounter.get()).isEqualTo(2); } diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/BaseL4JAiAgentConnectorTest.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/BaseL4JAiAgentConnectorTest.java index 8217b6a2dc0..2f314140ab0 100644 --- a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/BaseL4JAiAgentConnectorTest.java +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/BaseL4JAiAgentConnectorTest.java @@ -37,7 +37,6 @@ import dev.langchain4j.model.output.TokenUsage; import io.camunda.connector.agenticai.adhoctoolsschema.schema.AdHocToolsSchemaResolver; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelFactory; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentResponseModel; import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; import io.camunda.connector.agenticai.aiagent.model.AgentResponse; import io.camunda.connector.e2e.ElementTemplate; @@ -45,6 +44,7 @@ import io.camunda.connector.e2e.agenticai.aiagent.BaseAiAgentConnectorTest; import io.camunda.connector.e2e.agenticai.assertj.AgentResponseAssert; import io.camunda.connector.e2e.agenticai.assertj.ToolExecutionRequestEqualsPredicate; +import io.camunda.connector.e2e.agenticai.assertj.ToolExecutionResultMessageEqualsPredicate; import io.camunda.connector.test.utils.annotation.SlowTest; import java.util.ArrayList; import java.util.Arrays; @@ -314,6 +314,9 @@ protected void assertLastChatRequest( RecursiveComparisonConfiguration.builder() .withEqualsForType( new ToolExecutionRequestEqualsPredicate(), ToolExecutionRequest.class) + .withEqualsForType( + new ToolExecutionResultMessageEqualsPredicate(), + ToolExecutionResultMessage.class) .build()) .containsExactlyElementsOf( expectedConversation.subList(0, expectedConversation.size() - 1)); @@ -338,6 +341,4 @@ protected static ChatInteraction of( return new ChatInteraction(chatResponse, userFeedback); } } - - protected record DownloadFileToolResult(int status, DocumentToContentResponseModel document) {} } diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorMcpIntegrationTests.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorMcpIntegrationTests.java index 83124ed6ae6..58d9fe30f65 100644 --- a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorMcpIntegrationTests.java +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorMcpIntegrationTests.java @@ -17,6 +17,8 @@ package io.camunda.connector.e2e.agenticai.aiagent.langchain4j.outboundconnector; import static io.camunda.connector.e2e.agenticai.aiagent.AiAgentTestFixtures.HAIKU_TEXT; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.assertExtractedDocumentsUserMessage; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.parseDocumentReference; import static io.camunda.connector.e2e.agenticai.aiagent.langchain4j.Langchain4JAiAgentToolSpecifications.EXPECTED_MCP_TOOL_SPECIFICATIONS; import static io.camunda.connector.e2e.agenticai.mcp.McpSdkToolSpecifications.MCP_TOOL_SPECIFICATIONS; import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +35,7 @@ import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.agent.tool.ToolSpecification; import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ImageContent; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; @@ -49,6 +52,7 @@ import io.camunda.connector.agenticai.mcp.client.model.McpRemoteClientTransportConfiguration; import io.camunda.connector.agenticai.mcp.client.model.McpRemoteClientTransportConfiguration.SseHttpMcpRemoteClientTransportConfiguration; import io.camunda.connector.agenticai.mcp.client.model.McpRemoteClientTransportConfiguration.StreamableHttpMcpRemoteClientTransportConfiguration; +import io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.ExtractedDocument; import io.camunda.connector.e2e.agenticai.aiagent.langchain4j.Langchain4JAiAgentToolSpecifications; import io.camunda.connector.e2e.agenticai.assertj.AgentResponseAssert; import io.camunda.connector.test.utils.annotation.SlowTest; @@ -56,6 +60,7 @@ import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import java.io.IOException; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -344,6 +349,107 @@ void handlesMcpToolCalls() throws IOException { verify(chatModel, times(3)).chat(any(ChatRequest.class)); } + @Test + void extractsDocumentsFromMcpImageToolCallResult() throws IOException { + final var imageBytes = "fake-png-image-data".getBytes(); + final var imageBase64 = Base64.getEncoder().encodeToString(imageBytes); + + when(aMcpClient.callTool(aMcpClientToolExecutionRequestCaptor.capture())) + .thenReturn(mcpCallToolResultWithImage(imageBase64, "image/png")); + + final var initialUserPrompt = "Get me an image from MCP!"; + + final var aiToolCallMessage = + new AiMessage( + "I will call the MCP tool to get an image.", + List.of( + ToolExecutionRequest.builder() + .id("img111") + .name("MCP_A_MCP_Client___toolA") + .arguments("{\"paramA1\": \"getImage\", \"paramA2\": 1}") + .build())); + final var aiFinalResponse = new AiMessage("Here is the image I retrieved from MCP."); + + mockChatInteractions( + ChatInteraction.of( + ChatResponse.builder() + .metadata( + ChatResponseMetadata.builder() + .finishReason(FinishReason.TOOL_EXECUTION) + .tokenUsage(new TokenUsage(10, 20)) + .build()) + .aiMessage(aiToolCallMessage) + .build()), + ChatInteraction.of( + ChatResponse.builder() + .metadata( + ChatResponseMetadata.builder() + .finishReason(FinishReason.STOP) + .tokenUsage(new TokenUsage(100, 200)) + .build()) + .aiMessage(aiFinalResponse) + .build(), + userSatisfiedFeedback())); + + final var zeebeTest = + createProcessInstance(testProcessWithMcp, e -> e, Map.of("userPrompt", initialUserPrompt)) + .waitForProcessCompletion(); + + assertThat(chatRequestCaptor.getAllValues()).hasSize(2); + final var lastMessages = chatRequestCaptor.getValue().messages(); + assertThat(lastMessages).hasSize(5); + + assertThat(lastMessages.get(0)).isInstanceOf(SystemMessage.class); + assertThat(lastMessages.get(1)).isInstanceOf(UserMessage.class); // initial prompt + assertThat(lastMessages.get(2)).isInstanceOf(AiMessage.class); // tool call + + // tool result: document serialized as document reference + var toolResultText = ((ToolExecutionResultMessage) lastMessages.get(3)).text(); + var documentReference = parseDocumentReference(toolResultText); + + assertThat(lastMessages.get(3)) + .isInstanceOfSatisfying( + ToolExecutionResultMessage.class, + msg -> { + assertThat(msg.id()).isEqualTo("img111"); + assertThat(msg.toolName()).isEqualTo("MCP_A_MCP_Client___toolA"); + }); + assertThat(documentReference.metadata().contentType()).isEqualTo("image/png"); + + // document user message: extracted document content + assertExtractedDocumentsUserMessage( + lastMessages.get(4), + ExtractedDocument.forToolCall( + "img111", + "MCP_A_MCP_Client___toolA", + documentReference, + content -> + assertThat(content) + .isInstanceOfSatisfying( + ImageContent.class, + img -> { + assertThat(img.image().mimeType()).isEqualTo("image/png"); + assertThat(img.image().base64Data()).isEqualTo(imageBase64); + }))); + + assertAgentResponse( + zeebeTest, + agentResponse -> + AgentResponseAssert.assertThat(agentResponse) + .isReady() + .hasNoToolCalls() + .hasMetrics(new AgentMetrics(2, new AgentMetrics.TokenUsage(110, 220))) + .hasResponseMessageText(aiFinalResponse.text()) + .hasResponseText(aiFinalResponse.text())); + } + + protected McpSchema.CallToolResult mcpCallToolResultWithImage( + String base64Data, String mimeType) { + return McpSchema.CallToolResult.builder() + .addContent(new McpSchema.ImageContent(null, base64Data, mimeType)) + .build(); + } + @Override protected void assertToolSpecifications(ChatRequest chatRequest) { assertThat(chatRequest.toolSpecifications()) diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorToolCallingTests.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorToolCallingTests.java index 6be1bfd8d53..7925eb15ed4 100644 --- a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorToolCallingTests.java +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/langchain4j/outboundconnector/L4JAiAgentConnectorToolCallingTests.java @@ -17,7 +17,11 @@ package io.camunda.connector.e2e.agenticai.aiagent.langchain4j.outboundconnector; import static io.camunda.connector.e2e.agenticai.aiagent.AiAgentTestFixtures.FEEDBACK_LOOP_RESPONSE_TEXT; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.assertDocumentContentBlock; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.assertExtractedDocumentsUserMessage; +import static io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.parseDocumentReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import dev.langchain4j.agent.tool.ToolExecutionRequest; @@ -29,8 +33,8 @@ import dev.langchain4j.model.chat.response.ChatResponseMetadata; import dev.langchain4j.model.output.FinishReason; import dev.langchain4j.model.output.TokenUsage; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentResponseModel; import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.e2e.agenticai.aiagent.ToolCallResultDocumentAssertions.ExtractedDocument; import io.camunda.connector.e2e.agenticai.assertj.AgentResponseAssert; import io.camunda.connector.test.utils.annotation.SlowTest; import java.util.List; @@ -71,44 +75,22 @@ void executesAgentWithToolCallingAndUserFeedback() throws Exception { void supportsDocumentResponsesFromToolCalls( String filename, String type, String mimeType, WireMockRuntimeInfo wireMock) throws Exception { - DownloadFileToolResult expectedDownloadFileResult; - if (type.equals("text")) { - expectedDownloadFileResult = - new DownloadFileToolResult( - 200, - new DocumentToContentResponseModel(type, mimeType, testFileContent(filename).get())); - } else { - expectedDownloadFileResult = - new DownloadFileToolResult( - 200, - new DocumentToContentResponseModel( - type, mimeType, testFileContentBase64(filename).get())); - } - final var initialUserPrompt = "Go and download a document!"; - final var expectedConversation = - List.of( - new SystemMessage( - "You are a helpful AI assistant. Answer all the questions, but always be nice. Explain your thinking."), - new UserMessage(initialUserPrompt), - new AiMessage( - "The user asked me to download a document. I will call the Download_A_File tool to do so.", - List.of( - ToolExecutionRequest.builder() - .id("aaa111") - .name("Download_A_File") - .arguments( - "{\"url\": \"%s\"}" - .formatted(wireMock.getHttpBaseUrl() + "/" + filename)) - .build())), - new ToolExecutionResultMessage( - "aaa111", - "Download_A_File", - objectMapper.writeValueAsString(expectedDownloadFileResult)), - new AiMessage( - "I loaded a document and learned that it contains interesting data. Anything specific you want to know?"), - new UserMessage("What is the content type?"), - new AiMessage("The content type is '%s'".formatted(mimeType))); + + final var aiToolCallMessage = + new AiMessage( + "The user asked me to download a document. I will call the Download_A_File tool to do so.", + List.of( + ToolExecutionRequest.builder() + .id("aaa111") + .name("Download_A_File") + .arguments( + "{\"url\": \"%s\"}".formatted(wireMock.getHttpBaseUrl() + "/" + filename)) + .build())); + final var aiResponseAfterTool = + new AiMessage( + "I loaded a document and learned that it contains interesting data. Anything specific you want to know?"); + final var aiFinalResponse = new AiMessage("The content type is '%s'".formatted(mimeType)); mockChatInteractions( ChatInteraction.of( @@ -118,7 +100,7 @@ void supportsDocumentResponsesFromToolCalls( .finishReason(FinishReason.TOOL_EXECUTION) .tokenUsage(new TokenUsage(10, 20)) .build()) - .aiMessage((AiMessage) expectedConversation.get(2)) + .aiMessage(aiToolCallMessage) .build()), ChatInteraction.of( ChatResponse.builder() @@ -127,7 +109,7 @@ void supportsDocumentResponsesFromToolCalls( .finishReason(FinishReason.STOP) .tokenUsage(new TokenUsage(100, 200)) .build()) - .aiMessage((AiMessage) expectedConversation.get(4)) + .aiMessage(aiResponseAfterTool) .build(), userFollowUpFeedback("What is the content type?")), ChatInteraction.of( @@ -137,7 +119,7 @@ void supportsDocumentResponsesFromToolCalls( .finishReason(FinishReason.STOP) .tokenUsage(new TokenUsage(11, 22)) .build()) - .aiMessage((AiMessage) expectedConversation.get(6)) + .aiMessage(aiFinalResponse) .build(), userSatisfiedFeedback())); @@ -148,9 +130,47 @@ void supportsDocumentResponsesFromToolCalls( Map.of("userPrompt", initialUserPrompt)) .waitForProcessCompletion(); - assertLastChatRequest(3, expectedConversation); + await() + .alias("Chat request captured") + .untilAsserted(() -> assertThat(chatRequestCaptor.getValue()).isNotNull()); + + assertThat(chatRequestCaptor.getAllValues()).hasSize(3); + final var lastMessages = chatRequestCaptor.getValue().messages(); + assertThat(lastMessages).hasSize(7); + + assertThat(lastMessages.get(0)).isInstanceOf(SystemMessage.class); + assertThat(lastMessages.get(1)).isInstanceOf(UserMessage.class); // initial prompt + assertThat(lastMessages.get(2)).isInstanceOf(AiMessage.class); // tool call + + // tool result: document serialized as document reference + var toolResultText = ((ToolExecutionResultMessage) lastMessages.get(3)).text(); + var documentReference = parseDocumentReference(toolResultText); + + assertThat(lastMessages.get(3)) + .isInstanceOfSatisfying( + ToolExecutionResultMessage.class, + msg -> { + assertThat(msg.id()).isEqualTo("aaa111"); + assertThat(msg.toolName()).isEqualTo("Download_A_File"); + }); + assertThat(documentReference.metadata().contentType()).isEqualTo(mimeType); + + // document user message: extracted document content + assertExtractedDocumentsUserMessage( + lastMessages.get(4), + ExtractedDocument.forToolCall( + "aaa111", + "Download_A_File", + documentReference, + content -> assertDocumentContentBlock(content, type, mimeType))); + + assertThat(lastMessages.get(5)).isInstanceOf(AiMessage.class); // response after tool + + assertThat(lastMessages.get(6)) + .isInstanceOfSatisfying( + UserMessage.class, + msg -> assertThat(msg.singleText()).isEqualTo("What is the content type?")); - String expectedResponseText = ((AiMessage) expectedConversation.getLast()).text(); assertAgentResponse( zeebeTest, agentResponse -> @@ -158,8 +178,8 @@ void supportsDocumentResponsesFromToolCalls( .isReady() .hasNoToolCalls() .hasMetrics(new AgentMetrics(3, new AgentMetrics.TokenUsage(121, 242))) - .hasResponseMessageText(expectedResponseText) - .hasResponseText(expectedResponseText)); + .hasResponseMessageText(aiFinalResponse.text()) + .hasResponseText(aiFinalResponse.text())); assertThat(userFeedbackJobWorkerCounter.get()).isEqualTo(2); } diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/AnthropicMessagesApiAiAgentJobWorkerTests.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/AnthropicMessagesApiAiAgentJobWorkerTests.java new file mode 100644 index 00000000000..ff287ce24e1 --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/AnthropicMessagesApiAiAgentJobWorkerTests.java @@ -0,0 +1,137 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.camunda.connector.e2e.agenticai.aiagent.wireformat; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import io.camunda.connector.e2e.agenticai.assertj.JobWorkerAgentResponseAssert; +import io.camunda.connector.test.utils.annotation.SlowTest; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Wire-format regression test for the Anthropic Messages API. Verifies the full HTTP contract + * between the AI Agent connector and the Anthropic {@code POST /v1/messages} endpoint using + * WireMock. Runs against LangChain4j initially; once ADR 004 native provider layer ships the same + * stubs cover the new implementation. + */ +@SlowTest +public class AnthropicMessagesApiAiAgentJobWorkerTests extends BaseWireFormatAiAgentJobWorkerTest { + + @Override + protected String llmApiPath() { + return "/v1/messages"; + } + + @Override + protected Map elementTemplateProperties() { + return Map.ofEntries( + Map.entry("agentContext", "=agent.context"), + Map.entry("provider.type", "anthropic"), + Map.entry("provider.anthropic.endpoint", "http://localhost:" + wireMockPort + "/v1"), + Map.entry("provider.anthropic.authentication.apiKey", "test-api-key"), + Map.entry("provider.anthropic.model.model", "claude-sonnet-4-6"), + Map.entry( + "data.systemPrompt.prompt", + "=\"You are a helpful AI assistant. Answer all the questions, but always be nice.\""), + Map.entry( + "data.userPrompt.prompt", + "=if (is defined(followUpUserPrompt)) then followUpUserPrompt else userPrompt"), + Map.entry("data.userPrompt.documents", "=[]"), + Map.entry("data.memory.storage.type", "in-process"), + Map.entry("data.response.includeAssistantMessage", "=true"), + Map.entry("data.response.includeAgentContext", "=true")); + } + + @Override + protected MappingBuilder withApiKeyHeaderMatcher(MappingBuilder stub) { + return stub.withHeader("x-api-key", equalTo("test-api-key")); + } + + @Override + protected String toolCallResponseBody() { + return """ + { + "id": "msg_01XBvkPq5k7uHxRo45RsL7Ae", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01JnvVF6Cya67WpVoLMDkEaq", + "name": "SuperfluxProduct", + "input": {"a": 5, "b": 3} + } + ], + "model": "claude-sonnet-4-6", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 100, + "output_tokens": 50 + } + } + """; + } + + @Override + protected String finalResponseBody() { + return """ + { + "id": "msg_02YCwlPr6m8vJySp56SsM8Bf", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "%s" + } + ], + "model": "claude-sonnet-4-6", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 200, + "output_tokens": 30 + } + } + """ + .formatted(RESPONSE_TEXT); + } + + @Test + void executesAgentWithToolCallAgainstAnthropicMessagesApi() throws Exception { + final var zeebeTest = runToolCallScenario(); + + assertAgentResponse( + zeebeTest, + agentResponse -> + JobWorkerAgentResponseAssert.assertThat(agentResponse) + .isReady() + .hasMetrics(EXPECTED_METRICS) + .hasResponseMessageText(RESPONSE_TEXT) + .hasResponseText(RESPONSE_TEXT)); + + assertThat(userFeedbackJobWorkerCounter.get()).isEqualTo(1); + verify(2, postRequestedFor(urlEqualTo(llmApiPath()))); + } +} diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/BaseWireFormatAiAgentJobWorkerTest.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/BaseWireFormatAiAgentJobWorkerTest.java new file mode 100644 index 00000000000..a696be5cae7 --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/BaseWireFormatAiAgentJobWorkerTest.java @@ -0,0 +1,117 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.camunda.connector.e2e.agenticai.aiagent.wireformat; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; + +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.stubbing.Scenario; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.e2e.ZeebeTest; +import io.camunda.connector.e2e.agenticai.aiagent.BaseAiAgentJobWorkerTest; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; + +/** + * Base class for wire-format E2E tests that verify the AI Agent connector against a real HTTP API + * contract, mocked via WireMock. Unlike the Mockito-based tests that intercept at the {@code + * ChatModelFactory} level, these tests drive the full LLM HTTP stack so they remain valid as a + * regression suite when the provider implementation changes (e.g. switching from LangChain4j to the + * native provider layer described in ADR 004). + * + *

Test scenario: single tool call (SuperfluxProduct) followed by a final text response, user + * satisfied on first feedback. + */ +abstract class BaseWireFormatAiAgentJobWorkerTest extends BaseAiAgentJobWorkerTest { + + protected static final String RESPONSE_TEXT = "The SuperfluxProduct of 5 and 3 is 24."; + protected static final String USER_PROMPT = "Calculate the superflux product of 5 and 3"; + + private static final String SCENARIO_NAME = "LLM Tool Call Flow"; + private static final String AFTER_TOOL_CALL_STATE = "AfterToolCall"; + + // Token counts in stub responses: turn1 input=100 output=50, turn2 input=200 output=30 + protected static final AgentMetrics EXPECTED_METRICS = + new AgentMetrics(2, new AgentMetrics.TokenUsage(300, 80)); + + protected int wireMockPort; + + @BeforeEach + void captureWireMockPort(WireMockRuntimeInfo wireMockRuntimeInfo) { + wireMockPort = wireMockRuntimeInfo.getHttpPort(); + } + + /** + * Sets user feedback to "satisfied" before each test so the process completes without a follow-up + * loop. Runs after {@link + * io.camunda.connector.e2e.agenticai.aiagent.BaseAiAgentTest#openUserFeedbackJobWorker()} resets + * the reference to an empty map. + */ + @BeforeEach + void setUserFeedbackToSatisfied() { + userFeedbackVariables.set(userSatisfiedFeedback()); + } + + /** The API path WireMock should intercept, e.g. {@code /v1/messages}. */ + protected abstract String llmApiPath(); + + /** Response body for the first LLM call — must instruct the agent to call SuperfluxProduct. */ + protected abstract String toolCallResponseBody(); + + /** Response body for the second LLM call — final text answer, no tool calls. */ + protected abstract String finalResponseBody(); + + /** + * Adds provider-specific API key header matching to the stub, e.g. {@code x-api-key} for + * Anthropic or {@code Authorization: Bearer} for OpenAI. + */ + protected abstract MappingBuilder withApiKeyHeaderMatcher(MappingBuilder stub); + + protected void stubLlmApiForToolCallThenFinalResponse() { + stubFor( + withApiKeyHeaderMatcher( + post(urlEqualTo(llmApiPath())) + .inScenario(SCENARIO_NAME) + .whenScenarioStateIs(Scenario.STARTED) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(toolCallResponseBody())) + .willSetStateTo(AFTER_TOOL_CALL_STATE))); + + stubFor( + withApiKeyHeaderMatcher( + post(urlEqualTo(llmApiPath())) + .inScenario(SCENARIO_NAME) + .whenScenarioStateIs(AFTER_TOOL_CALL_STATE) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(finalResponseBody())))); + } + + protected ZeebeTest runToolCallScenario() throws Exception { + stubLlmApiForToolCallThenFinalResponse(); + return createProcessInstance(Map.of("userPrompt", USER_PROMPT)).waitForProcessCompletion(); + } +} diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/OpenAiChatCompletionsApiAiAgentJobWorkerTests.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/OpenAiChatCompletionsApiAiAgentJobWorkerTests.java new file mode 100644 index 00000000000..585d8d8cf29 --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/OpenAiChatCompletionsApiAiAgentJobWorkerTests.java @@ -0,0 +1,151 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.camunda.connector.e2e.agenticai.aiagent.wireformat; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import io.camunda.connector.e2e.agenticai.assertj.JobWorkerAgentResponseAssert; +import io.camunda.connector.test.utils.annotation.SlowTest; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Wire-format regression test for the OpenAI Chat Completions endpoint ({@code POST + * /v1/chat/completions}). Uses the {@code openaiCompatible} provider so the test exercises the same + * wire format that the {@code openai} discriminator produces with {@code apiFamily = COMPLETIONS}. + */ +@SlowTest +public class OpenAiChatCompletionsApiAiAgentJobWorkerTests + extends BaseWireFormatAiAgentJobWorkerTest { + + @Override + protected String llmApiPath() { + return "/v1/chat/completions"; + } + + @Override + protected Map elementTemplateProperties() { + return Map.ofEntries( + Map.entry("agentContext", "=agent.context"), + Map.entry("provider.type", "openaiCompatible"), + Map.entry("provider.openaiCompatible.endpoint", "http://localhost:" + wireMockPort + "/v1"), + Map.entry("provider.openaiCompatible.authentication.apiKey", "test-api-key"), + Map.entry("provider.openaiCompatible.model.model", "gpt-4o"), + Map.entry( + "data.systemPrompt.prompt", + "=\"You are a helpful AI assistant. Answer all the questions, but always be nice.\""), + Map.entry( + "data.userPrompt.prompt", + "=if (is defined(followUpUserPrompt)) then followUpUserPrompt else userPrompt"), + Map.entry("data.userPrompt.documents", "=[]"), + Map.entry("data.memory.storage.type", "in-process"), + Map.entry("data.response.includeAssistantMessage", "=true"), + Map.entry("data.response.includeAgentContext", "=true")); + } + + @Override + protected MappingBuilder withApiKeyHeaderMatcher(MappingBuilder stub) { + return stub.withHeader("Authorization", equalTo("Bearer test-api-key")); + } + + @Override + protected String toolCallResponseBody() { + return """ + { + "id": "chatcmpl-abc123", + "object": "chat.completion", + "created": 1728933352, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_xyz789", + "type": "function", + "function": { + "name": "SuperfluxProduct", + "arguments": "{\\"a\\": 5, \\"b\\": 3}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + } + } + """; + } + + @Override + protected String finalResponseBody() { + return """ + { + "id": "chatcmpl-def456", + "object": "chat.completion", + "created": 1728933353, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "%s" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 200, + "completion_tokens": 30, + "total_tokens": 230 + } + } + """ + .formatted(RESPONSE_TEXT); + } + + @Test + void executesAgentWithToolCallAgainstOpenAiChatCompletionsApi() throws Exception { + final var zeebeTest = runToolCallScenario(); + + assertAgentResponse( + zeebeTest, + agentResponse -> + JobWorkerAgentResponseAssert.assertThat(agentResponse) + .isReady() + .hasMetrics(EXPECTED_METRICS) + .hasResponseMessageText(RESPONSE_TEXT) + .hasResponseText(RESPONSE_TEXT)); + + assertThat(userFeedbackJobWorkerCounter.get()).isEqualTo(1); + verify(2, postRequestedFor(urlEqualTo(llmApiPath()))); + } +} diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/OpenAiResponsesApiAiAgentJobWorkerTests.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/OpenAiResponsesApiAiAgentJobWorkerTests.java new file mode 100644 index 00000000000..fec9dbcd42f --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/aiagent/wireformat/OpenAiResponsesApiAiAgentJobWorkerTests.java @@ -0,0 +1,152 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.camunda.connector.e2e.agenticai.aiagent.wireformat; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import io.camunda.connector.e2e.agenticai.assertj.JobWorkerAgentResponseAssert; +import io.camunda.connector.test.utils.annotation.SlowTest; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Wire-format regression test for the OpenAI Responses endpoint ({@code POST /v1/responses}). + * Drives the {@code openai} discriminator with {@code apiFamily = responses}, exercising the native + * {@code OpenAiResponsesChatModelApi} end-to-end against a WireMock-stubbed responses API. + */ +@SlowTest +public class OpenAiResponsesApiAiAgentJobWorkerTests extends BaseWireFormatAiAgentJobWorkerTest { + + @Override + protected String llmApiPath() { + return "/v1/responses"; + } + + @Override + protected Map elementTemplateProperties() { + return Map.ofEntries( + Map.entry("agentContext", "=agent.context"), + Map.entry("provider.type", "openai"), + Map.entry("provider.openai.apiFamily", "responses"), + Map.entry("provider.openai.endpoint", "http://localhost:" + wireMockPort + "/v1"), + Map.entry("provider.openai.authentication.apiKey", "test-api-key"), + Map.entry("provider.openai.model.model", "gpt-5"), + Map.entry( + "data.systemPrompt.prompt", + "=\"You are a helpful AI assistant. Answer all the questions, but always be nice.\""), + Map.entry( + "data.userPrompt.prompt", + "=if (is defined(followUpUserPrompt)) then followUpUserPrompt else userPrompt"), + Map.entry("data.userPrompt.documents", "=[]"), + Map.entry("data.memory.storage.type", "in-process"), + Map.entry("data.response.includeAssistantMessage", "=true"), + Map.entry("data.response.includeAgentContext", "=true")); + } + + @Override + protected MappingBuilder withApiKeyHeaderMatcher(MappingBuilder stub) { + return stub.withHeader("Authorization", equalTo("Bearer test-api-key")); + } + + @Override + protected String toolCallResponseBody() { + return """ + { + "id": "resp_abc123", + "object": "response", + "created_at": 1728933352, + "status": "completed", + "model": "gpt-5", + "parallel_tool_calls": true, + "tool_choice": "auto", + "tools": [], + "output": [ + { + "type": "function_call", + "call_id": "call_xyz789", + "name": "SuperfluxProduct", + "arguments": "{\\"a\\": 5, \\"b\\": 3}" + } + ], + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "total_tokens": 150, + "input_tokens_details": { "cached_tokens": 0 }, + "output_tokens_details": { "reasoning_tokens": 0 } + } + } + """; + } + + @Override + protected String finalResponseBody() { + return """ + { + "id": "resp_def456", + "object": "response", + "created_at": 1728933353, + "status": "completed", + "model": "gpt-5", + "parallel_tool_calls": true, + "tool_choice": "auto", + "tools": [], + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "%s", "annotations": [] } + ] + } + ], + "usage": { + "input_tokens": 200, + "output_tokens": 30, + "total_tokens": 230, + "input_tokens_details": { "cached_tokens": 0 }, + "output_tokens_details": { "reasoning_tokens": 0 } + } + } + """ + .formatted(RESPONSE_TEXT); + } + + @Test + void executesAgentWithToolCallAgainstOpenAiResponsesApi() throws Exception { + final var zeebeTest = runToolCallScenario(); + + assertAgentResponse( + zeebeTest, + agentResponse -> + JobWorkerAgentResponseAssert.assertThat(agentResponse) + .isReady() + .hasMetrics(EXPECTED_METRICS) + .hasResponseMessageText(RESPONSE_TEXT) + .hasResponseText(RESPONSE_TEXT)); + + assertThat(userFeedbackJobWorkerCounter.get()).isEqualTo(1); + verify(2, postRequestedFor(urlEqualTo(llmApiPath()))); + } +} diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/assertj/ToolExecutionResultMessageEqualsPredicate.java b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/assertj/ToolExecutionResultMessageEqualsPredicate.java new file mode 100644 index 00000000000..b4e8ad0ce53 --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/java/io/camunda/connector/e2e/agenticai/assertj/ToolExecutionResultMessageEqualsPredicate.java @@ -0,0 +1,60 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.camunda.connector.e2e.agenticai.assertj; + +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import java.util.Objects; +import java.util.function.BiPredicate; +import org.json.JSONException; +import org.skyscreamer.jsonassert.JSONCompare; +import org.skyscreamer.jsonassert.JSONCompareMode; + +/** + * Compares {@link ToolExecutionResultMessage} instances using JSON-equivalent comparison for the + * text field, falling back to plain string equality for non-JSON content. + */ +public class ToolExecutionResultMessageEqualsPredicate + implements BiPredicate { + + @Override + public boolean test(ToolExecutionResultMessage a, ToolExecutionResultMessage b) { + if (!Objects.equals(a.id(), b.id())) { + return false; + } + + if (!Objects.equals(a.toolName(), b.toolName())) { + return false; + } + + return jsonEquals(a.text(), b.text()); + } + + private static boolean jsonEquals(String a, String b) { + if (Objects.equals(a, b)) { + return true; + } + if (a == null || b == null) { + return false; + } + try { + return !JSONCompare.compareJSON(a, b, JSONCompareMode.STRICT).failed(); + } catch (JSONException e) { + // not valid JSON (and not equal strings, since Objects.equals check above handles that) + return false; + } + } +} diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/author-info.pdf b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/author-info.pdf new file mode 100644 index 00000000000..040c6499629 Binary files /dev/null and b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/author-info.pdf differ diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/headcount-report.pdf b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/headcount-report.pdf new file mode 100644 index 00000000000..823e3d107bc Binary files /dev/null and b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/headcount-report.pdf differ diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/project-launch.pdf b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/project-launch.pdf new file mode 100644 index 00000000000..559b0a96538 Binary files /dev/null and b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/__files/document-tool-call-results/project-launch.pdf differ diff --git a/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/document-tool-call-results.bpmn b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/document-tool-call-results.bpmn new file mode 100644 index 00000000000..311f3cd8877 --- /dev/null +++ b/connectors-e2e-test/connectors-e2e-test-agentic-ai/src/test/resources/document-tool-call-results.bpmn @@ -0,0 +1,105 @@ + + + + + Flow_Start_To_Download + + + + + + + + + + + + + + + + + + + + + + Flow_Start_To_Download + Flow_Download_To_Agent + + + + + + + + + Document analysis agent for CPT viability testing + + + + Flow_Download_To_Agent + Flow_Agent_To_End + + Retrieves a single document for analysis. Returns one PDF document from the pre-downloaded files. + + + + + + Searches for and retrieves multiple documents. Returns a list of two PDF documents from the pre-downloaded files. + + + + + + Fetches a full report containing multiple documents in a nested structure. Returns a map with a summary string, an attachments list with two documents, and a metadata map containing a cover page document. + + + + + + + Flow_Agent_To_End + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/connectors/agentic-ai/AGENTS.md b/connectors/agentic-ai/AGENTS.md index f76b6b225eb..662a00d2a23 100644 --- a/connectors/agentic-ai/AGENTS.md +++ b/connectors/agentic-ai/AGENTS.md @@ -69,8 +69,14 @@ agent/ └── AgentLimitsValidatorImpl # Safety limits (max model calls) framework/ -├── AiFrameworkAdapter # Abstract LLM interface (RuntimeMemory → response) -└── langchain4j/ # LangChain4J implementation +├── api/ # Provider-neutral SPI +│ ├── ChatClient # Facade called by BaseAgentRequestHandler +│ ├── ChatModelApi # Per-job model client: capabilities() + complete(...) +│ ├── ChatModelApiFactory # One bean per provider discriminator +│ └── ChatModelApiRegistry # Resolves provider config → factory +├── ChatClientImpl # Assembles ChatRequest/ChatOptions, dispatches, accumulates metrics +├── ChatModelApiRegistryImpl # Indexes factories by providerType() +└── langchain4j/ # LangChain4j-backed ChatModelApi (one factory bean per provider) memory/ ├── conversation/ diff --git a/connectors/agentic-ai/AI_AGENT.md b/connectors/agentic-ai/AI_AGENT.md index 4e030e21a37..e2a85230d1d 100644 --- a/connectors/agentic-ai/AI_AGENT.md +++ b/connectors/agentic-ai/AI_AGENT.md @@ -144,16 +144,15 @@ leading to the following result "type" : "text", "text" : "This is a sample response text from the AI agent." } ], + "messageId" : "chatcmpl-123", "metadata" : { - "framework" : { - "finishReason" : "STOP", - "id" : "chatcmpl-123", - "tokenUsage" : { - "inputTokenCount" : 5, - "outputTokenCount" : 6, - "totalTokenCount" : 11 - } - } + "timestamp" : "2025-01-15T10:30:00Z" + }, + "modelId" : "gpt-5", + "stopReason" : "STOP", + "usage" : { + "inputTokenCount" : 5, + "outputTokenCount" : 6 } }, "toolCalls" : [ ] @@ -230,6 +229,6 @@ leading to the following result | Connector Info | | | --- | --- | | Type | io.camunda.agenticai:aiagent:1 | -| Version | 10 | +| Version | 12 | | Supported element types | | diff --git a/connectors/agentic-ai/docs/adr-005-implementation-plan.md b/connectors/agentic-ai/docs/adr-005-implementation-plan.md new file mode 100644 index 00000000000..e5a3bdae041 --- /dev/null +++ b/connectors/agentic-ai/docs/adr-005-implementation-plan.md @@ -0,0 +1,725 @@ +# ADR-005 Phase 1 — Incremental Implementation Plan + +## Context + +[ADR-005](connectors/agentic-ai/docs/adr/005-replace-langchain4j-framework.md) replaces the +LangChain4j-backed `AiFrameworkAdapter` with a native provider layer over official vendor SDKs. +The ADR names this "Phase 1 — one shipping unit," but it is large enough to review and merge in +chunks. This plan breaks it into eight green-build checkpoints (Phases A–H) on the working branch. + +**Plan revision (2026-05-07)** — re-ordered to ship two real native providers ahead of the +capability/multimodality infrastructure. Validating the SPI against two divergent wire formats +before generalising leads to a smaller, better-shaped abstraction in Phase E. Reasoning, prompt +caching, Azure OpenAI, Anthropic cloud backends, Google GenAI and Bedrock-Converse are all +explicitly **deferred** out of the first native cut. + +**Plan revision (2026-05-13)** — branch state catch-up. Phase E3+E4 and Phase F have both +landed on `agentic-ai/custom-llm-layer`. The L4J bridge has been narrowed accordingly: native +factories own the `anthropic` (DIRECT backend) and `openai` (apiKey on all three backends) +discriminators; the bridge still serves `bedrock` and `googleGenAi`, plus the +`openai`/FOUNDRY+clientCredentials slot. Phase G (remaining cloud backends and native impls) +and Phase H (default-off bridge demotion) are still open. + +Phase E is split into three sub-phases (E1, E2, E3+E4 all done — see §"Phase E" below). Phase E +is layered on top of PR #6999 (`agentic-ai-document-tool-call-results`); our branch was rebased +onto that PR while it was in active review. + +**Actual state** (`agentic-ai/custom-llm-layer`, 2026-05-13): +- Phase 0 done: `AssistantMessage` gains `modelId`/`apiId`/`stopReason`/`usage`; `TokenUsage` + gains cache and reasoning fields; `ReasoningContent` added to `Content` hierarchy; + `contentBlocks` added to `ToolCallResult`; `AiFrameworkChatResponse#rawChatResponse()` dropped. +- **Phase A done**: SPI under `framework/api/` shipped; `BaseAgentRequestHandler` routes through + `ChatClient`; LangChain4j wired as the bridge `ChatModelApi` for all six provider discriminators + (one factory bean per discriminator). `AiFrameworkAdapter` and `AiFrameworkChatResponse` already + removed from the source tree (ahead of the original Phase F schedule). +- **Phases B / C / D done**: native `AnthropicMessagesChatModelApi` (text + image + PDF after E4), + `OpenAiChatCompletionsChatModelApi`, `OpenAiResponsesChatModelApi`, plus the + `apiFamily` switch on the `openai` discriminator and element template bump 10 → 11. +- **Phase E1 / E2 done**: capability matrix loaded as Spring Boot config (bundled + `model-capabilities.yaml` registered as a low-precedence `PropertySource`); each native impl + now consumes a `ModelCapabilities` resolved at factory time. +- **Phase E3+E4 done**: `ToolCallResultStrategy` ships single-pass per-block routing for every + document in a chat request; native multimodal emission (images + PDFs) wired in all three + native impls; `AgentMessagesHandlerImpl` no longer extracts documents; modality detection + via `DocumentModality` MIME mapping (`Modality.PDF` renamed to `Modality.DOCUMENT`). +- **Phase F done**: `ProviderConfiguration` restructured by wire format. Anthropic gains a + sealed `AnthropicAuthentication` (`apiKey` / `clientCredentials`) plus + `AnthropicBackend { DIRECT, BEDROCK, VERTEX, FOUNDRY }`. OpenAI consolidates the three legacy + configs into a single `OpenAiProviderConfiguration` with `OpenAiBackend { OPENAI, FOUNDRY, + CUSTOM }`, FOUNDRY/CUSTOM `endpoint` required, headers/queryParameters/customParameters + scoped to CUSTOM. Google renamed `GoogleVertexAi` → `GoogleGenAi` with + `GoogleBackend { DEVELOPER_API, VERTEX }`. Bedrock validates non-Anthropic model IDs at + construction. `ProviderConfigurationDeserializer` wired via type-level `@JsonDeserialize` + (not via Jackson `Module`, because the connector runtime's `@ConnectorsObjectMapper` doesn't + do module discovery) rewrites all legacy shapes per the ADR migration table. Element + template bumped 11 → 12 with backend dropdowns + conditional auth groups. +- Wire-format e2e regression tests for the Anthropic Messages API, OpenAI Chat Completions + API, and OpenAI Responses API (WireMock-based) exercise the native impls. +- ADR-005 document + this implementation plan committed. + +**Still open**: +- **Phase G** — native impls for Anthropic cloud backends (BEDROCK / VERTEX / FOUNDRY), + OpenAI FOUNDRY with clientCredentials auth, native Google GenAI, native Bedrock-Converse. + The L4J bridge currently registers factories for `bedrock` and `googleGenAi`; the native + `OpenAiChatModelApiFactory` throws a "Phase G Azure SDK integration" placeholder when + building a FOUNDRY client with clientCredentials. The native + `AnthropicMessagesChatModelApiFactory` rejects any backend other than DIRECT. +- **Phase H** — demote the L4J bridge to opt-in (`camunda.connector.agenticai.framework=langchain4j` + is currently the default; it should default off). ADR status → Implemented. +- Deferred capabilities: **reasoning** (signed thinking blocks, encrypted reasoning items), + **prompt caching** (`cache_control` markers, `prompt_cache_key`), **audio + video modalities**, + **JDK `java.net.http.HttpClient` adapter** (replacing OkHttp transport). + +**Target end state**: `BaseAgentRequestHandler` calls `ChatClient`. Native `ChatModelApi` impls +ship for Anthropic Messages (DIRECT + cloud backends), OpenAI Chat Completions, OpenAI +Responses (OpenAI + Foundry + Custom), Google GenAI, and Bedrock-Converse. LangChain4j survives +only as an opt-in bridge. + +--- + +## Architecture overview + +``` +BaseAgentRequestHandler + │ + ▼ + ChatClient (facade) resolves factory, applies ToolCallResultStrategy + │ updates agentContext metrics, returns ChatClientResult + ▼ +ChatModelApiRegistry Map + │ + ▼ +ChatModelApiFactory singleton bean per wire-protocol family (or bridge) + │ create(config) → ChatModelApi (per-job) + ▼ +ChatModelApi capabilities() + complete(request, options, listener) + │ + ▼ + Vendor SDK Anthropic / AWS Bedrock / OpenAI / Google GenAI +``` + +**Data flow through ChatClient.chat():** +1. Resolve factory from `executionContext.provider()` via registry → `ChatModelApi` +2. Query `ChatModelApi.capabilities()` → `ModelCapabilities` +3. Apply `ToolCallResultStrategy` to route `toolCallResults.contentBlocks` (native vs fallback) +4. Build `ChatRequest` (messages + tools + response format) + `ChatOptions` (cache, reasoning) +5. `chatModelApi.complete(request, options, NOOP_LISTENER)` → `CompletableFuture` +6. Wrap into `ChatClientResult` (updated `agentContext` + `assistantMessage`) + +--- + +## SPI types (all in `framework/api/`) + +Defined in Phase A; referenced by all subsequent phases: + +| Type | Role | +|------|------| +| `ChatRequest` | `List messages`, `List toolDefinitions`, `ResponseFormatConfiguration responseFormat` | +| `ChatResponse` | `AssistantMessage assistantMessage` (carries `stopReason`, `usage` etc. per Phase 0) | +| `ChatOptions` | `@Nullable Integer maxOutputTokens`, `@Nullable ReasoningConfig reasoning`, `@Nullable CacheRetention cacheRetention`, `Map providerOptions` | +| `CacheRetention` | enum `NONE \| SHORT \| LONG` | +| `ReasoningConfig` | sealed: `ReasoningEffort(Effort)`, `ReasoningBudget(int)`, `ReasoningDisabled` | +| `ModelCapabilities` | modality lists per location, `supports_*` flags, context window, max output tokens | +| `ChatModelEvent` | sealed hierarchy per ADR §"Stream event hierarchy" | +| `ChatStreamListener` | single method per event type; static `NOOP` constant | +| `ChatModelApi` | `ModelCapabilities capabilities()`, `CompletableFuture complete(ChatRequest, ChatOptions, ChatStreamListener)` | +| `ChatModelApiFactory` | `String providerType()`, `Class configurationType()`, `ChatModelApi create(C config)` | +| `ChatModelApiRegistry` | `Map>` keyed by `providerType()` string; mirrors `ChatModelProviderRegistry` exactly | +| `ChatClient` | `ChatClientResult chat(AgentExecutionContext, AgentContext, RuntimeMemory)` | +| `ChatClientResult` | `AgentContext agentContext()`, `AssistantMessage assistantMessage()` — replaces `AiFrameworkChatResponse` in call sites | + +**Registry dispatch** mirrors `ChatModelProviderRegistry`: +`providerConfiguration.providerType()` → factory lookup → `factory.create(config)`. +The bridge `Langchain4JChatModelApiFactory` is parameterised on a single discriminator (`providerType()` returns one string) and registered as **one Spring bean per discriminator** in `AgenticAiLangchain4JFrameworkConfiguration` — six beans total, each named `langchain4JChatModelApiFactory` and gated with `@ConditionalOnMissingBean(name = ...)` so a customer or a native impl can replace any individual one. The registry collects all `ChatModelApiFactory` beans and indexes by `providerType()`; duplicate discriminators fail at startup. Phase E swaps each bridge bean out by registering a native factory under the same name. + +--- + +## Phase A — Bridge cutover (behavior-identical) + +**Goal**: every chat call routes through the new SPI. LangChain4j is still the only +implementation. Proves SPI shape is right before any native provider lands. + +**Files to create** (`framework/api/`): +- All SPI types listed above (interfaces + records, no impls) + +**Files to create** (`framework/langchain4j/`): +- `Langchain4JChatModelApi` + `Langchain4JChatModelApiFactory` — + the API wraps a pre-resolved L4J `ChatModel` and translates `ChatRequest`/`ChatOptions` to/from + the L4J shape; the factory is parameterised on a single discriminator and produced as one bean + per provider in `AgenticAiLangchain4JFrameworkConfiguration` (six beans total). Returns + conservative `ModelCapabilities` (text-only, no caching/reasoning, parallel tool calls true, + null context window) from `capabilities()`. +- `ChatModelApiRegistryImpl` — identical structure to `ChatModelProviderRegistry` (lines 16–57). +- `ChatClientImpl` — steps 1–6 from the data-flow above; wraps the future with `.get()` (sync for + now, async for Phase C+); extracts `assistantMessage` → updates `agentContext.metrics`. + +**Files to modify**: +- `BaseAgentRequestHandler`: field `AiFrameworkAdapter framework` → `ChatClient chatClient`; + lines 171–172: `framework.executeChatRequest(...)` → `chatClient.chat(...)`. Return type changes + from `AiFrameworkChatResponse` to `ChatClientResult` — update 3 usages on lines 173–178. +- `AgenticAiConnectorsAutoConfiguration`: inject `ChatClient` instead of `AiFrameworkAdapter` + into the two request handler beans; add beans for `ChatModelApiRegistry` and `ChatClientImpl`. + The LangChain4j config still produces the `Langchain4JChatModelApiFactory` bean. +- `OutboundConnectorAgentRequestHandlerTest` + `JobWorkerAgentRequestHandlerTest`: change + `@Mock AiFrameworkAdapter` → `@Mock ChatClient`; update stub return type. + +**Tests to add**: +- `ChatModelApiRegistryImplTest` — mirrors `ChatModelProviderRegistryTest` (resolution, duplicate + detection, missing-key error). +- `ChatClientImplTest` — verifies request assembly (messages from `RuntimeMemory`, tools from + `AgentContext`), dispatches to the mock `ChatModelApi`, updates `agentContext.metrics`. + +**Verification**: +```bash +mvn clean test -pl connectors/agentic-ai +mvn test -pl connectors-e2e-test/connectors-e2e-test-agentic-ai \ + -Dtest="AnthropicMessagesApiAiAgentJobWorkerTests,OpenAiResponsesApiAiAgentJobWorkerTests" +``` + +--- + +## Scope deferred out of native cut (Phases B–D) + +The first native cut deliberately ignores these — they re-enter in Phase E or G: + +| Topic | Re-enters | +|-------|-----------| +| Capability matrix YAML + resolver | E1 (done) | +| `ChatModelApi.capabilities()` resolved per call | E2 (done) | +| `ToolCallResultStrategy` (always inline-text in B–D) | E3+E4 (combined) | +| Multimodal user-message / tool-result content — **image + PDF only** | E3+E4 (combined) | +| Multimodal — audio / video | G+ (when matching native impls land) | +| Reasoning content (signed thinking blocks, encrypted reasoning items) | post-E (own sub-phase) | +| Prompt caching (`cache_control`, `prompt_cache_key`) | post-E (own sub-phase) | +| JDK `java.net.http.HttpClient` adapter for the Anthropic / OpenAI SDKs (replaces OkHttp transport) | post-E | +| Azure OpenAI native impl | G | +| Anthropic cloud backends (Bedrock / Vertex / Foundry) | G | +| Google GenAI native impl | G | +| Bedrock-Converse native impl (non-Anthropic models) | G | +| `ProviderConfiguration` discriminator restructure + Jackson migration | F | + +Under this scope each native impl returns a hardcoded `ModelCapabilities` (text-only, no +reasoning, no caching, parallel tool calls true). `ChatOptions.cacheRetention` and +`ChatOptions.reasoning` are accepted but ignored. `ToolCallResult.contentBlocks` carries text +parts only — image / PDF / audio / video parts get rejected (or pass through unchanged where the +existing handler already drops them) until Phase E lands the strategy. + +--- + +## Phase B — Native `anthropic-messages` (direct backend, text-only) + +**Goal**: replace the L4J bridge for the `anthropic` discriminator with a direct vendor-SDK impl. +Validates streaming-first internal pattern + content-block accumulation against one wire format. + +**Files to create** (`framework/anthropic/`): +- `AnthropicMessagesChatModelApi` — drives `anthropic-java` SDK streaming endpoint. Accumulates + content blocks by index, materialises `AssistantMessage`, maps `stop_reason` → `StopReason`, + populates `TokenUsage` (cache / reasoning fields left at zero). Emits `ChatModelEvent`s as the + stream progresses. Reasoning blocks parsed structurally but **dropped** in this phase (or kept + as opaque text content per ADR — we'll decide during impl). Error mapping: model-side terminal + → `ChatResponse{stopReason=ERROR}`; transport/auth → exceptional future with + `ConnectorException(ERROR_CODE_FAILED_MODEL_CALL, ...)`. +- `AnthropicMessagesChatModelApiFactory` — `providerType() = AnthropicProviderConfiguration.ANTHROPIC_ID`; + `configurationType() = AnthropicProviderConfiguration.class`. Direct backend only. +- `AnthropicMessagesApiConfiguration` — `@ConditionalOnClass(AnthropicClient.class)` Spring config + bean. Registers the factory under bean name `langchain4JAnthropicChatModelApiFactory` (same name + as the bridge bean), so `@ConditionalOnMissingBean(name = ...)` in + `AgenticAiLangchain4JFrameworkConfiguration` skips registering the bridge for this discriminator. + +**Files to modify**: +- `connectors/agentic-ai/pom.xml`: add `com.anthropic:anthropic-java` SDK. +- `AgenticAiConnectorsAutoConfiguration`: `@Import(AnthropicMessagesApiConfiguration.class)`. + +**Tests to add**: +- `AnthropicMessagesChatModelApiTest` — mocked SDK client; verify message conversion, tool + conversion, content-block accumulation, stop-reason mapping, usage accounting, error path. +- `AnthropicMessagesChatModelApiFactoryTest` — providerType/configurationType wiring. + +**Verification**: `mvn clean test -pl connectors/agentic-ai`; the existing +`AnthropicMessagesApiAiAgentJobWorkerTests` wire-format e2e should pass against the native impl +(skip if it asserts on bridge-specific behaviour — flag for later). + +--- + +## Phase C — Native OpenAI Chat Completions (`openai` + `openaiCompatible`) + +**Goal**: replace the L4J bridge for `openai` and `openaiCompatible` discriminators. Azure +deferred to G. + +**Files to create** (`framework/openai/`): +- `OpenAiChatCompletionsChatModelApi` — drives `openai-java` SDK chat-completions endpoint + (streaming-first internal). Same accumulation / stop-reason / error patterns as Phase B's + Anthropic impl. +- `OpenAiToolConverter` — small shared converter: `ToolDefinition` → openai-java `ChatTool` + (JSON schema). Lives in `framework/openai/` — reused by Phase D's Responses impl. +- `OpenAiChatModelApiFactory` — registered under bean name + `langchain4JOpenAiChatModelApiFactory`. Builds an `OpenAIClient` from + `OpenAiProviderConfiguration` and instantiates `OpenAiChatCompletionsChatModelApi`. + (Phase D wraps an apiFamily branch around the impl-class choice.) +- `OpenAiCompatibleChatModelApiFactory` — registered under bean name + `langchain4JOpenAiCompatibleChatModelApiFactory`. Builds an `OpenAIClient` with custom baseUrl + and optional auth, hands to `OpenAiChatCompletionsChatModelApi`. +- `OpenAiChatModelApiConfiguration` — `@ConditionalOnClass(OpenAIClient.class)` Spring config bean + registering both factories. + +**Files to modify**: +- `connectors/agentic-ai/pom.xml`: add `com.openai:openai-java` SDK. +- `AgenticAiConnectorsAutoConfiguration`: `@Import(OpenAiChatModelApiConfiguration.class)`. + +**Tests to add**: +- `OpenAiChatCompletionsChatModelApiTest` — mocked SDK client; conversion + accumulation + + errors. +- `OpenAiChatModelApiFactoryTest` / `OpenAiCompatibleChatModelApiFactoryTest`. +- `OpenAiToolConverterTest` — JSON schema fidelity. + +**Verification**: `mvn clean test -pl connectors/agentic-ai`. The existing +`OpenAiChatCompletionsApiAiAgentJobWorkerTests` (or analogous wire-format e2e) should pass. + +--- + +## Phase D — Add OpenAI Responses (`apiFamily` switch on `openai`) + +**Goal**: ship native `openai-responses` without doing the Phase F config restructure. +Azure stays bridged; openaiCompatible stays Completions-only. + +**Files to create** (`framework/openai/`): +- `OpenAiResponsesChatModelApi` — drives `openai-java` SDK responses endpoint. Handles input-item + shape, output-item shape, and the different streaming format. Reuses `OpenAiToolConverter` for + the `tools[]` JSON schema. Encrypted reasoning items parsed structurally but dropped in this + phase (Phase E re-enables roundtripping). + +**Files to modify**: +- `OpenAiProviderConfiguration`: + - Add `ApiFamily { COMPLETIONS, RESPONSES }`. + - Add `apiFamily` field, defaulting to `COMPLETIONS` so saved process state from v10 element + template instances deserializes unchanged. **No Jackson migration deserializer** needed for + this — the canonical restructure (with multi-row migration table) is Phase F. +- `OpenAiChatModelApiFactory` (from Phase C): branch on `config.apiFamily()`. Same client built + from auth/baseUrl is handed to either impl class. +- `element-templates/agenticai-aiagent-outbound-connector.json`: bump `version` 10 → 11; add + `apiFamily` dropdown (Completions / Responses) under the OpenAI provider section. Maven's + `gmavenplus` step regenerates the versioned snapshot and the job-worker template automatically. +- `element-templates/README.md`: update top row for AI Agent (Task + Sub-process tables, both + reflect v11) per AGENTS.md §"Version index README" rule. + +**Tests to add**: +- `OpenAiResponsesChatModelApiTest` — mocked SDK client; input-item / output-item conversion; + tool-call extraction; streaming accumulation; errors. +- `OpenAiChatModelApiFactoryTest`: extend with `apiFamily=RESPONSES` branch. +- `OpenAiProviderConfigurationTest`: round-trip a config without `apiFamily` (defaults applied). + +**Verification**: `mvn clean install -pl connectors/agentic-ai` (regenerates element templates); +verify `versioned/agenticai-aiagent-outbound-connector-10.json` exists alongside the new v11 file +in the main folder. The existing `OpenAiResponsesApiAiAgentJobWorkerTests` wire-format e2e should +pass against the native impl. + +--- + +## Phase E — Capability matrix + tool-result strategy + multimodality (done) + +**Plan revision (2026-05-07)** — Phase E is split into three sub-phases. E1 + E2 ship as +independent commits; **E3 and E4 ship as one combined commit** because the strategy and the +native multimodal emission depend on each other (the bundled matrix declares modalities that +the impls have to actually emit, otherwise `ToolCallResult.contentBlocks` entries would be +silently dropped between phases). Reasoning and prompt caching are explicitly **deferred out +of Phase E** to keep the cut focused; the matrix already declares the flags so the slot is +reserved. + +**Branch state (2026-05-13)**: all three sub-phases are landed on +`agentic-ai/custom-llm-layer`. E1 + E2 sections kept as built. E3+E4 description below +matches what actually shipped: single-pass `ToolCallResultStrategyImpl`, native multimodal +emission for images + PDFs on all three native impls, removal of the +`createDocumentMessageForToolResults` extraction site in `AgentMessagesHandlerImpl`, +`Modality.PDF` renamed to `Modality.DOCUMENT`. + +Phase E is layered on top of the work from PR #6999 (`agentic-ai-document-tool-call-results`), +which contributes the `ToolCallResultDocumentExtractor` (recursive walker over lists / maps / +MCP-shaped content), per-handler extraction hooks on `GatewayToolHandler`, the synthetic +`UserMessage` injection with `METADATA_TOOL_CALL_DOCUMENTS`, the XML document tags inserted in +tool result message text, and the window-count handling that excludes synthetic document +messages. Our branch was rebased onto that PR while it was in active review. + +### Sub-phase E1 — Capability matrix + resolver (done) + +Spring-Boot-native configuration: bundled YAML registered as a low-precedence +`PropertySource` via `EnvironmentPostProcessor`, library consumers override via their own +`application.yml` under the same prefix. + +**Configuration prefix**: `camunda.connector.agenticai.aiagent.framework.capabilities`. + +**Bundled YAML location**: `resources/capabilities/model-capabilities.yaml`. + +**Structure under `capabilities`** (each api family): +- `defaults`: capability block applied to every model entry in the family +- `models`: map of opaque identifiers → entries. Each entry has one of: + - explicit `id` (defaults to the map key when neither field is set), or + - explicit `pattern` — string OR list of strings, glob using `*` only. + Plus an optional `aliases` list (id entries only) and a `capabilities` overlay. + + Note: `*` and `.` cannot appear in the map key (Spring Boot's `MapBinder` strips them). + Pattern entries always declare the glob in the `pattern` field while the map key stays a + stable, override-friendly identifier. + +**Merge semantics** (Spring Boot config + ADR-005 capability matrix): +- Maps merge recursively (sub-keys of `input-modalities` / `output-modalities` are inherited + individually) +- Lists replace wholesale +- Scalars and booleans replace + +**Resolution chain**: +1. Connector config override (per-call, future hook on `ChatOptions`) +2. Exact id or alias match +3. Pattern (longest matching glob across entries; entry score = longest matching glob in its + pattern list) +4. Conservative defaults (text-only, all flags false) + +**Files added** (`framework/capabilities/`): +- `AgenticAiFrameworkProperties` — `@ConfigurationProperties` record (sparse fields) +- `ModelCapabilitiesYaml` — sparse capability block bound by Spring Boot, projected onto + `ModelCapabilities` after deep-merge +- `CapabilityMatrixEnvironmentPostProcessor` — registers the bundled YAML at lowest precedence +- `CapabilityMatrixFactory` — derives `id`/`pattern` from the entry shape, validates entries, + converts capability sub-trees to `JsonNode` for the resolver +- `CapabilityMatrix` — built matrix used by the resolver +- `ModelCapabilitiesResolver` — 4-step chain with INFO logs on pattern / default fall-throughs +- `AgenticAiCapabilitiesConfiguration` — Spring config wiring + +**Tests added**: `ModelCapabilitiesResolverTest` (13 unit cases), +`BundledCapabilityMatrixTest` (Spring `ApplicationContextRunner` integration, 9 cases), +`CapabilityMatrixOverrideTest` (override deep-merge via `withPropertyValues`, 4 cases). + +### Sub-phase E2 — Wire `ChatModelApi.capabilities()` through the resolver (done) + +Each native impl (`AnthropicMessagesChatModelApi`, +`OpenAiChatCompletionsChatModelApi`, `OpenAiResponsesChatModelApi`) accepts a +`ModelCapabilities` via constructor instead of holding a hardcoded conservative profile. The +factories (`AnthropicMessagesChatModelApiFactory`, `OpenAiChatModelApiFactory`, +`OpenAiCompatibleChatModelApiFactory`) take a `ModelCapabilitiesResolver` dependency and resolve +at `create()` time. The OpenAI native factory branches on `OpenAiConnection.apiFamily()` for the +resolver lookup (`openai-completions` vs. `openai-responses`); the openaiCompatible factory +always resolves under `openai-completions`. The L4J bridge keeps its own conservative defaults +(it stays as a fallback path; replacing it through the resolver is not worth the change at this +point). + +### Sub-phase E3 + E4 — `ToolCallResultStrategy` + native multimodal emission (done) + +**Goal**: single-pass per-block routing for every document in a chat request (E3), plus +the native multimodal emission paths in each provider impl that consume the routed +`ToolCallResult.contentBlocks` and emit images / PDFs on the wire (E4). Shipped together so +the bundled capability matrix is honest — every modality the matrix declares for a family +has a working emitter on the same commit. + +> **TODO (post-Phase E):** revisit the `ChatClient`↔`BaseAgentRequestHandler` boundary +> once E3+E4 land. `ChatClient` will then own three responsibilities (request assembly, +> strategy routing with memory mutation, dispatch + metrics). If routing complexity grows +> further, consider extracting routing into a step `BARQ` owns directly, or collapsing +> `ChatClient` into `BARQ`. Don't chase this in Phase E. + +**Files to create** (`framework/strategy/`): +- `ToolCallResultStrategy` — interface. Single method: + ```java + StrategyResult apply(ChatRequest request, ModelCapabilities capabilities); + ``` + with `record StrategyResult(ChatRequest request, List syntheticContextMessages)`. +- `ToolCallResultStrategyImpl` — single-pass walker. Per document found via the existing + `ContentTreeDocumentWalker` / per-handler `extractDocuments` hook (PR #6999 reused + unchanged), routes: + 1. **Tool-result-message docs** vs. `capabilities.toolResultModalities()`: + - `INLINE`: append `DocumentContent` to `ToolCallResult.contentBlocks`; document stays + in the tree for textual rendering. + - `FALLBACK`: replace document with `DocumentXmlTag.from(doc, toolCallId, toolName).toXml()` + inline, append the original `DocumentContent` to a per-message bucket. After the + walk, emit one synthetic `UserMessage` per affected tool-result message — header + text + XML tag + DocumentContent pairs, `METADATA_TOOL_CALL_DOCUMENTS=true`. Same + shape as PR #6999's `createDocumentMessageForToolResults` output. + 2. **User-message / event-message docs** vs. `capabilities.userMessageModalities()`: + - `SUPPORTED`: leave the document where it sits (today's inlining in the same + `UserMessage` content list — no change). + - `UNSUPPORTED`: throw `ConnectorException(ERROR_CODE_FAILED_MODEL_CALL, ...)` with + a message naming the document, modality, and resolved model. + +**Files to modify**: +- `ChatClientImpl` — inject `ToolCallResultStrategy`. After `registry.resolve(provider)` + and before `api.complete(...)`: + ```java + var capabilities = api.capabilities(); + var initialRequest = new ChatRequest(runtimeMemory.filteredMessages(), ...); + var routed = strategy.apply(initialRequest, capabilities); + routed.syntheticContextMessages().forEach(runtimeMemory::addMessage); + var chatResponse = joinChat(api.complete(routed.request(), options, listener)); + ``` +- `AgentMessagesHandlerImpl` — **remove** the `documentExtractor` field, the + `ToolCallResultDocumentExtractor` constructor parameter, the + `createDocumentMessageForToolResults` private method, and the call from `addUserMessages` + (line 134 in the post-rebase code). Tool-result messages now reach `ChatClientImpl` with + unmodified content trees and empty `ToolCallResult.contentBlocks`. +- `AgenticAiConnectorsAutoConfiguration` — drop the `ToolCallResultDocumentExtractor` + argument from the `AgentMessagesHandlerImpl` bean wiring; add a `ToolCallResultStrategy` + bean and inject into `ChatClientImpl`. The `ToolCallResultDocumentExtractor` bean stays + (now consumed by `ToolCallResultStrategyImpl`). + +**Behavior preserved on the bridge path**: Bridge-served providers report a conservative +capability profile (`tool-result: [text]`, `user-message: [text]`); every document falls +back to the synthetic `UserMessage`, identical to today's PR #6999 output. Bridge e2e +tests stay green without modification. + +**Tests to add**: +- `ToolCallResultStrategyImplTest` — pure-function table-driven cases. Covers: + - Tool-result image with `tool-result: [text, image, document]` → `INLINE`, no synthetic. + - Tool-result PDF with `tool-result: [text, image]` → `FALLBACK`, one synthetic UM with + one DocumentContent; XML placeholder substituted in tool result body. + - Mixed result (one image + one PDF in same tool result, capability `[text, image]`) → + image inline, PDF fallback, single synthetic UM with the PDF only. + - Multiple tool-result messages in one request, each with documents → one synthetic UM + per affected tool-result message, ordered. + - User-message PDF with `user-message: [text, image]` → throws `ConnectorException` + naming the doc + modality + model. + - Event-message image with `user-message: [text]` → throws. + - User-message text only → no-op, same `ChatRequest` returned. + - Nested documents in MCP / A2A tool result content (delegated to per-handler + `extractDocuments`) — image inline, PDF fallback works inside lists/maps. +- `AgentMessagesHandlerImplTest` — adjust existing test cases that asserted on the synthetic + `UserMessage`: those move to `ToolCallResultStrategyImplTest`. Add a new test asserting + `addUserMessages` no longer produces a synthetic UM (extraction is no longer its + responsibility). +- `ChatClientImplTest` — extend with: (a) strategy invoked with resolved capabilities, + (b) returned synthetic messages added to `RuntimeMemory` before dispatch, (c) request + passed to `api.complete` is the strategy's modified request. + +**Multimodal emission (E4 portion)** — scope matches L4J parity +(`DocumentToContentConverterImpl` supports text + image + PDF). Audio and video are **out of +scope for the combined E3+E4 phase**; the capability matrix slot remains for Phase G+. + +- **Modality detection**: shared utility maps Camunda `Document.metadata().contentType()` + (MIME) → `ModelCapabilities.Modality`. Sealed `Content` hierarchy stays as-is — no new + `ImageContent` / `PdfContent` subtypes; dispatch is MIME-based at conversion time. Used by + both the strategy (capability check) and the per-impl emitters (block construction). +- **Anthropic Messages**: `ContentBlockParam.ofImage(...)` (base64 data + media_type) and + `ContentBlockParam.ofDocument(...)` for PDFs in user messages; + `ToolResultBlockParam.Content.Block.ofImage(...)` and `.ofDocument(...)` for tool results. +- **OpenAI Chat Completions**: `ChatCompletionContentPart.ofImageUrl(...)` with data URL on + user messages. Tool messages are text-only (SDK enforces + `ChatCompletionContentPartText`-only for tool message content arrays). Bundled matrix: + `tool-result: [text]` for openai-completions — strategy always falls back tool-result + documents on this family. +- **OpenAI Responses**: `ResponseInputContent.ofInputImage(...)` (base64) and + `ofInputFile(...)` (PDF base64) on user messages. Multimodal tool results via + `ResponseInputItem.FunctionCallOutput.Output.ofResponseFunctionCallOutputItemList(...)`. + +**User-message capability mismatch** (image in user prompt for a text-only model): the +strategy fails loud with `ConnectorException(ERROR_CODE_FAILED_MODEL_CALL, ...)` and a clear +message — this is the strategy's responsibility (E3); native impls do **not** consult +capabilities and emit blindly. + +**Multimodal tests** (in addition to the strategy tests above): +- `AnthropicMessagesChatModelApiTest` — image + PDF in user message; image + PDF in tool + result; mixed text + image + PDF tool-result content list. +- `OpenAiResponsesChatModelApiTest` — same coverage as Anthropic. +- `OpenAiChatCompletionsChatModelApiTest` — image in user message; assert tool-result with + any non-text block routes via the strategy fallback (no inline emission attempted). +- Wire-format e2e per native family that exercises the inline-native path; existing PR #6999 + e2e covers the fallback path. + +### Deferred out of Phase E + +- **Reasoning** (extended thinking blocks, signed reasoning roundtrip, encrypted reasoning + items): kept on the matrix flag list but no impl work in E. Phase F or its own sub-phase. +- **Prompt caching** (`cache_control` markers, `prompt_cache_key`): same. +- **JDK `java.net.http.HttpClient` adapter** (replacing OkHttp): same — purely a transport + swap, no behavioural change. +- **Audio + video modalities**: Phase G+ when the corresponding native impl lands (gpt-4o-audio + on Completions, Gemini for video). + +**Verification**: `mvn clean test -pl connectors/agentic-ai` after each sub-phase; the three +wire-format e2e tests stay green; new multimodal cases under `wireformat/` exercise the +inline-native path in E4. + +--- + +## Phase F — `ProviderConfiguration` restructure + Jackson migration (done) + +**Goal**: introduce the canonical discriminator scheme without breaking saved process state. +Element template version bump 11 → 12 (after D's bump). + +**Branch state (2026-05-13)**: landed across commits `04150588f` → `77b5c9163`. The section +below describes the as-built result; everything aligns with the original design except for one +deliberate wiring choice: the migration deserializer is registered via type-level +`@JsonDeserialize` on `ProviderConfiguration` instead of via a Jackson `Module`, because the +connector runtime's `@ConnectorsObjectMapper` composes a hard-coded module list (no `Module` +bean discovery) — see the wiring note in the deserializer block below. + +**Files to modify / create**: +- `AnthropicProviderConfiguration`: add `AnthropicBackend { DIRECT, BEDROCK, VERTEX, FOUNDRY }`; + conditional auth fields per backend. `AnthropicAuthentication` is converted from a flat record + to a `sealed interface` with a `type` discriminator — same pattern as `AzureAuthentication` in + `AzureOpenAiProviderConfiguration`. Two non-platform variants: + - `AnthropicApiKeyAuthentication(@NotBlank String apiKey)` — preserves the current single field. + Valid for **DIRECT** and **FOUNDRY** backends. + - `AnthropicClientCredentialsAuthentication(@NotBlank String clientId, @NotBlank String clientSecret, + @NotBlank String tenantId, String authorityHost)` — exact field-for-field mirror of + `AzureClientCredentialsAuthentication` (`authorityHost` optional). Valid for **FOUNDRY** only. + Backend × auth validity enforced at construction time: reject `(DIRECT, clientCredentials)` and + any `(BEDROCK | VERTEX, apiKey | clientCredentials)` pairing with a clear error. BEDROCK and + VERTEX auth shapes (AWS credentials chain and Google service-account / ADC) follow the patterns + established in `BedrockProviderConfiguration` and `GoogleVertexAiProviderConfiguration`. +- `OpenAiProviderConfiguration`: consolidates the three existing OpenAI-family configs into one. + - Add `OpenAiBackend { OPENAI, FOUNDRY, CUSTOM }` enum + `backend` field. + - `apiFamily` (already added in Phase D) stays and is present for all backends. + - Auth becomes conditional on backend: + - `OPENAI`: existing apiKey (+ optional organizationId, projectId). + - `FOUNDRY`: apiKey **or** clientCredentials (Entra ID / Client Credentials, same shape as the + existing `AzureAuthentication` sealed type). + - `CUSTOM`: apiKey (optional). + - `endpoint`: required for FOUNDRY and CUSTOM backends. + - `headers`, `queryParameters`, `customParameters` (`Map`): CUSTOM backend only, + carried over from `OpenAiCompatibleProviderConfiguration`. + - Discriminator stays `openai`; model type stays `OpenAiModel`. +- **Remove** `AzureOpenAiProviderConfiguration` and `OpenAiCompatibleProviderConfiguration` as + sealed interface members; drop their `@JsonSubTypes` entries. Existing Java source files are + deleted; the migration deserializer handles their saved shapes. +- **Remove** `OpenAiCompatibleChatModelApiFactory`: its logic (custom base URL + optional API key) + folds into `OpenAiChatModelApiFactory` under the `CUSTOM` backend branch. The `OpenAiChatModelApiFactory` + now branches on `backend` (to build the right `OpenAIClient`) and then on `apiFamily` (to pick + the impl class). +- `GoogleVertexAiProviderConfiguration` → `GoogleGenAiProviderConfiguration`: add + `GoogleBackend { DEVELOPER_API, VERTEX }`; rename discriminator `googleVertexAi` → `googleGenAi`. +- `BedrockProviderConfiguration`: validate at construction that model ID is non-Anthropic + (forwarding to the Anthropic factory if it is). +- New `ProviderConfigurationDeserializer extends StdDeserializer`: + pre-processes JSON node before delegating. Migration rules per ADR table. Pattern: + `JsonSchemaElementDeserializer.java:52` (tree-walking dispatch). **Wire via type-level + `@JsonDeserialize(using = ProviderConfigurationDeserializer.class)` on the abstract + `ProviderConfiguration` itself — not via a Jackson `Module`.** The connector runtime composes + `@ConnectorsObjectMapper` in `ConnectorsAutoConfiguration` with a hard-coded module list and + exposes no `Module` bean discovery, so module-based registration from agentic-ai isn't possible. + Type-level `@JsonDeserialize` works on any `ObjectMapper` without contributing to a global module + list. Serialization continues to use the standard `@JsonTypeInfo` / `@JsonSubTypes` mechanism on + the new shape — `@JsonDeserialize` only affects the read path. Inside the deserializer, dispatch + to concrete subtypes via `mapper.treeToValue(migrated, SubType.class)`; that doesn't re-enter the + custom deserializer because Jackson's `BeanDeserializer` for the resolved subtype takes over. +- `element-templates/agenticai-aiagent-outbound-connector.json`: bump 11 → 12. The three separate + OpenAI provider entries (`openai`, `azureOpenAi`, `openaiCompatible`) are collapsed into one + `openai` entry with a `backend` dropdown (OpenAI / Azure AI Foundry / Custom). Anthropic gains a + `backend` dropdown (Direct / Bedrock / Vertex / Foundry). Google is renamed and gains a `backend` + dropdown (Developer API / Vertex). Auth fields are conditionally shown per backend. Maven + regenerates the versioned snapshot + job-worker template. +- `element-templates/README.md`: replace top row again (or insert new row if Camunda min version + changes). + +**Tests to add**: +- `ProviderConfigurationDeserializerTest` — every row of the migration table round-trips to new + shape; forward serialization writes new shape; idempotence (deserialize old → re-serialize → + re-deserialize gives the same canonical instance). +- One test that round-trips a `ProviderConfiguration` containing a `Document`-typed field through + the actual `@ConnectorsObjectMapper` (with `JacksonModuleDocumentDeserializer` and + `JacksonModuleFeelFunction` registered) to confirm the type-level `@JsonDeserialize` doesn't + collide with the runtime modules already layered on the mapper. + +**Verification**: `mvn clean install -pl connectors/agentic-ai`; manual inspect +`versioned/agenticai-aiagent-outbound-connector-11.json` created; deserialization smoke test +covers a saved `agentContext` from a v10/v11 instance. + +--- + +## Phase G — Remaining native impls + +**Goal**: native impls for the last four families. Each follows the Phase B pattern. + +1. **Anthropic cloud backends** — extend `AnthropicMessagesChatModelApiFactory` to honour + `backend = bedrock | vertex | foundry` (SDK modules: `anthropic-java-bedrock`, + `anthropic-java-vertex`, `anthropic-java-foundry`). Same wire format; only client construction + differs. +2. **OpenAI FOUNDRY backend** — extend `OpenAiChatModelApiFactory` to build an Azure-flavored + `OpenAIClient` when `config.backend() == FOUNDRY` (API key or Entra/AAD clientCredentials auth, + endpoint from config). Hands to the existing `OpenAiChatCompletions` / `OpenAiResponses` impl + classes; branches on `config.apiFamily()` as today. No separate factory class needed. (If + openai-java's Azure variant lags Responses endpoint coverage, FOUNDRY stays Completions-only for + one release.) +3. **Native Google GenAI** — `google-genai-java` SDK; backend toggle (`developer-api` / `vertex`). + Reasoning via `thoughtSignature`; thinking budget via `ThinkingConfig.thinkingBudget`. +4. **Native Bedrock-Converse** — AWS SDK v2 `bedrockruntime`. Non-Anthropic models only. + Multimodal tool results via `ToolResultContentBlock`; cache via `cachePoint` blocks. + +**Per-impl checklist** (same as Phase B): +- SDK dependency with `@ConditionalOnClass` +- Streaming-first internal driver +- Full `ChatModelEvent` emission +- Content + usage accumulation +- Error classification per ADR table +- `model-capabilities.yaml` entries +- Unit tests (mocked SDK client) +- Wire-format e2e regression test under `connectors-e2e-test-agentic-ai/.../wireformat/` + +**Verification**: after each impl, corresponding wire-format e2e test passes; full unit suite +green. + +--- + +## Phase H — Cleanup + LangChain4j demotion + +**Goal**: bridge stays as opt-in only. + +**Files to delete**: already removed during Phase A. + +**Files to modify**: +- `Langchain4JChatModelApiFactory` / `AgenticAiLangchain4JFrameworkConfiguration`: drop default + Spring registration; gate behind `camunda.connector.agenticai.framework.langchain4j.bridge.enabled=false` + by default. Document in release notes / `docs/reference/ai-agent.md`. +- `AgenticAiConnectorsAutoConfiguration`: drop `AgenticAiLangchain4JFrameworkConfiguration` + default import. +- ADR-005 status: Proposed → Implemented (final date set when shipping). +- `docs/reference/ai-agent.md` + `AGENTS.md` (agentic-ai): `ChatModelApi` is the framework; + LangChain4j bridge is legacy opt-in. + +**Tests to add/update**: +- `AgenticAiConnectorsAutoConfigurationTest` updated for new wiring. +- Smoke test: bridge re-enabled via property → handler still resolves all provider types. + +**Verification**: +```bash +mvn clean install -pl connectors/agentic-ai +mvn test -pl connectors-e2e-test/connectors-e2e-test-agentic-ai -Dtest="*Wireformat*" +mvn test -pl connectors-e2e-test/connectors-e2e-test-agentic-ai # full suite (slow — final gate) +``` + +--- + +## Critical files + +| File | Phase | +|------|-------| +| `BaseAgentRequestHandler.java:172–176` | A (cutover site, done) | +| `framework/api/` SPI package | A (done) | +| `framework/ChatClientImpl.java` | A (done), E (capability + strategy wiring, done) | +| `framework/strategy/ToolCallResultStrategyImpl.java` | E3+E4 (done) | +| `framework/multimodal/DocumentModality.java` | E3+E4 (done — MIME → Modality mapping) | +| `framework/anthropic/` | B (done), G (cloud backends, open) | +| `framework/openai/` | C (done), D (done), G (FOUNDRY + clientCredentials, open) | +| `OpenAiProviderConfiguration.java` | D (apiFamily, done), F (backend + auth restructure, done) | +| `model/request/provider/*ProviderConfiguration.java` | F (done) | +| `model/request/provider/ProviderConfigurationDeserializer.java` | F (done — type-level `@JsonDeserialize`) | +| `element-templates/agenticai-aiagent-outbound-connector.json` | D (v11, done), F (v12, done) | +| `element-templates/README.md` | D (done), F (done) | +| `AgenticAiConnectorsAutoConfiguration.java` | A (done), B–F (provider imports, done), G (open) | +| `docs/adr/005-replace-langchain4j-framework.md` | H (status → Implemented when G+H land) | + +## Reusable existing code + +| Code | Used in | +|------|---------| +| `ChatModelProviderRegistry.java:16–57` | registry pattern for `ChatModelApiRegistryImpl` (Phase A, done) | +| `JsonSchemaElementDeserializer.java:52` | tree-walking deserializer pattern for `ProviderConfigurationDeserializer` (Phase F) | +| `AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL` | transport/auth error wrapping in each native impl (B onwards) | +| `AgentMessagesHandlerImpl` tool-result fallback path (PR #6999) | `ToolCallResultStrategy` fallback (Phase E) | + +## End-to-end verification (after Phase H) + +1. `mvn clean install -pl connectors/agentic-ai` — all unit tests green, element templates + regenerate, `AI_AGENT.md` regenerates. +2. `mvn test -pl connectors-e2e-test/connectors-e2e-test-agentic-ai -Dtest=*Wireformat*` — both + wire-format tests pass via native Anthropic and OpenAI implementations. +3. `mvn test -pl connectors-e2e-test/connectors-e2e-test-agentic-ai` — full suite green. +4. Manual: `git diff element-templates/` shows the latest source + previous in `versioned/` + + README updated. +5. Stale-process smoke: deserialize a saved `agentContext` from a pre-restructure instance with + the new `ProviderConfigurationDeserializer` — every migration table row covered by tests. diff --git a/connectors/agentic-ai/docs/adr/004-document-handling-in-tool-call-results.md b/connectors/agentic-ai/docs/adr/004-document-handling-in-tool-call-results.md new file mode 100644 index 00000000000..36ef86513e5 --- /dev/null +++ b/connectors/agentic-ai/docs/adr/004-document-handling-in-tool-call-results.md @@ -0,0 +1,180 @@ +# Document handling in tool call results + +* Deciders: Agentic AI Team +* Date: Apr 10, 2026 + +## Status + +**Implemented** (PR #6999). + +## Context and Problem Statement + +BPMN tool activities (modeled as ad-hoc subprocess tasks) can return complex data structures containing Camunda +Documents, either at the root level or nested within maps and lists. When these tool call results are passed to the LLM, +the documents need to be represented in a way the model can actually interpret. + +The current implementation converts `ToolCallResult.content()` to a single JSON text string for LangChain4J's +`ToolExecutionResultMessage`. Documents encountered during Jackson serialization are converted to a Claude-specific +content block format (`DocumentToContentSerializer`) containing base64-encoded data embedded within the text. This +approach does not work -- models cannot meaningfully interpret large base64 blobs embedded in tool result text. The +natural alternative — passing documents as native multi-modal content on the tool result — is also blocked today by +how providers and LangChain4J interact: providers vary in what they accept as tool result content (e.g. the OpenAI +Responses API and Anthropic accept rich content blocks; the OpenAI Chat Completions API is narrower), and LangChain4J's +provider adapters only partially expose that capability (e.g. `OpenAiChatModel` surfaces images but not other binary +types). See "Option 1" below for the full rationale. + +In contrast, documents provided via user messages already work correctly: they are converted to proper LangChain4J +content types (`ImageContent`, `PdfFileContent`, `TextContent`) through `DocumentToContentConverterImpl` and arrive at +the model as native multi-modal content blocks. + +## Decision Drivers + +* **Model compatibility**: Documents must be provided in a format that LLMs can actually process. +* **Provider independence**: The solution must work across all supported providers, not just those with native + multi-content tool result support. +* **Auditability**: Document handling must be visible in the persisted conversation history. +* **Abstraction layer**: Changes should be made in the generic agent layer (internal message model), not deep in the + LangChain4J framework adapter, so they are framework-agnostic and auditable. +* **Consistency**: Reuse existing, proven document handling infrastructure where possible. + +## Considered Options + +### Option 1: Multi-content `ToolExecutionResultMessage` (L4J-native) + +Extract documents from tool call results and add them as separate `Content` blocks on the LangChain4J +`ToolExecutionResultMessage`, which supports `List` since v1.12. + +**Rejected** because: + +- Native multi-content tool results are limited by a combination of factors: providers themselves vary in what + they accept as tool result content (e.g. the OpenAI Responses API and Anthropic accept rich content blocks; the + OpenAI Chat Completions API is narrower), and LangChain4J's provider adapters only partially expose that + capability — `OpenAiChatModel`, for instance, surfaces images but not other binary types, and broader coverage + would require migrating to richer adapters such as `OpenAiResponsesChatModel`. +- Changes would be invisible in the conversation history (only visible at the L4J wire format level). +- Tightly couples the solution to LangChain4J capabilities. + +### Option 2: Text-only (describe binary documents, inline text documents) + +Serialize all documents as textual descriptions (filename, content type) without providing actual content. + +**Rejected** because the model cannot analyze documents it cannot see. This defeats the purpose for use cases that +require visual/content analysis of tool-returned documents. + +### Option 3: Extract documents into appended user messages (selected) + +Extract all documents from tool call results into a follow-up `UserMessage` with `DocumentContent` blocks. The tool +result text retains a document reference (serialized by the default connectors `DocumentSerializer`) so the model can +correlate the reference with the actual content in the user message. + +## Decision + +**Option 3**: Extract documents from tool call results into appended user messages. + +### Design + +**Generic layer** (`AgentMessagesHandlerImpl`): + +1. After building the `ToolCallResultMessage`, scan each `ToolCallResult.content()` for `Document` instances using + `ToolCallResultDocumentExtractor`. The extractor delegates per result to the responsible + `GatewayToolHandler.extractDocuments(...)` (with `ContentTreeDocumentWalker` as the default fallback for results not + managed by any gateway handler) — see "Per-handler document extraction" below. +2. Build a single `UserMessage` (metadata: `toolCallDocuments=true`) containing a preamble, per-document XML tags with + correlation attributes, and `DocumentContent` blocks. +3. Apply the same extraction to event messages: prepend `` XML tags before each `DocumentContent` block in the + event `UserMessage`. +4. Message ordering: `ToolCallResultMessage` → document `UserMessage` → event `UserMessage`(s). + +**LangChain4J layer** (`ToolCallConverterImpl`): + +1. Serialize `ToolCallResult.content()` using the default connectors `ObjectMapper`, which serializes `Document` + instances as document references (store ID, document ID, metadata with content type and filename) via the standard + `DocumentSerializer`. +2. Remove the `ContentConverter` dependency; the custom `DocumentToContentModule` / `DocumentToContentSerializer` / + `DocumentToContentResponseModel` infrastructure is deleted. + +**Conversation history**: The `ToolCallResultMessage` retains `Document` objects in its content tree (serialized as +references when persisted). The follow-up `UserMessage` with `DocumentContent` blocks is a regular message in the +conversation. Both are visible and auditable. + +### Document user message format + +The document `UserMessage` contains interleaved `TextContent` tags and `DocumentContent` blocks. Each document is +preceded by a self-closing XML tag with correlation attributes: + +``` +TextContent: "Documents extracted from tool call results:" +TextContent: +DocumentContent: [report.pdf content] +TextContent: +DocumentContent: [chart.png content] +TextContent: +DocumentContent: [data.csv content] +``` + +The `document-short-id` is the first segment of the document's UUID identifier (e.g. `25ece9fa` from +`25ece9fa-aeea-423d-98ed-67c1f08b137b`). It provides a compact correlation key for the model to match the reference in +the tool result JSON with the actual content. All attribute values are XML-escaped. + +For event documents, the same `` tag format is used, but without `tool` and `call-id` attributes since events +are not associated with a specific tool call. + +### Message window memory + +The synthetic document `UserMessage` (identified by `UserMessage.METADATA_TOOL_CALL_DOCUMENTS`) does not count toward +the `maxMessages` context window limit. When evicting messages, the document `UserMessage` is removed together with its +associated `ToolCallResultMessage` — it is never orphaned. + +### Per-handler document extraction + +Gateway tool handlers (MCP, A2A) transform `ToolCallResult` objects — renaming tool calls with fully qualified +identifiers and producing typed domain objects (`McpClientCallToolResult`, `A2aSendMessageResult`) as the transformed +`content()`. Each handler exposes a domain-specific `extractDocuments(ToolCallResult)` method on the +`GatewayToolHandler` SPI that walks its own typed structure (sealed-type `switch`) to collect `Document` instances: + +* MCP: walks `McpClientCallToolResult.content()` and matches `McpDocumentContent` and + `McpEmbeddedResourceContent.BlobDocumentResource`. +* A2A: walks `A2aSendMessageResult` (sealed `A2aMessage | A2aTask`), descending into artifacts and recursive task + history to collect `DocumentContent` instances. + +`ToolCallResultDocumentExtractor` routes each result to the handler that manages it (via +`GatewayToolHandlerRegistry.handlerForToolDefinition(toolName)`). When no handler claims the result — typical for plain +BPMN tool calls whose `content()` is a raw `Map`/`List`/`Document` tree from the engine — the extractor falls back to +`ContentTreeDocumentWalker`, which performs the original `instanceof`-based recursion over `Map`, `Collection`, +`Object[]`, and `Document`. + +The walker is also public: handler implementations whose typed content embeds raw user-generated subtrees (e.g. opaque +`Map` payloads from a downstream system) can call `ContentTreeDocumentWalker.extractDocumentsFromContent(...)` for those parts. + +The default `GatewayToolHandler.extractDocuments` implementation delegates to the walker, so third-party handlers that +return raw content do not need to override anything. + +For simple text-only MCP results, the handler still extracts the text string directly (optimization to avoid +unnecessary JSON wrapping); in that case `extractDocuments` returns an empty list since the content is a `String`. + +### Future optimization (out of scope) + +A follow-up optimization can promote specific document types from the user message back into the +`ToolExecutionResultMessage` for providers that support native multi-content tool results (e.g., images on Anthropic). +This would be a post-processing step in the L4J framework adapter, transparent to the generic layer and conversation +history. The document `UserMessage` can be rebuilt from the `ToolCallResult` content tree (by re-running extraction) +with only the non-promoted documents remaining. + +## Consequences + +### Positive + +* Documents in tool call results become visible to the LLM across all providers. +* Reuses the existing, proven `DocumentToContentConverterImpl` path (same as user prompt documents). +* Conversation history shows the full picture: tool results with document references + user message with document + content. +* Removes ~3 classes of stale document-to-base64 serialization infrastructure. +* Text documents (JSON, XML, plain text) rendered as readable text; binary documents (PDF, images) rendered as native + multi-modal content blocks. + +### Negative + +* Adds one extra user message to the conversation when tool results contain documents, consuming additional tokens. +* The model must correlate document references in the tool result text with document content in the follow-up user + message. The XML tags with tool name, call ID, and document short ID mitigate this. +* Slight increase in conversation history size due to the additional message. diff --git a/connectors/agentic-ai/docs/adr/005-replace-langchain4j-framework.md b/connectors/agentic-ai/docs/adr/005-replace-langchain4j-framework.md new file mode 100644 index 00000000000..6dcdf7d6c15 --- /dev/null +++ b/connectors/agentic-ai/docs/adr/005-replace-langchain4j-framework.md @@ -0,0 +1,745 @@ +# Replace LangChain4j with Native Provider Layer + +* Deciders: Agentic AI Team +* Date: May 5, 2026 + +## Status + +**Proposed** — design accepted; a working prototype implementing Phases 0, A–D, E (E1+E2+E3+E4), +and F lives on the hackdays branch `agentic-ai/custom-llm-layer` (PR #7151) for reference. The +prototype branch is not for merge; the design is being re-landed on `main` in smaller, +reviewable units tracked under the ADR-005 parent issue. Status will move to **Implemented** +once Phase G + Phase H land. + +## Context and Problem Statement + +The agentic-ai module abstracts LLM access behind a single global `AiFrameworkAdapter` whose only +production implementation wraps LangChain4j (`Langchain4JAiFrameworkAdapter`). Six per-vendor +`ChatModelProvider` beans construct LangChain4j `ChatModel` instances; converters translate our +domain `Message`/`Content` types to and from LangChain4j's `ChatMessage` types. + +This abstraction worked while LangChain4j tracked provider features closely. It increasingly does +not. Concrete blockers we have hit or will imminently hit: + +* `AssistantMessage` / `SystemMessage` content lists are restricted to a single `TextContent` + block at conversion time + ([`ChatMessageConverterImpl.java:104-110`](../../src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterImpl.java)). + Reasoning blocks and multi-block assistant content cannot be represented even structurally. +* `ToolCallResultMessage` carries content as a single `Object` flattened to a JSON string. Native + multimodal tool results (images and PDFs inside `tool_result` blocks, supported by Anthropic and + AWS Bedrock at the wire level) cannot be expressed; the workaround in PR #6999 synthesizes a + user message with `DocumentContent` blocks because the framework cannot carry documents in tool + results. +* `AgentMetrics.TokenUsage` records only input + output token counts. Cache attribution + (`cache_creation` / `cache_read`) and reasoning-token spend are not surfaced. +* Reasoning models are not supported. Anthropic extended thinking, OpenAI Responses-API reasoning, + and Gemini thinking budgets all require carrying signed reasoning blocks across turns; the + framework discards them. +* Prompt caching is not configurable. Every provider exposes some form of cache control + (Anthropic per-block ephemeral markers, Bedrock cache points, OpenAI prompt cache key, Gemini + context caching); the framework exposes none. +* For Claude on AWS Bedrock and Google Vertex AI, the framework routes through Bedrock Converse / + Vertex generic adapters, which are lowest-common-denominator and lose Claude-specific features + even though Anthropic publishes platform-backend SDKs that preserve full feature parity. +* Per-block cache control, server-side tool blocks (web search, code execution), and beta-API + flags all require escape hatches the framework does not expose. + +Should we extend LangChain4j upstream and continue building on it, or replace the framework layer +with our own provider abstraction? + +## Decision Drivers + +* **Feature surface**: We need access to provider features (multimodal tool results, reasoning + with signature roundtrip, granular cache control, cache and reasoning token attribution) that + the framework's lowest-common-denominator types structurally cannot represent. +* **Release cadence**: Closing each gap upstream requires waiting for the framework's release + cycle; some of the gaps above are structural (data-model restrictions) and unlikely to be + closed without breaking changes. +* **Maintenance burden**: A provider abstraction that we own lets us pick official vendor SDKs, + benefit from their auth / retry / signing handling, and surface their richer types directly. +* **Predictable rollout**: A complete replacement avoids a long-lived hybrid code path with two + sets of converters and conditional behavior. + +## Considered Options + +1. Continue with LangChain4j; contribute upstream PRs for missing features. +2. Build a thin custom HTTP/JSON layer per provider, owning the wire format end-to-end. +3. Replace the framework abstraction with a custom provider layer built on the official vendor + Java SDKs (Anthropic, AWS Bedrock, OpenAI, Google GenAI). + +## Decision Outcome + +Chosen option: **Option 3 — Native provider layer over official vendor SDKs**. + +The vendor SDKs already handle authentication (including SigV4, OAuth refresh, Bearer + Azure +credentials, GCP ADC), retries, timeouts, signing, SSE / event-stream parsing, and tool-schema +derivation. Reimplementing these in-house (Option 2) is unnecessary duplication. Continuing on +LangChain4j (Option 1) does not address the structural data-model restrictions. + +The replacement is delivered as one shipping unit (after a small additive Phase 0). LangChain4j +support is preserved as an opt-in bridge implementation for users running custom bundles with +provider models we have not yet covered natively, but is no longer registered by default. + +### Positive Consequences + +* Direct access to provider-native content blocks (multimodal tool results, signed reasoning, + cache control breakpoints, server-side tool blocks). +* Full token-usage attribution including cache and reasoning tokens. +* Cleaner cloud Claude story: a single Anthropic implementation serves direct, AWS Bedrock, + Google Vertex, and Azure Foundry deployments without lossy adapter routing. +* Opt-in SDK dependencies via `@ConditionalOnClass` — deployments only pull in the providers + they use. +* Discriminated streaming events enable accurate tool-argument accumulation with mid-stream + repair, partial-content preservation on errors, and clean abort propagation. + +### Negative Consequences + +* Larger per-provider implementation than today's LangChain4j wrappers. +* One-time migration effort across the framework, converters, and provider configuration shapes. +* Element template version bump for provider configuration restructuring (Task and Sub-process + flavors). +* Capability matrix YAML to maintain alongside the supported model set. + +## Architecture + +The framework abstraction is replaced with a layered design: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ ChatClient (facade — what BaseAgentRequestHandler calls) │ +│ resolves model + capabilities, applies tool-result │ +│ strategy, dispatches to ChatModelApi. │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ ChatModelApi (per-job instance, configuration baked in) │ +│ capabilities() : ModelCapabilities │ +│ complete(request, options, listener) : CompletableFuture │ +│ │ +│ Constructed by ChatModelApiFactory — one bean per │ +│ wire-protocol family. │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Vendor SDKs │ +│ anthropic-sdk-java | aws-sdk-java-v2 bedrockruntime | │ +│ openai-java | google-genai-java │ +└──────────────────────────────────────────────────────────────┘ +``` + +Five native API families ship in scope: + +| Family | Wire protocol | Covers | +|--------------------|--------------------------|-------------------------------------------------------------------------------| +| `anthropic-messages` | Anthropic Messages API | Claude direct, Claude on Bedrock, Claude on Vertex AI, Claude on Azure Foundry| +| `bedrock-converse` | AWS Bedrock Converse | Nova, Mistral, Llama, Cohere on Bedrock | +| `openai-responses` | OpenAI Responses API | GPT-5, o-series, Azure deployments thereof | +| `openai-completions` | OpenAI Chat Completions | Legacy OpenAI Chat models, OpenAI-compatible gateways (Ollama, vLLM, etc.) | +| `google-genai` | Google GenAI | Gemini Developer API + Vertex AI (single client, backend toggle) | + +For Claude on Bedrock / Vertex / Foundry, the Anthropic SDK exposes platform-backend modules +(`anthropic-java-bedrock`, `anthropic-java-vertex`, `anthropic-java-foundry`) that preserve the +Anthropic wire format end-to-end. Routing Claude through these backends instead of through +generic platform adapters is a key reason for the restructure. + +### Per-job instance pattern + +`ChatModelApiFactory` is a singleton bean (one per wire +protocol). Per call, the registry resolves the factory by `ProviderConfiguration` discriminator +and produces a per-job `ChatModelApi` instance carrying the resolved configuration and (lazily +constructed) SDK client. `ChatClient` performs this resolution once at the start of each +request and reuses the instance across capability lookups, strategy application, and the call +itself. + +This mirrors the current `ChatModelProvider` pattern: factory beans are stateless, runtime +clients are per-call. + +### Streaming-first internally, blocking surface externally + +Each `ChatModelApi` implementation drives the SDK's streaming API internally. Streaming gives +us accurate tool-argument JSON accumulation across partial chunks, partial-content preservation +when a call errors mid-stream, mid-call abort on job cancel, and incremental token-usage +updates. None of these benefits require exposing a streaming primitive to callers. + +The public surface is `CompletableFuture` plus an optional +`ChatStreamListener` for in-process observability (logging, metrics, future event emission). +Listener defaults to NOOP. No reactive types in the public API. + +### Stream event hierarchy + +```java +public sealed interface ChatModelEvent permits + StartEvent, + TextStartEvent, TextDeltaEvent, TextEndEvent, + ReasoningStartEvent, ReasoningDeltaEvent, ReasoningEndEvent, + ToolCallStartEvent, ToolCallArgumentsDeltaEvent, ToolCallEndEvent, + UsageEvent, + DoneEvent, ErrorEvent { } +``` + +Each delta event carries a content-block index so the listener can group fragments by block. +`DoneEvent` carries the final assembled `ChatResponse`. `ErrorEvent` carries the error message, +partial content accumulated so far, and the partial usage record. + +### Error semantics + +Three classes of failure with distinct surface behavior: + +| Class | Examples | Surface | +|----------------------------|--------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------| +| Model-side terminal | `stop_reason: refusal`, content filter, max-tokens hit, malformed tool-use | `CompletableFuture` completes normally with `ChatResponse{assistantMessage{stopReason=ERROR, content, usage, ...}}` | +| Transport / SDK / I/O | Connection refused, read timeout, TLS failure, malformed wire response | `CompletableFuture` completes exceptionally with `ConnectorException(ERROR_CODE_FAILED_MODEL_CALL, ...)` | +| Auth / config | Bad API key, region not enabled, model not found | `CompletableFuture` completes exceptionally with a distinct error code | + +Token usage and partial content accumulated before the failure are preserved on the response +record where applicable, so call accounting remains accurate even on terminal errors. + +## Domain Model Extensions + +All changes are additive and nullable. Existing serialized `agentContext` records and stored +conversations deserialize unchanged with new fields defaulting to `null` / empty. + +### `Content` sealed hierarchy + +One new variant: + +```java +public sealed interface Content permits + TextContent, DocumentContent, ObjectContent, + ReasoningContent { ... } // NEW + +public record ReasoningContent( + String text, + @Nullable String signature, // opaque round-trip blob + boolean redacted, + Map metadata +) implements Content { } +``` + +`signature` carries the provider-specific encrypted reasoning blob (Anthropic encrypted +thinking, Gemini `thoughtSignature`, OpenAI Responses encrypted reasoning item). It is +required for multi-turn reasoning continuation; the model rejects requests where prior +reasoning is not replayed verbatim. + +`DocumentContent` (Camunda `Document` reference) remains the single representation for any +multimedia in the system. Inline binary content does not appear in the domain model — bytes +exist only transiently inside a `ChatModelApi` implementation while serializing requests or +materializing model output to a Camunda Document. + +### `AssistantMessage` provenance and metrics + +Four new optional fields: + +```java +public record AssistantMessage( + List content, + List toolCalls, + @Nullable String modelId, // NEW + @Nullable String messageId, // NEW (provider-assigned message id) + @Nullable StopReason stopReason, // NEW + @Nullable TokenUsage usage, // NEW (per-message) + Map metadata // existing — escape hatch +) implements Message, ContentMessage { } +``` + +`StopReason` is a normalized enum: `STOP | LENGTH | TOOL_USE | ERROR | ABORTED | +CONTENT_FILTERED | GUARDRAIL`. Each implementation maps provider-specific finish reasons into +this set. + +### `ToolCallResult` multimodal support + +```java +public record ToolCallResult( + @Nullable String id, + @Nullable String name, + @Nullable Object content, // existing — string/JSON path + @Nullable List contentBlocks, // NEW — when present, used preferentially + Map properties +) { } +``` + +When `contentBlocks` is present, implementations whose target API supports multimodal tool +results pass it through as native content (`tool_result` content list on Anthropic, +`ToolResultContentBlock` on Bedrock). Implementations without native support delegate to +`ToolCallResultStrategy` — see below. + +### `TokenUsage` extension + +```java +public record TokenUsage( + int inputTokens, + int outputTokens, + int cacheReadInputTokens, // NEW + int cacheCreationInputTokens, // NEW + int reasoningTokens, // NEW + int totalTokens // computed when not reported +) { } +``` + +`AgentMetrics.tokenUsage` becomes the cumulative roll-up across calls; +`AssistantMessage.usage` is per-call. + +### Removed surface + +`AiFrameworkChatResponse#rawChatResponse()` is removed. The current interface exposes it +but no caller consumes it; the `AssistantMessage.metadata` map serves as the escape hatch +for provider-specific data when needed. + +## Capability Matrix + +The capability matrix ships as a Spring Boot configuration — a bundled YAML resource is +registered as a low-precedence `PropertySource` by an `EnvironmentPostProcessor` at startup, +and library consumers override or extend any value via their own `application.yml` under the +same prefix. No bespoke YAML parser; standard Spring property binding handles everything. + +**Configuration prefix**: `camunda.connector.agenticai.aiagent.framework.capabilities`. + +**Bundled resource**: `classpath:capabilities/model-capabilities.yaml`. + +**Structure** (per api family): + +```yaml +camunda.connector.agenticai.aiagent.framework.capabilities: + anthropic-messages: + defaults: + input-modalities: + user-message: [text, image, document] + tool-result: [text, image, document] + output-modalities: + assistant-message: [text] + supports-reasoning: false + supports-reasoning-signature-roundtrip: false + supports-prompt-caching: true + supports-parallel-tool-calls: true + context-window: 200000 + max-output-tokens: 8192 + models: + claude-opus-4-7: # map key is the id (no `pattern` field) + aliases: [claude-opus-latest] + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + max-output-tokens: 32000 + claude-opus-4: # map key is opaque; `pattern` carries the glob + pattern: claude-opus-4-* + capabilities: + supports-reasoning: true + max-output-tokens: 32000 + claude-haiku: + pattern: [claude-haiku-4-*, claude-haiku-3-*] + capabilities: {} +``` + +Each api family carries: +- `defaults`: capability block applied to every entry in the family (deep-merged with + per-entry overlays at resolve time) +- `models`: map of opaque identifiers → entries. Each entry has one of: + - explicit `id` (defaults to the map key when neither field is set), or + - explicit `pattern` — string OR list of strings, glob using `*` only. + Plus optional `aliases` (id entries only) and a `capabilities` overlay. + +**Map keys cannot contain `*` or `.`**: Spring Boot's `MapBinder` strips these characters, +so glob patterns always live in the `pattern` field while the map key stays a stable +override identifier. + +Modality vocabulary: `text | image | document | audio | video`. Modality lists per location +(`user-message`, `tool-result`, `assistant-message`) are symmetric — every modality at every +location has an explicit answer for each model. + +### Merge semantics + +Spring Boot config defaults: maps merge recursively (sub-keys of `input-modalities` and +`output-modalities` are inherited individually), lists replace wholesale, scalars and +booleans replace. The same rules apply both within the bundled YAML (per-entry `capabilities` +on top of `defaults`) and across PropertySources (consumer `application.yml` on top of +bundled defaults). + +### Resolution order + +Most-specific-first, scoped to the api family of the connector configuration: + +1. **Connector config override** — per-call `Optional` passed into + `ModelCapabilitiesResolver.resolve(...)`. Reserved hook; not yet wired from + `ChatOptions`. +2. **Exact id or alias match** — the `id` field of an entry (or its derived map key), + or any string in its `aliases` list, equals the requested model id. +3. **Pattern match** — any glob in the entry's `pattern` field matches the requested model + id; the entry's score is the length of the longest matching glob, and longest score wins + across entries. +4. **Conservative defaults** — text-only across the board, all `supports_*` flags `false`, + numeric limits null. + +Aliases resolve at step 2 directly (no pre-rewriting); patterns at step 3 match against +the original requested id. Resolution at steps 3 or 4 logs an INFO message once per +(api family, model id) so operators notice they are running on best-effort or default +capabilities. Resolution at step 2 is silent — alias mappings are verified declarations. + +### Conservative defaults for unknown models + +```yaml +input-modalities: + user-message: [text] + tool-result: [text] +output-modalities: + assistant-message: [text] +supports-reasoning: false +supports-reasoning-signature-roundtrip: false +supports-prompt-caching: false +supports-parallel-tool-calls: false +context-window: null +max-output-tokens: null +``` + +Unknown api families fall through to the conservative defaults at lookup time (the +resolver has no entry to match) and emit an INFO log so the operator notices. + +### Library-consumer overrides + +Consumers override or extend any value by declaring properties under the same prefix in +their `application.yml`: + +```yaml +camunda.connector.agenticai.aiagent.framework.capabilities: + anthropic-messages: + models: + claude-opus-4-7: + capabilities: + max-output-tokens: 64000 # tune existing entry + my-org-tuned-claude: # add a new entry + capabilities: + supports-reasoning: true + max-output-tokens: 12345 +``` + +Map-key reuse means a consumer override deep-merges into the bundled entry; a new map key +adds a new entry. Modality lists replace wholesale (Spring Boot list semantics) — overriding +`tool-result: [text]` discards the bundled `[text, image, document]`. To add a modality, restate +the full list including the inherited entries. + +## Tool Call Result Routing + +`ToolCallResultStrategy` is the **single decision point** that routes every document found +in a chat request to one of three outcomes, in one pass over the request. There is no +extract-then-restore: documents are routed once at the SPI boundary based on the resolved +`ModelCapabilities`. + +**Where it runs.** `ChatClientImpl.chat(...)` invokes the strategy after resolving the +`ChatModelApi` and before dispatch: + +``` +1. registry.resolve(provider) → ChatModelApi +2. capabilities = chatModelApi.capabilities() +3. (request, syntheticContextMessages) = strategy.apply(initialRequest, capabilities) +4. syntheticContextMessages.forEach(runtimeMemory::addMessage) ← side effect +5. chatModelApi.complete(request, options, listener) +6. agentContext metric update; return ChatClientResult +``` + +The synthetic-message memory write happens **inside** `ChatClient.chat(...)` so the +pre-dispatch context is part of the persisted `agentContext.conversation` exactly as the +model saw it. Replay across iterations is deterministic; the next iteration sees the +synthetic `UserMessage` as ordinary history. Requires no signature change on `ChatClient` +(it already takes `RuntimeMemory`). + +> **TODO (post-Phase E):** revisit the `ChatClient` ↔ `BaseAgentRequestHandler` boundary +> once E3+E4 land. `ChatClient` now performs three jobs (request assembly, strategy routing +> with memory mutation, dispatch + metrics). If the strategy responsibility grows further +> we should consider either (a) extracting routing into a separate pre-chat step `BARQ` +> owns directly, or (b) collapsing `ChatClient` into `BARQ` entirely. Defer until we see +> how the multimodal path settles. + +**Strategy contract.** Pure function: + +```java +record StrategyResult(ChatRequest request, List syntheticContextMessages) {} + +StrategyResult apply(ChatRequest request, ModelCapabilities capabilities); +``` + +Walks every message in the request once. For each `Document` encountered (delegating to the +existing PR #6999 walker / per-handler `extractDocuments` hook), three branches keyed on +which message type the document lives in: + +1. **Tool-result-message documents** — modality vs. `capabilities.toolResultModalities()`: + * **Inline**: append the `DocumentContent` to `ToolCallResult.contentBlocks`. The native + impl emits it as provider-native multimodal content on the same tool result. The + textual rendering of the result still includes the document's representation (filename, + short id, etc.) so structure is preserved. + * **Fallback**: replace the document inline with an XML placeholder + (``, today's PR #6999 format) + and append the original `DocumentContent` to a per-tool-result-message bucket. +2. **User-message and event-message documents** — modality vs. + `capabilities.userMessageModalities()`: + * **Supported**: leave the document where it sits (today's inlining in the same + `UserMessage` content list — no change). + * **Unsupported**: **fail loud** with `ConnectorException(ERROR_CODE_FAILED_MODEL_CALL, + ...)` and a clear message naming the document, modality, and resolved model. Mirrors + L4J `DocumentConversionException` semantics. There is no synthesis fallback for user + messages — the agent author must supply documents the model can read. + +**Synthesis output.** Whenever a tool-result message produced fallback documents, the +strategy emits one synthetic `UserMessage` per affected tool-result message (same shape PR +#6999 produces today: `METADATA_TOOL_CALL_DOCUMENTS=true`, header text, XML tag + +`DocumentContent` pairs). These are the `syntheticContextMessages` returned to +`ChatClientImpl`. `MessageWindowRuntimeMemory` already excludes messages with +`METADATA_TOOL_CALL_DOCUMENTS=true` from the window count, so synthesis volume cannot +push history out. + +**Single pass guarantees.** +* Every document is visited exactly once. +* Inline-eligible docs never enter the synthesis path. +* Fallback docs never appear on `ToolCallResult.contentBlocks`. +* Native impls (E4) read `ToolCallResult.contentBlocks` and emit blindly — they do **not** + consult capabilities. + +**Behavior of the L4J bridge.** Bridge-served providers report a conservative capability +profile (`tool-result: [text]`, `user-message: [text]`); every document falls back to the +synthetic `UserMessage`, identical to today's PR #6999 behavior — no regression. + +**Removal in `AgentMessagesHandlerImpl`.** The current PR #6999 call site +(`createDocumentMessageForToolResults` invoked from `addUserMessages`) and the +unconditional `documentExtractor` dependency move out — extraction is now solely the +strategy's responsibility. The XML-tag generator (`DocumentXmlTag`), the recursive +walker (`ContentTreeDocumentWalker`), and the per-handler `extractDocuments` hook on +`GatewayToolHandler` are all reused unchanged; only the call site relocates. + +## Reasoning Support + +Two-tier API: + +```java +public sealed interface ReasoningConfig + permits ReasoningEffort, ReasoningBudget, ReasoningDisabled { } + +public record ReasoningEffort(Effort level) implements ReasoningConfig { } + // MINIMAL | LOW | MEDIUM | HIGH | X_HIGH +public record ReasoningBudget(int tokens) implements ReasoningConfig { } +public record ReasoningDisabled() implements ReasoningConfig { } +``` + +`ChatOptions.reasoning` carries a high-level config; per-implementation translation maps it +to provider-native fields (Anthropic adaptive `effort` for newer Opus / Sonnet, budget-based +`thinking_budget_tokens` for older Claude 4; OpenAI Responses `reasoning.effort`; Gemini +`ThinkingConfig.thinkingBudget`; Bedrock per-model). Provider-specific overrides remain +available via `ChatOptions.providerOptions`. + +`ReasoningContent.signature` round-trip is mandatory for multi-turn reasoning. Each +implementation: + +* Accumulates signature deltas during streaming and stores them on the `ReasoningContent` + block in the assistant message. +* Replays signatures on subsequent requests when the prior assistant turn is included in the + conversation history. + +`TokenUsage.reasoningTokens` populates from provider-native fields where reported (OpenAI +`outputTokensDetails.reasoningTokens`, Gemini `thoughtsTokenCount`); zero where not separately +reported. + +## Prompt Caching + +```java +public enum CacheRetention { NONE, SHORT, LONG } +``` + +`SHORT` corresponds to the provider's default ephemeral retention (Anthropic 5 minutes, +OpenAI default prompt cache, Bedrock default). `LONG` corresponds to extended retention +(Anthropic 1 hour, OpenAI 24 hours, Bedrock 1 hour). `NONE` strips all cache markers. + +Cache breakpoint placement is implementation-specific. Each implementation places markers +at the boundaries the provider supports: + +* `anthropic-messages`: `cache_control` ephemeral markers on system prompt, last tool + definition, and last user / tool-result content block (up to 4 breakpoints). +* `bedrock-converse`: `cachePoint` blocks at the same boundaries, on supported models. +* `openai-responses` / `openai-completions`: caching is automatic; `prompt_cache_key` is + set to the conversation identifier. +* `google-genai`: implicit caching is always on; explicit context cache lifecycle is a + future extension. + +`TokenUsage.cacheReadInputTokens` and `cacheCreationInputTokens` populate from provider-native +fields. + +## Provider Configuration Restructure + +Configurations are restructured by wire format. The `ProviderConfiguration` sealed type +reduces from six to **four** members. The three separate OpenAI-family configs (`openai`, +`azureOpenAi`, `openaiCompatible`) are merged into a single `OpenAiProviderConfiguration` +with a backend discriminator, mirroring the Anthropic pattern. Element template UI groups +conditional fields under each discriminator value. + +``` +ProviderConfiguration +├── AnthropicProviderConfiguration api: anthropic-messages +│ backend: { direct | bedrock | vertex | foundry } ← NEW +│ auth fields conditional on backend +│ model: AnthropicModel +├── BedrockProviderConfiguration api: bedrock-converse +│ (non-Anthropic Bedrock only: Nova, Mistral, Llama, Cohere) +│ auth (existing AWS chain) +│ model: BedrockModel +├── OpenAiProviderConfiguration api: openai-{responses|completions} +│ backend: { openai | foundry | custom } ← NEW (replaces 3 separate configs) +│ apiFamily: { responses | completions } ← present for all backends +│ auth conditional on backend: +│ openai: apiKey (+ optional organizationId, projectId) +│ foundry: apiKey | clientCredentials (Entra ID / Client Credentials) +│ custom: apiKey (optional) +│ endpoint: required for foundry and custom backends +│ headers, queryParameters, customParameters: Map (custom backend only) +│ model: OpenAiModel +└── GoogleGenAiProviderConfiguration api: google-genai + backend: { developer-api | vertex } ← NEW + auth fields conditional on backend + model: GeminiModel +``` + +`AzureOpenAiProviderConfiguration` and `OpenAiCompatibleProviderConfiguration` are removed +as sealed interface members. Their saved shapes are rewritten by the migration deserializer +(see table below), and their discriminators (`azureOpenAi`, `openaiCompatible`) are dropped +from `@JsonSubTypes`. + +`GoogleVertexAiProviderConfiguration` is renamed to `GoogleGenAiProviderConfiguration` since +the same configuration now covers both backends. + +`BedrockProviderConfiguration` becomes non-Anthropic-only at validation time. Saved +configurations with `anthropic.*` model identifiers are rewritten by the Jackson migration +deserializer to the Anthropic configuration with backend = bedrock — see below. + +### Backward compatibility — Jackson migration + +A custom `StdDeserializer` rewrites legacy configuration shapes to +the new structure at deserialization time. Existing process instances continue to work +without manual intervention. + +| Saved shape | Rewritten to | Detection | +|----------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|-----------------------| +| `{type: bedrock, bedrock.model.model: "anthropic.claude-..."}` | `{type: anthropic, anthropic.backend: bedrock, anthropic.model.model: "anthropic..."}` | model-id prefix | +| `{type: bedrock, bedrock.model.model: "amazon.nova-..." \| "meta..." }` | passthrough | model-id prefix | +| `{type: googleVertexAi, ...}` | `{type: googleGenAi, googleGenAi.backend: vertex, ...}` | discriminator rename | +| `{type: anthropic, ...}` (no backend) | `{type: anthropic, anthropic.backend: direct, ...}` | missing field default | +| `{type: anthropic, anthropic.authentication: {apiKey: "..."}}` (no auth `type`) | `{type: anthropic, anthropic.authentication: {type: apiKey, apiKey: "..."}, ...}` | missing nested discriminator | +| `{type: openai, ...}` (no backend) | `{type: openai, openai.backend: openai, openai.apiFamily: completions, ...}` | missing field default | +| `{type: azureOpenAi, ...}` | `{type: openai, openai.backend: foundry, openai.apiFamily: completions, ...}` with auth + endpoint field mapping | discriminator rename | +| `{type: openaiCompatible, ...}` | `{type: openai, openai.backend: custom, openai.apiFamily: completions, ...}` with auth + custom field mapping | discriminator rename | + +Auth field mapping for `azureOpenAi` → `foundry`: the `azureOpenAi.auth` sub-tree (apiKey +or clientCredentials shape) maps directly onto `openai.auth`; `azureOpenAi.endpoint` maps +to `openai.endpoint`. + +Auth and custom field mapping for `openaiCompatible` → `custom`: `openaiCompatible.auth.apiKey` +maps to `openai.auth.apiKey`; `openaiCompatible.endpoint` maps to `openai.endpoint`; +`openaiCompatible.headers`, `openaiCompatible.queryParameters`, and +`openaiCompatible.customParameters` map to the same-named fields under `openai`. + +Defaults during migration preserve current behavior (Chat Completions for all OpenAI variants, +direct for Anthropic). Newly created configurations pick up the new defaults (Responses API +for the openai backend). + +The migration deserializer is permanent infrastructure — kept indefinitely so that stale +process variables remain readable. + +### Element template version bump + +Both element templates (`agenticai-aiagent-outbound-connector.json` and +`agenticai-aiagent-job-worker.json`) version-bump together per the AGENTS.md rule. Old +templates move to `element-templates/versioned/` per the existing pattern. The +`element-templates/README.md` index is updated accordingly. + +## Migration Plan + +Two phases: + +### Phase 0 — Domain model extensions (additive, behavior-preserving) + +* Add `ReasoningContent` to the `Content` sealed hierarchy. +* Add optional `modelId`, `messageId`, `stopReason`, `usage` fields to `AssistantMessage`. +* Add optional `contentBlocks` field to `ToolCallResult`. +* Add `cacheReadInputTokens`, `cacheCreationInputTokens`, `reasoningTokens` to `TokenUsage`. +* Drop `AiFrameworkChatResponse#rawChatResponse()`. +* LangChain4j converter populates the new fields where the framework provides them; uses + null defaults elsewhere. + +No call-site changes. Existing tests pass with no behavior change. + +### Phase 1 — Complete replacement (one shipping unit) + +* New SPI: `ChatModelApiFactory`, `ChatModelApiRegistry`, `ChatModelApi`, `ChatClient` + facade returning `ChatClientResult`, `ChatModelEvent` sealed hierarchy, `ChatStreamListener`. + Multi-provider bridges (e.g. LangChain4j) register one `ChatModelApiFactory` bean per + `providerType()` discriminator. +* Capability matrix YAML + resolution chain (config override → exact id / alias → pattern + → conservative defaults). +* `ToolCallResultStrategy` (multimodal-native and user-message-fallback policies). +* Five native `ChatModelApiFactory` implementations: `anthropic-messages`, + `bedrock-converse`, `openai-responses`, `openai-completions`, `google-genai`. +* `ProviderConfiguration` restructure with Jackson migration deserializer. +* Element template version bump (Task and Sub-process flavors). +* `BaseAgentRequestHandler` cuts over to `ChatClient`. The `AiFrameworkAdapter` interface + is removed. +* The LangChain4j integration (`framework/langchain4j/`) is preserved as an + opt-in bridge for users assembling custom bundles with provider models we have not yet + covered natively. It implements `ChatModelApiFactory` but is not registered by default. + Same plan applies to a future Spring AI bridge — the architecture supports it as a + drop-in `ChatModelApiFactory` implementation, but adoption is out of scope for this + iteration. + +The rollout is all-or-nothing. No feature flag selects between the legacy framework and +the native layer; users on supported models migrate transparently when they consume the +release. + +## Future Extensions + +Items deliberately scoped out of this iteration: + +* **Cross-model session normalization**. A `MessageTransformer` layer that handles + conversations spanning multiple models in a single session (degrading reasoning blocks + when model changes, normalizing tool-call IDs across provider ID-format constraints, + skipping errored assistant turns on replay) is unnecessary while a job is bound to a + single model. The architecture leaves an interception point at the boundary between + `ChatClient` and `ChatModelApi` where this slots in cleanly when needed. +* **Spring AI bridge**. The `ChatModelApiFactory` SPI is shaped so a future bridge + implementing the same interface lights up Spring AI's broader provider catalog without + blocking the current scope. +* **Cost tracking**. Token accounting is in scope; cost computation from token counts is + deferred. The capability matrix schema does not carry cost fields at this stage. +* **Reactive streaming surface**. The boundary is `CompletableFuture`; + exposing the underlying event stream as a reactive primitive is deferred. The internal + `ChatStreamListener` extension point covers in-process observability needs without + committing to a public reactive API. +* **Connector SDK retry classification**. The error semantics distinguish model-side errors + (response with `stopReason: ERROR`) from transport / auth errors (exceptional future + completion). Whether the Connector SDK can suppress automatic retry per error code so + authentication / configuration errors do not retry pointlessly is a separate work item; + the current design does not depend on it. +* **Output media materialization**. `output_modalities` in the capability matrix + accommodates models that emit images or other media; the `ChatModelApi` implementation + responsible for the first such model adds the byte → Camunda Document materialization + path at that time. The schema is forward-compatible. +* **Tool argument JSON repair**. A small utility that runs a best-effort repair pass on + malformed tool-argument JSON streamed from providers will be added under + `framework/util/` and called from each native implementation. Behavior is identical to + the SDK accumulators for well-formed JSON; recovery only kicks in on malformed cases + observed in production. + +## References + +* Current framework abstraction: + [`AiFrameworkAdapter.java`](../../src/main/java/io/camunda/connector/agenticai/aiagent/framework/AiFrameworkAdapter.java), + [`Langchain4JAiFrameworkAdapter.java`](../../src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkAdapter.java) +* Single migration call site: + [`BaseAgentRequestHandler.java`](../../src/main/java/io/camunda/connector/agenticai/aiagent/agent/BaseAgentRequestHandler.java) +* Domain model: + [`Message.java`](../../src/main/java/io/camunda/connector/agenticai/model/message/Message.java), + [`Content.java`](../../src/main/java/io/camunda/connector/agenticai/model/message/content/Content.java), + [`ToolCallResult.java`](../../src/main/java/io/camunda/connector/agenticai/model/tool/ToolCallResult.java), + [`AgentMetrics.java`](../../src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentMetrics.java) +* Provider configurations: + [`ProviderConfiguration.java`](../../src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfiguration.java) +* Tool-result document handling (PR #6999): + [`AgentMessagesHandlerImpl.java`](../../src/main/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerImpl.java) +* Element templates: + [`agenticai-aiagent-outbound-connector.json`](../../element-templates/agenticai-aiagent-outbound-connector.json), + [`agenticai-aiagent-job-worker.json`](../../element-templates/agenticai-aiagent-job-worker.json), + [`element-templates/README.md`](../../element-templates/README.md) \ No newline at end of file diff --git a/connectors/agentic-ai/docs/reference/a2a.md b/connectors/agentic-ai/docs/reference/a2a.md index 71e451c0e36..bdcb53a350b 100644 --- a/connectors/agentic-ai/docs/reference/a2a.md +++ b/connectors/agentic-ai/docs/reference/a2a.md @@ -150,8 +150,14 @@ Simpler than MCP because there's only one "tool" per A2A element (the agent itse 5. Result (A2aSendMessageResult — either A2aTask or A2aMessage) flows back 6. A2aGatewayToolHandler.transformToolCallResults(): - Rebuilds fully qualified name: "A2A_WeatherAgent" - - Returns ToolCallResult with the send message result content + - Deserializes content into the typed A2aSendMessageResult and sets it as the transformed + ToolCallResult content 7. LLM receives the response and decides next action based on task state +8. A2aGatewayToolHandler.extractDocuments() walks the typed A2aSendMessageResult and collects + Camunda Documents from DocumentContent entries: A2aMessage.contents at the root, plus + A2aTask.artifacts and (recursively) A2aTask.history. Documents flow into the synthetic + document UserMessage produced by ToolCallResultDocumentExtractor (see ai-agent.md §19 + "Document Extraction from Tool Call Results"). ``` --- diff --git a/connectors/agentic-ai/docs/reference/ai-agent.md b/connectors/agentic-ai/docs/reference/ai-agent.md index 8054dfceaf6..f9be6200332 100644 --- a/connectors/agentic-ai/docs/reference/ai-agent.md +++ b/connectors/agentic-ai/docs/reference/ai-agent.md @@ -1291,6 +1291,9 @@ public interface GatewayToolHandler extends GatewayToolCallTransformer { // Migration GatewayToolDefinitionUpdates resolveUpdatedGatewayToolDefinitions(agentContext, gatewayToolDefinitions); + + // Document extraction (default falls back to ContentTreeDocumentWalker for raw content trees) + default List extractDocuments(ToolCallResult toolCallResult); } // From GatewayToolCallTransformer: @@ -1359,6 +1362,38 @@ Gateway handlers store per-handler state in `AgentContext.properties`: These are used during discovery checking and tool call result transformation. +### Document Extraction from Tool Call Results + +Tool call results may contain Camunda `Document` instances — at the root, nested in maps/lists, or +embedded inside typed gateway responses (e.g. `McpDocumentContent`, A2A artifacts). The agent +extracts those documents into a synthetic follow-up `UserMessage` with native `DocumentContent` +blocks so LLMs can interpret them; see [ADR-004](../adr/004-document-handling-in-tool-call-results.md). + +`ToolCallResultDocumentExtractor` is the entrypoint, called from `AgentMessagesHandlerImpl` after +the `ToolCallResultMessage` is built. For each result it asks the registry which handler manages +the tool name (`GatewayToolHandlerRegistry.handlerForToolDefinition`) and either: + +- delegates to that handler's `extractDocuments(ToolCallResult)` — handlers walk their own typed + content (sealed-type `switch` over `McpContent` / `A2aSendMessageResult`), so documents inside + typed records remain discoverable; +- falls back to `ContentTreeDocumentWalker` — a stateless static utility that recursively walks + `Map`, `Collection`, `Object[]` and `Document` nodes. Used for plain BPMN tools whose content is + the raw FEEL tree from the engine. + +The default `GatewayToolHandler.extractDocuments` implementation also delegates to +`ContentTreeDocumentWalker`, so third-party handlers that return raw maps work without overriding. +Handlers whose typed content embeds raw user-generated subtrees can call the walker directly on +those subtrees. + +``` +ToolCallResultDocumentExtractor.extractDocuments(results) + ├─ for each result: + │ GatewayToolHandlerRegistry.handlerForToolDefinition(result.name()) + │ ├─ Some(handler) → handler.extractDocuments(result) ── typed walk + │ └─ None → ContentTreeDocumentWalker.extractDocumentsFromContent(content) + └─ groups documents by ToolCallDocuments(toolCallId, toolCallName, documents) +``` + --- diff --git a/connectors/agentic-ai/docs/reference/mcp.md b/connectors/agentic-ai/docs/reference/mcp.md index 1ee04c5f6fb..d75daeaa124 100644 --- a/connectors/agentic-ai/docs/reference/mcp.md +++ b/connectors/agentic-ai/docs/reference/mcp.md @@ -226,9 +226,15 @@ Discovery tool call IDs use a different format: `MCP_toolsList_` — 5. Result flows back as toolCallResult (McpClientCallToolResult) 6. McpClientGatewayToolHandler.transformToolCallResults(): - Extracts tool name from result, rebuilds fully qualified name - - Maps content: if single McpTextContent → use string directly, else use list + - Maps content: if single McpTextContent → use string directly, else use the typed + List as the transformed ToolCallResult content - Returns ToolCallResult with name="MCP_MyFilesystem___readFile" 7. Agent presents result to LLM with the original fully qualified tool name +8. McpClientGatewayToolHandler.extractDocuments() walks the List via a sealed-type + switch and collects Camunda Documents from McpDocumentContent and + McpEmbeddedResourceContent.BlobDocumentResource entries — feeding the synthetic document + UserMessage produced by ToolCallResultDocumentExtractor (see ai-agent.md §19 "Document + Extraction from Tool Call Results"). ``` --- diff --git a/connectors/agentic-ai/element-templates/README.md b/connectors/agentic-ai/element-templates/README.md index 5d20bda5b76..da06e1359f5 100644 --- a/connectors/agentic-ai/element-templates/README.md +++ b/connectors/agentic-ai/element-templates/README.md @@ -15,7 +15,7 @@ field in each JSON file captures the same information (e.g. `^8.9` means "requires Camunda 8.9 or later"). For example, if you are on Camunda 8.9, use the AI Agent template version `7`; -if you are on Camunda 8.10, use version `10`. +if you are on Camunda 8.10, use version `12`. ## AI Agent connectors @@ -26,7 +26,7 @@ The AI Agent ships in two flavors that share the same versioning scheme. | Connector | Minimum Camunda version | Template version | File | | --- | --- | --- | --- | -| AI Agent Task | 8.10 | 10 | [`agenticai-aiagent-outbound-connector.json`](./agenticai-aiagent-outbound-connector.json) | +| AI Agent Task | 8.10 | 12 | [`agenticai-aiagent-outbound-connector.json`](./agenticai-aiagent-outbound-connector.json) | | AI Agent Task | 8.9 | 7 | [`versioned/agenticai-aiagent-outbound-connector-7.json`](./versioned/agenticai-aiagent-outbound-connector-7.json) | | AI Agent Task | 8.8 | 5 | [`versioned/agenticai-aiagent-outbound-connector-5.json`](./versioned/agenticai-aiagent-outbound-connector-5.json) | @@ -34,7 +34,7 @@ The AI Agent ships in two flavors that share the same versioning scheme. | Connector | Minimum Camunda version | Template version | File | | --- | --- | --- | --- | -| AI Agent Sub-process | 8.10 | 10 | [`agenticai-aiagent-job-worker.json`](./agenticai-aiagent-job-worker.json) | +| AI Agent Sub-process | 8.10 | 12 | [`agenticai-aiagent-job-worker.json`](./agenticai-aiagent-job-worker.json) | | AI Agent Sub-process | 8.9 | 7 | [`versioned/agenticai-aiagent-job-worker-7.json`](./versioned/agenticai-aiagent-job-worker-7.json) | | AI Agent Sub-process | 8.8 | 5 | [`versioned/agenticai-aiagent-job-worker-5.json`](./versioned/agenticai-aiagent-job-worker-5.json) | diff --git a/connectors/agentic-ai/element-templates/agenticai-aiagent-job-worker.json b/connectors/agentic-ai/element-templates/agenticai-aiagent-job-worker.json index b93dc37fb41..ff8e1f99ed1 100644 --- a/connectors/agentic-ai/element-templates/agenticai-aiagent-job-worker.json +++ b/connectors/agentic-ai/element-templates/agenticai-aiagent-job-worker.json @@ -5,7 +5,7 @@ "description" : "Run a multi-step AI reasoning loop with dynamic tool selection", "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-subprocess/", - "version" : 10, + "version" : 12, "category" : { "id" : "connectors", "name" : "Connectors" @@ -113,17 +113,11 @@ "name" : "AWS Bedrock", "value" : "bedrock" }, { - "name" : "Azure OpenAI", - "value" : "azureOpenAi" - }, { - "name" : "Google Vertex AI", - "value" : "google-vertex-ai" + "name" : "Google GenAI", + "value" : "googleGenAi" }, { "name" : "OpenAI", "value" : "openai" - }, { - "name" : "OpenAI Compatible", - "value" : "openaiCompatible" } ] }, { "id" : "provider.anthropic.endpoint", @@ -142,6 +136,59 @@ "type" : "simple" }, "type" : "String" + }, { + "id" : "provider.anthropic.backend", + "label" : "Backend", + "description" : "Specify the Anthropic backend to use.", + "optional" : false, + "value" : "direct", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.backend", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Direct (Anthropic API)", + "value" : "direct" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Google Vertex AI", + "value" : "vertex" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + } ] + }, { + "id" : "provider.anthropic.authentication.type", + "label" : "Authentication", + "description" : "Specify the Anthropic authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { "id" : "provider.anthropic.authentication.apiKey", "label" : "Anthropic API key", @@ -156,9 +203,116 @@ "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "anthropic", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -337,9 +491,9 @@ }, "type" : "String" }, { - "id" : "provider.azureOpenAi.endpoint", - "label" : "Endpoint", - "description" : "Specify Azure OpenAI endpoint. Details in the documentation.", + "id" : "provider.googleGenAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", "optional" : false, "constraints" : { "notEmpty" : true @@ -347,41 +501,19 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.endpoint", + "name" : "provider.googleGenAi.projectId", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Azure OpenAI authentication strategy.", - "value" : "apiKey", - "group" : "provider", - "binding" : { - "name" : "provider.azureOpenAi.authentication.type", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "Dropdown", - "choices" : [ { - "name" : "API key", - "value" : "apiKey" - }, { - "name" : "Client credentials", - "value" : "clientCredentials" - } ] - }, { - "id" : "provider.azureOpenAi.authentication.apiKey", - "label" : "API key", + "id" : "provider.googleGenAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", "optional" : false, "constraints" : { "notEmpty" : true @@ -389,51 +521,42 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.apiKey", + "name" : "provider.googleGenAi.region", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "apiKey", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.clientId", - "label" : "Client ID", - "description" : "ID of a Microsoft Entra application", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.googleGenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientId", + "name" : "provider.googleGenAi.authentication.type", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] }, { - "id" : "provider.azureOpenAi.authentication.clientSecret", - "label" : "Client secret", - "description" : "Secret of a Microsoft Entra application", + "id" : "provider.googleGenAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", "optional" : false, "constraints" : { "notEmpty" : true @@ -441,154 +564,167 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientSecret", + "name" : "provider.googleGenAi.authentication.jsonKey", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", + "property" : "provider.googleGenAi.authentication.type", + "equals" : "serviceAccountCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.tenantId", - "label" : "Tenant ID", - "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "id" : "provider.googleGenAi.backend", + "label" : "Backend", + "description" : "Specify the Google GenAI backend to use.", "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "value" : "vertex", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.tenantId", + "name" : "provider.googleGenAi.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Vertex AI", + "value" : "vertex" + }, { + "name" : "Developer API (Google AI Studio)", + "value" : "developer-api" + } ] }, { - "id" : "provider.azureOpenAi.authentication.authorityHost", - "label" : "Authority host", - "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.backend", + "label" : "Backend", + "description" : "Specify the OpenAI backend to use.", + "optional" : false, + "value" : "openai", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.authorityHost", + "name" : "provider.openai.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "OpenAI", + "value" : "openai" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + }, { + "name" : "Custom", + "value" : "custom" + } ] }, { - "id" : "provider.azureOpenAi.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.authentication.type", + "label" : "Authentication", + "description" : "Specify the OpenAI authentication strategy.", + "value" : "apiKey", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.timeouts.timeout", + "name" : "provider.openai.authentication.type", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "openai", "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { - "id" : "provider.googleVertexAi.projectId", - "label" : "Project ID", - "description" : "Specify Google Cloud project ID", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.apiKey", + "label" : "API key", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.projectId", + "name" : "provider.openai.authentication.apiKey", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.region", - "label" : "Region", - "description" : "Specify the region where AI inference should take place", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.region", + "name" : "provider.openai.authentication.organizationId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Google Vertex AI authentication strategy.", - "value" : "serviceAccountCredentials", + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.type", + "name" : "provider.openai.authentication.projectId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, - "type" : "Dropdown", - "choices" : [ { - "name" : "Service account credentials", - "value" : "serviceAccountCredentials" - }, { - "name" : "Application default credentials (Hybrid/Self-Managed only)", - "value" : "applicationDefaultCredentials" - } ] + "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.jsonKey", - "label" : "JSON key of the service account", - "description" : "This is the key of the service account in JSON format.", + "id" : "provider.openai.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -596,24 +732,25 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.jsonKey", + "name" : "provider.openai.authentication.clientId", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.googleVertexAi.authentication.type", - "equals" : "serviceAccountCredentials", + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "openai", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.apiKey", - "label" : "OpenAI API key", + "id" : "provider.openai.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -621,47 +758,68 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.apiKey", + "name" : "provider.openai.authentication.clientSecret", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.organizationId", - "label" : "Organization ID", - "description" : "For members of multiple organizations. Details in the documentation.", - "optional" : true, + "id" : "provider.openai.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.organizationId", + "name" : "provider.openai.authentication.tenantId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.projectId", - "label" : "Project ID", - "description" : "For accounts with multiple projects. Details in the documentation.", + "id" : "provider.openai.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.projectId", + "name" : "provider.openai.authentication.authorityHost", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -682,90 +840,77 @@ }, "type" : "String" }, { - "id" : "provider.openaiCompatible.endpoint", - "label" : "API endpoint", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.endpoint", + "name" : "provider.openai.apiFamily", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Specify an endpoint to use the connector with an OpenAI compatible API. ", - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] }, { - "id" : "provider.openaiCompatible.authentication.apiKey", - "label" : "API key", + "id" : "provider.openai.endpoint", + "label" : "Endpoint", + "description" : "Optional. Override the default OpenAI base URL (e.g. for an OpenAI proxy or gateway). Leave blank to use the SDK default.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.authentication.apiKey", + "name" : "provider.openai.endpoint", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Leave blank if using HTTP headers for authentication.
If an Authorization header is specified in the headers, then the API key is ignored.", "type" : "String" }, { - "id" : "provider.openaiCompatible.headers", + "id" : "provider.openai.headers", "label" : "Headers", "description" : "Map of HTTP headers to add to the request.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.headers", + "name" : "provider.openai.headers", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.openaiCompatible.queryParameters", + "id" : "provider.openai.queryParameters", "label" : "Query Parameters", "description" : "Map of query parameters to add to the request URL.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.queryParameters", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", - "group" : "provider", - "binding" : { - "name" : "provider.openaiCompatible.timeouts.timeout", + "name" : "provider.openai.queryParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -931,78 +1076,7 @@ "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.azureOpenAi.model.deploymentName", - "label" : "Model deployment name", - "description" : "Specify the model deployment name. Details in the documentation.", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.deploymentName", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.azureOpenAi.model.parameters.maxTokens", - "label" : "Maximum tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.maxTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.googleVertexAi.model.model", + "id" : "provider.googleGenAi.model.model", "label" : "Model", "description" : "Specify the model ID. Details in the documentation.", "optional" : false, @@ -1012,79 +1086,79 @@ "feel" : "optional", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.model", + "name" : "provider.googleGenAi.model.model", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "id" : "provider.googleGenAi.model.parameters.maxOutputTokens", "label" : "Maximum output tokens", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "name" : "provider.googleGenAi.model.parameters.maxOutputTokens", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.temperature", + "id" : "provider.googleGenAi.model.parameters.temperature", "label" : "Temperature", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.temperature", + "name" : "provider.googleGenAi.model.parameters.temperature", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topP", + "id" : "provider.googleGenAi.model.parameters.topP", "label" : "top P", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topP", + "name" : "provider.googleGenAi.model.parameters.topP", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topK", + "id" : "provider.googleGenAi.model.parameters.topK", "label" : "top K", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topK", + "name" : "provider.googleGenAi.model.parameters.topK", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", @@ -1162,91 +1236,19 @@ "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.openaiCompatible.model.model", - "label" : "Model", - "description" : "Specify the model ID. Details in the documentation.", - "optional" : false, - "value" : "gpt-4o", - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.model", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "label" : "Maximum completion tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.customParameters", + "id" : "provider.openai.model.parameters.customParameters", "label" : "Custom parameters", "description" : "Map of additional request parameters to include.", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.openaiCompatible.model.parameters.customParameters", + "name" : "provider.openai.model.parameters.customParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -1703,7 +1705,7 @@ "id" : "version", "label" : "Version", "description" : "Version of the element template", - "value" : "10", + "value" : "12", "group" : "connector", "binding" : { "key" : "elementTemplateVersion", diff --git a/connectors/agentic-ai/element-templates/agenticai-aiagent-outbound-connector.json b/connectors/agentic-ai/element-templates/agenticai-aiagent-outbound-connector.json index a07015eb55f..3982bf9e981 100644 --- a/connectors/agentic-ai/element-templates/agenticai-aiagent-outbound-connector.json +++ b/connectors/agentic-ai/element-templates/agenticai-aiagent-outbound-connector.json @@ -5,7 +5,7 @@ "description" : "Execute a single AI-powered action with tool calling capabilities", "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-task/", - "version" : 10, + "version" : 12, "category" : { "id" : "connectors", "name" : "Connectors" @@ -92,17 +92,11 @@ "name" : "AWS Bedrock", "value" : "bedrock" }, { - "name" : "Azure OpenAI", - "value" : "azureOpenAi" - }, { - "name" : "Google Vertex AI", - "value" : "google-vertex-ai" + "name" : "Google GenAI", + "value" : "googleGenAi" }, { "name" : "OpenAI", "value" : "openai" - }, { - "name" : "OpenAI Compatible", - "value" : "openaiCompatible" } ] }, { "id" : "provider.anthropic.endpoint", @@ -121,6 +115,59 @@ "type" : "simple" }, "type" : "String" + }, { + "id" : "provider.anthropic.backend", + "label" : "Backend", + "description" : "Specify the Anthropic backend to use.", + "optional" : false, + "value" : "direct", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.backend", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Direct (Anthropic API)", + "value" : "direct" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Google Vertex AI", + "value" : "vertex" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + } ] + }, { + "id" : "provider.anthropic.authentication.type", + "label" : "Authentication", + "description" : "Specify the Anthropic authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { "id" : "provider.anthropic.authentication.apiKey", "label" : "Anthropic API key", @@ -135,9 +182,116 @@ "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "anthropic", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -316,9 +470,9 @@ }, "type" : "String" }, { - "id" : "provider.azureOpenAi.endpoint", - "label" : "Endpoint", - "description" : "Specify Azure OpenAI endpoint. Details in the documentation.", + "id" : "provider.googleGenAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", "optional" : false, "constraints" : { "notEmpty" : true @@ -326,41 +480,19 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.endpoint", + "name" : "provider.googleGenAi.projectId", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Azure OpenAI authentication strategy.", - "value" : "apiKey", - "group" : "provider", - "binding" : { - "name" : "provider.azureOpenAi.authentication.type", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "Dropdown", - "choices" : [ { - "name" : "API key", - "value" : "apiKey" - }, { - "name" : "Client credentials", - "value" : "clientCredentials" - } ] - }, { - "id" : "provider.azureOpenAi.authentication.apiKey", - "label" : "API key", + "id" : "provider.googleGenAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", "optional" : false, "constraints" : { "notEmpty" : true @@ -368,51 +500,42 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.apiKey", + "name" : "provider.googleGenAi.region", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "apiKey", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.clientId", - "label" : "Client ID", - "description" : "ID of a Microsoft Entra application", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.googleGenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientId", + "name" : "provider.googleGenAi.authentication.type", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] }, { - "id" : "provider.azureOpenAi.authentication.clientSecret", - "label" : "Client secret", - "description" : "Secret of a Microsoft Entra application", + "id" : "provider.googleGenAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", "optional" : false, "constraints" : { "notEmpty" : true @@ -420,154 +543,167 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientSecret", + "name" : "provider.googleGenAi.authentication.jsonKey", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", + "property" : "provider.googleGenAi.authentication.type", + "equals" : "serviceAccountCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.tenantId", - "label" : "Tenant ID", - "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "id" : "provider.googleGenAi.backend", + "label" : "Backend", + "description" : "Specify the Google GenAI backend to use.", "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "value" : "vertex", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.tenantId", + "name" : "provider.googleGenAi.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Vertex AI", + "value" : "vertex" + }, { + "name" : "Developer API (Google AI Studio)", + "value" : "developer-api" + } ] }, { - "id" : "provider.azureOpenAi.authentication.authorityHost", - "label" : "Authority host", - "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.backend", + "label" : "Backend", + "description" : "Specify the OpenAI backend to use.", + "optional" : false, + "value" : "openai", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.authorityHost", + "name" : "provider.openai.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "OpenAI", + "value" : "openai" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + }, { + "name" : "Custom", + "value" : "custom" + } ] }, { - "id" : "provider.azureOpenAi.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.authentication.type", + "label" : "Authentication", + "description" : "Specify the OpenAI authentication strategy.", + "value" : "apiKey", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.timeouts.timeout", + "name" : "provider.openai.authentication.type", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "openai", "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { - "id" : "provider.googleVertexAi.projectId", - "label" : "Project ID", - "description" : "Specify Google Cloud project ID", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.apiKey", + "label" : "API key", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.projectId", + "name" : "provider.openai.authentication.apiKey", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.region", - "label" : "Region", - "description" : "Specify the region where AI inference should take place", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.region", + "name" : "provider.openai.authentication.organizationId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Google Vertex AI authentication strategy.", - "value" : "serviceAccountCredentials", + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.type", + "name" : "provider.openai.authentication.projectId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, - "type" : "Dropdown", - "choices" : [ { - "name" : "Service account credentials", - "value" : "serviceAccountCredentials" - }, { - "name" : "Application default credentials (Hybrid/Self-Managed only)", - "value" : "applicationDefaultCredentials" - } ] + "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.jsonKey", - "label" : "JSON key of the service account", - "description" : "This is the key of the service account in JSON format.", + "id" : "provider.openai.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -575,24 +711,25 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.jsonKey", + "name" : "provider.openai.authentication.clientId", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.googleVertexAi.authentication.type", - "equals" : "serviceAccountCredentials", + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "openai", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.apiKey", - "label" : "OpenAI API key", + "id" : "provider.openai.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -600,47 +737,68 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.apiKey", + "name" : "provider.openai.authentication.clientSecret", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.organizationId", - "label" : "Organization ID", - "description" : "For members of multiple organizations. Details in the documentation.", - "optional" : true, + "id" : "provider.openai.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.organizationId", + "name" : "provider.openai.authentication.tenantId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.projectId", - "label" : "Project ID", - "description" : "For accounts with multiple projects. Details in the documentation.", + "id" : "provider.openai.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.projectId", + "name" : "provider.openai.authentication.authorityHost", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -661,90 +819,77 @@ }, "type" : "String" }, { - "id" : "provider.openaiCompatible.endpoint", - "label" : "API endpoint", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.endpoint", + "name" : "provider.openai.apiFamily", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Specify an endpoint to use the connector with an OpenAI compatible API. ", - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] }, { - "id" : "provider.openaiCompatible.authentication.apiKey", - "label" : "API key", + "id" : "provider.openai.endpoint", + "label" : "Endpoint", + "description" : "Optional. Override the default OpenAI base URL (e.g. for an OpenAI proxy or gateway). Leave blank to use the SDK default.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.authentication.apiKey", + "name" : "provider.openai.endpoint", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Leave blank if using HTTP headers for authentication.
If an Authorization header is specified in the headers, then the API key is ignored.", "type" : "String" }, { - "id" : "provider.openaiCompatible.headers", + "id" : "provider.openai.headers", "label" : "Headers", "description" : "Map of HTTP headers to add to the request.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.headers", + "name" : "provider.openai.headers", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.openaiCompatible.queryParameters", + "id" : "provider.openai.queryParameters", "label" : "Query Parameters", "description" : "Map of query parameters to add to the request URL.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.queryParameters", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", - "group" : "provider", - "binding" : { - "name" : "provider.openaiCompatible.timeouts.timeout", + "name" : "provider.openai.queryParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -910,78 +1055,7 @@ "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.azureOpenAi.model.deploymentName", - "label" : "Model deployment name", - "description" : "Specify the model deployment name. Details in the documentation.", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.deploymentName", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.azureOpenAi.model.parameters.maxTokens", - "label" : "Maximum tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.maxTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.googleVertexAi.model.model", + "id" : "provider.googleGenAi.model.model", "label" : "Model", "description" : "Specify the model ID. Details in the documentation.", "optional" : false, @@ -991,79 +1065,79 @@ "feel" : "optional", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.model", + "name" : "provider.googleGenAi.model.model", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "id" : "provider.googleGenAi.model.parameters.maxOutputTokens", "label" : "Maximum output tokens", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "name" : "provider.googleGenAi.model.parameters.maxOutputTokens", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.temperature", + "id" : "provider.googleGenAi.model.parameters.temperature", "label" : "Temperature", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.temperature", + "name" : "provider.googleGenAi.model.parameters.temperature", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topP", + "id" : "provider.googleGenAi.model.parameters.topP", "label" : "top P", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topP", + "name" : "provider.googleGenAi.model.parameters.topP", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topK", + "id" : "provider.googleGenAi.model.parameters.topK", "label" : "top K", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topK", + "name" : "provider.googleGenAi.model.parameters.topK", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", @@ -1141,91 +1215,19 @@ "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.openaiCompatible.model.model", - "label" : "Model", - "description" : "Specify the model ID. Details in the documentation.", - "optional" : false, - "value" : "gpt-4o", - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.model", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "label" : "Maximum completion tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.customParameters", + "id" : "provider.openai.model.parameters.customParameters", "label" : "Custom parameters", "description" : "Map of additional request parameters to include.", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.openaiCompatible.model.parameters.customParameters", + "name" : "provider.openai.model.parameters.customParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -1677,7 +1679,7 @@ "id" : "version", "label" : "Version", "description" : "Version of the element template", - "value" : "10", + "value" : "12", "group" : "connector", "binding" : { "key" : "elementTemplateVersion", diff --git a/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-job-worker-hybrid.json b/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-job-worker-hybrid.json index 26deb07fad6..3ca2a8afb8d 100644 --- a/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-job-worker-hybrid.json +++ b/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-job-worker-hybrid.json @@ -5,7 +5,7 @@ "description" : "Run a multi-step AI reasoning loop with dynamic tool selection", "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-subprocess/", - "version" : 10, + "version" : 12, "category" : { "id" : "connectors", "name" : "Connectors" @@ -118,17 +118,11 @@ "name" : "AWS Bedrock", "value" : "bedrock" }, { - "name" : "Azure OpenAI", - "value" : "azureOpenAi" - }, { - "name" : "Google Vertex AI", - "value" : "google-vertex-ai" + "name" : "Google GenAI", + "value" : "googleGenAi" }, { "name" : "OpenAI", "value" : "openai" - }, { - "name" : "OpenAI Compatible", - "value" : "openaiCompatible" } ] }, { "id" : "provider.anthropic.endpoint", @@ -147,6 +141,59 @@ "type" : "simple" }, "type" : "String" + }, { + "id" : "provider.anthropic.backend", + "label" : "Backend", + "description" : "Specify the Anthropic backend to use.", + "optional" : false, + "value" : "direct", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.backend", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Direct (Anthropic API)", + "value" : "direct" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Google Vertex AI", + "value" : "vertex" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + } ] + }, { + "id" : "provider.anthropic.authentication.type", + "label" : "Authentication", + "description" : "Specify the Anthropic authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { "id" : "provider.anthropic.authentication.apiKey", "label" : "Anthropic API key", @@ -161,9 +208,116 @@ "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "anthropic", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -342,9 +496,9 @@ }, "type" : "String" }, { - "id" : "provider.azureOpenAi.endpoint", - "label" : "Endpoint", - "description" : "Specify Azure OpenAI endpoint. Details in the documentation.", + "id" : "provider.googleGenAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", "optional" : false, "constraints" : { "notEmpty" : true @@ -352,41 +506,19 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.endpoint", + "name" : "provider.googleGenAi.projectId", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Azure OpenAI authentication strategy.", - "value" : "apiKey", - "group" : "provider", - "binding" : { - "name" : "provider.azureOpenAi.authentication.type", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "Dropdown", - "choices" : [ { - "name" : "API key", - "value" : "apiKey" - }, { - "name" : "Client credentials", - "value" : "clientCredentials" - } ] - }, { - "id" : "provider.azureOpenAi.authentication.apiKey", - "label" : "API key", + "id" : "provider.googleGenAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", "optional" : false, "constraints" : { "notEmpty" : true @@ -394,51 +526,42 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.apiKey", + "name" : "provider.googleGenAi.region", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "apiKey", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.clientId", - "label" : "Client ID", - "description" : "ID of a Microsoft Entra application", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.googleGenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientId", + "name" : "provider.googleGenAi.authentication.type", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] }, { - "id" : "provider.azureOpenAi.authentication.clientSecret", - "label" : "Client secret", - "description" : "Secret of a Microsoft Entra application", + "id" : "provider.googleGenAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", "optional" : false, "constraints" : { "notEmpty" : true @@ -446,154 +569,167 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientSecret", + "name" : "provider.googleGenAi.authentication.jsonKey", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", + "property" : "provider.googleGenAi.authentication.type", + "equals" : "serviceAccountCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.tenantId", - "label" : "Tenant ID", - "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "id" : "provider.googleGenAi.backend", + "label" : "Backend", + "description" : "Specify the Google GenAI backend to use.", "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "value" : "vertex", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.tenantId", + "name" : "provider.googleGenAi.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Vertex AI", + "value" : "vertex" + }, { + "name" : "Developer API (Google AI Studio)", + "value" : "developer-api" + } ] }, { - "id" : "provider.azureOpenAi.authentication.authorityHost", - "label" : "Authority host", - "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.backend", + "label" : "Backend", + "description" : "Specify the OpenAI backend to use.", + "optional" : false, + "value" : "openai", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.authorityHost", + "name" : "provider.openai.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "OpenAI", + "value" : "openai" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + }, { + "name" : "Custom", + "value" : "custom" + } ] }, { - "id" : "provider.azureOpenAi.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.authentication.type", + "label" : "Authentication", + "description" : "Specify the OpenAI authentication strategy.", + "value" : "apiKey", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.timeouts.timeout", + "name" : "provider.openai.authentication.type", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "openai", "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { - "id" : "provider.googleVertexAi.projectId", - "label" : "Project ID", - "description" : "Specify Google Cloud project ID", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.apiKey", + "label" : "API key", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.projectId", + "name" : "provider.openai.authentication.apiKey", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.region", - "label" : "Region", - "description" : "Specify the region where AI inference should take place", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.region", + "name" : "provider.openai.authentication.organizationId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Google Vertex AI authentication strategy.", - "value" : "serviceAccountCredentials", + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.type", + "name" : "provider.openai.authentication.projectId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, - "type" : "Dropdown", - "choices" : [ { - "name" : "Service account credentials", - "value" : "serviceAccountCredentials" - }, { - "name" : "Application default credentials (Hybrid/Self-Managed only)", - "value" : "applicationDefaultCredentials" - } ] + "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.jsonKey", - "label" : "JSON key of the service account", - "description" : "This is the key of the service account in JSON format.", + "id" : "provider.openai.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -601,24 +737,25 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.jsonKey", + "name" : "provider.openai.authentication.clientId", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.googleVertexAi.authentication.type", - "equals" : "serviceAccountCredentials", + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "openai", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.apiKey", - "label" : "OpenAI API key", + "id" : "provider.openai.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -626,47 +763,68 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.apiKey", + "name" : "provider.openai.authentication.clientSecret", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.organizationId", - "label" : "Organization ID", - "description" : "For members of multiple organizations. Details in the documentation.", - "optional" : true, + "id" : "provider.openai.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.organizationId", + "name" : "provider.openai.authentication.tenantId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.projectId", - "label" : "Project ID", - "description" : "For accounts with multiple projects. Details in the documentation.", + "id" : "provider.openai.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.projectId", + "name" : "provider.openai.authentication.authorityHost", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -687,90 +845,77 @@ }, "type" : "String" }, { - "id" : "provider.openaiCompatible.endpoint", - "label" : "API endpoint", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.endpoint", + "name" : "provider.openai.apiFamily", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Specify an endpoint to use the connector with an OpenAI compatible API. ", - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] }, { - "id" : "provider.openaiCompatible.authentication.apiKey", - "label" : "API key", + "id" : "provider.openai.endpoint", + "label" : "Endpoint", + "description" : "Optional. Override the default OpenAI base URL (e.g. for an OpenAI proxy or gateway). Leave blank to use the SDK default.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.authentication.apiKey", + "name" : "provider.openai.endpoint", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Leave blank if using HTTP headers for authentication.
If an Authorization header is specified in the headers, then the API key is ignored.", "type" : "String" }, { - "id" : "provider.openaiCompatible.headers", + "id" : "provider.openai.headers", "label" : "Headers", "description" : "Map of HTTP headers to add to the request.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.headers", + "name" : "provider.openai.headers", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.openaiCompatible.queryParameters", + "id" : "provider.openai.queryParameters", "label" : "Query Parameters", "description" : "Map of query parameters to add to the request URL.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.queryParameters", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", - "group" : "provider", - "binding" : { - "name" : "provider.openaiCompatible.timeouts.timeout", + "name" : "provider.openai.queryParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -936,78 +1081,7 @@ "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.azureOpenAi.model.deploymentName", - "label" : "Model deployment name", - "description" : "Specify the model deployment name. Details in the documentation.", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.deploymentName", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.azureOpenAi.model.parameters.maxTokens", - "label" : "Maximum tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.maxTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.googleVertexAi.model.model", + "id" : "provider.googleGenAi.model.model", "label" : "Model", "description" : "Specify the model ID. Details in the documentation.", "optional" : false, @@ -1017,79 +1091,79 @@ "feel" : "optional", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.model", + "name" : "provider.googleGenAi.model.model", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "id" : "provider.googleGenAi.model.parameters.maxOutputTokens", "label" : "Maximum output tokens", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "name" : "provider.googleGenAi.model.parameters.maxOutputTokens", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.temperature", + "id" : "provider.googleGenAi.model.parameters.temperature", "label" : "Temperature", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.temperature", + "name" : "provider.googleGenAi.model.parameters.temperature", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topP", + "id" : "provider.googleGenAi.model.parameters.topP", "label" : "top P", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topP", + "name" : "provider.googleGenAi.model.parameters.topP", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topK", + "id" : "provider.googleGenAi.model.parameters.topK", "label" : "top K", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topK", + "name" : "provider.googleGenAi.model.parameters.topK", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", @@ -1167,91 +1241,19 @@ "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.openaiCompatible.model.model", - "label" : "Model", - "description" : "Specify the model ID. Details in the documentation.", - "optional" : false, - "value" : "gpt-4o", - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.model", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "label" : "Maximum completion tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.customParameters", + "id" : "provider.openai.model.parameters.customParameters", "label" : "Custom parameters", "description" : "Map of additional request parameters to include.", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.openaiCompatible.model.parameters.customParameters", + "name" : "provider.openai.model.parameters.customParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -1708,7 +1710,7 @@ "id" : "version", "label" : "Version", "description" : "Version of the element template", - "value" : "10", + "value" : "12", "group" : "connector", "binding" : { "key" : "elementTemplateVersion", diff --git a/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-outbound-connector-hybrid.json b/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-outbound-connector-hybrid.json index 45ead89d618..030e626b39a 100644 --- a/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-outbound-connector-hybrid.json +++ b/connectors/agentic-ai/element-templates/hybrid/agenticai-aiagent-outbound-connector-hybrid.json @@ -5,7 +5,7 @@ "description" : "Execute a single AI-powered action with tool calling capabilities", "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-task/", - "version" : 10, + "version" : 12, "category" : { "id" : "connectors", "name" : "Connectors" @@ -97,17 +97,11 @@ "name" : "AWS Bedrock", "value" : "bedrock" }, { - "name" : "Azure OpenAI", - "value" : "azureOpenAi" - }, { - "name" : "Google Vertex AI", - "value" : "google-vertex-ai" + "name" : "Google GenAI", + "value" : "googleGenAi" }, { "name" : "OpenAI", "value" : "openai" - }, { - "name" : "OpenAI Compatible", - "value" : "openaiCompatible" } ] }, { "id" : "provider.anthropic.endpoint", @@ -126,6 +120,59 @@ "type" : "simple" }, "type" : "String" + }, { + "id" : "provider.anthropic.backend", + "label" : "Backend", + "description" : "Specify the Anthropic backend to use.", + "optional" : false, + "value" : "direct", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.backend", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Direct (Anthropic API)", + "value" : "direct" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Google Vertex AI", + "value" : "vertex" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + } ] + }, { + "id" : "provider.anthropic.authentication.type", + "label" : "Authentication", + "description" : "Specify the Anthropic authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { "id" : "provider.anthropic.authentication.apiKey", "label" : "Anthropic API key", @@ -140,9 +187,116 @@ "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "anthropic", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -321,9 +475,9 @@ }, "type" : "String" }, { - "id" : "provider.azureOpenAi.endpoint", - "label" : "Endpoint", - "description" : "Specify Azure OpenAI endpoint. Details in the documentation.", + "id" : "provider.googleGenAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", "optional" : false, "constraints" : { "notEmpty" : true @@ -331,41 +485,19 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.endpoint", + "name" : "provider.googleGenAi.projectId", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Azure OpenAI authentication strategy.", - "value" : "apiKey", - "group" : "provider", - "binding" : { - "name" : "provider.azureOpenAi.authentication.type", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "Dropdown", - "choices" : [ { - "name" : "API key", - "value" : "apiKey" - }, { - "name" : "Client credentials", - "value" : "clientCredentials" - } ] - }, { - "id" : "provider.azureOpenAi.authentication.apiKey", - "label" : "API key", + "id" : "provider.googleGenAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", "optional" : false, "constraints" : { "notEmpty" : true @@ -373,51 +505,42 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.apiKey", + "name" : "provider.googleGenAi.region", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "apiKey", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.clientId", - "label" : "Client ID", - "description" : "ID of a Microsoft Entra application", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.googleGenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientId", + "name" : "provider.googleGenAi.authentication.type", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] }, { - "id" : "provider.azureOpenAi.authentication.clientSecret", - "label" : "Client secret", - "description" : "Secret of a Microsoft Entra application", + "id" : "provider.googleGenAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", "optional" : false, "constraints" : { "notEmpty" : true @@ -425,154 +548,167 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.clientSecret", + "name" : "provider.googleGenAi.authentication.jsonKey", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", + "property" : "provider.googleGenAi.authentication.type", + "equals" : "serviceAccountCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "googleGenAi", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.azureOpenAi.authentication.tenantId", - "label" : "Tenant ID", - "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "id" : "provider.googleGenAi.backend", + "label" : "Backend", + "description" : "Specify the Google GenAI backend to use.", "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "value" : "vertex", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.tenantId", + "name" : "provider.googleGenAi.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Vertex AI", + "value" : "vertex" + }, { + "name" : "Developer API (Google AI Studio)", + "value" : "developer-api" + } ] }, { - "id" : "provider.azureOpenAi.authentication.authorityHost", - "label" : "Authority host", - "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.backend", + "label" : "Backend", + "description" : "Specify the OpenAI backend to use.", + "optional" : false, + "value" : "openai", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.authentication.authorityHost", + "name" : "provider.openai.backend", "type" : "zeebe:input" }, "condition" : { - "allMatch" : [ { - "property" : "provider.azureOpenAi.authentication.type", - "equals" : "clientCredentials", - "type" : "simple" - }, { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - } ] + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "OpenAI", + "value" : "openai" + }, { + "name" : "Azure AI Foundry", + "value" : "foundry" + }, { + "name" : "Custom", + "value" : "custom" + } ] }, { - "id" : "provider.azureOpenAi.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", + "id" : "provider.openai.authentication.type", + "label" : "Authentication", + "description" : "Specify the OpenAI authentication strategy.", + "value" : "apiKey", "group" : "provider", "binding" : { - "name" : "provider.azureOpenAi.timeouts.timeout", + "name" : "provider.openai.authentication.type", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "azureOpenAi", + "equals" : "openai", "type" : "simple" }, - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] }, { - "id" : "provider.googleVertexAi.projectId", - "label" : "Project ID", - "description" : "Specify Google Cloud project ID", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.apiKey", + "label" : "API key", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.projectId", + "name" : "provider.openai.authentication.apiKey", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.region", - "label" : "Region", - "description" : "Specify the region where AI inference should take place", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.region", + "name" : "provider.openai.authentication.organizationId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.type", - "label" : "Authentication", - "description" : "Specify the Google Vertex AI authentication strategy.", - "value" : "serviceAccountCredentials", + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.type", + "name" : "provider.openai.authentication.projectId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "google-vertex-ai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, - "type" : "Dropdown", - "choices" : [ { - "name" : "Service account credentials", - "value" : "serviceAccountCredentials" - }, { - "name" : "Application default credentials (Hybrid/Self-Managed only)", - "value" : "applicationDefaultCredentials" - } ] + "type" : "String" }, { - "id" : "provider.googleVertexAi.authentication.jsonKey", - "label" : "JSON key of the service account", - "description" : "This is the key of the service account in JSON format.", + "id" : "provider.openai.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -580,24 +716,25 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.googleVertexAi.authentication.jsonKey", + "name" : "provider.openai.authentication.clientId", "type" : "zeebe:input" }, "condition" : { "allMatch" : [ { - "property" : "provider.googleVertexAi.authentication.type", - "equals" : "serviceAccountCredentials", + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", "type" : "simple" }, { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "openai", "type" : "simple" } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.apiKey", - "label" : "OpenAI API key", + "id" : "provider.openai.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", "optional" : false, "constraints" : { "notEmpty" : true @@ -605,47 +742,68 @@ "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.apiKey", + "name" : "provider.openai.authentication.clientSecret", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.organizationId", - "label" : "Organization ID", - "description" : "For members of multiple organizations. Details in the documentation.", - "optional" : true, + "id" : "provider.openai.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.organizationId", + "name" : "provider.openai.authentication.tenantId", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { - "id" : "provider.openai.authentication.projectId", - "label" : "Project ID", - "description" : "For accounts with multiple projects. Details in the documentation.", + "id" : "provider.openai.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openai.authentication.projectId", + "name" : "provider.openai.authentication.authorityHost", "type" : "zeebe:input" }, "condition" : { - "property" : "provider.type", - "equals" : "openai", - "type" : "simple" + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] }, "type" : "String" }, { @@ -666,90 +824,77 @@ }, "type" : "String" }, { - "id" : "provider.openaiCompatible.endpoint", - "label" : "API endpoint", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.endpoint", + "name" : "provider.openai.apiFamily", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Specify an endpoint to use the connector with an OpenAI compatible API. ", - "type" : "String" + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] }, { - "id" : "provider.openaiCompatible.authentication.apiKey", - "label" : "API key", + "id" : "provider.openai.endpoint", + "label" : "Endpoint", + "description" : "Optional. Override the default OpenAI base URL (e.g. for an OpenAI proxy or gateway). Leave blank to use the SDK default.", "optional" : true, "feel" : "optional", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.authentication.apiKey", + "name" : "provider.openai.endpoint", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, - "tooltip" : "Leave blank if using HTTP headers for authentication.
If an Authorization header is specified in the headers, then the API key is ignored.", "type" : "String" }, { - "id" : "provider.openaiCompatible.headers", + "id" : "provider.openai.headers", "label" : "Headers", "description" : "Map of HTTP headers to add to the request.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.headers", + "name" : "provider.openai.headers", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.openaiCompatible.queryParameters", + "id" : "provider.openai.queryParameters", "label" : "Query Parameters", "description" : "Map of query parameters to add to the request URL.", "optional" : true, "feel" : "required", "group" : "provider", "binding" : { - "name" : "provider.openaiCompatible.queryParameters", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.timeouts.timeout", - "label" : "Timeout", - "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", - "optional" : true, - "feel" : "optional", - "group" : "provider", - "binding" : { - "name" : "provider.openaiCompatible.timeouts.timeout", + "name" : "provider.openai.queryParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -915,78 +1060,7 @@ "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.azureOpenAi.model.deploymentName", - "label" : "Model deployment name", - "description" : "Specify the model deployment name. Details in the documentation.", - "optional" : false, - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.deploymentName", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.azureOpenAi.model.parameters.maxTokens", - "label" : "Maximum tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.maxTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.azureOpenAi.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.azureOpenAi.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "azureOpenAi", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.googleVertexAi.model.model", + "id" : "provider.googleGenAi.model.model", "label" : "Model", "description" : "Specify the model ID. Details in the documentation.", "optional" : false, @@ -996,79 +1070,79 @@ "feel" : "optional", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.model", + "name" : "provider.googleGenAi.model.model", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "type" : "String" }, { - "id" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "id" : "provider.googleGenAi.model.parameters.maxOutputTokens", "label" : "Maximum output tokens", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "name" : "provider.googleGenAi.model.parameters.maxOutputTokens", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.temperature", + "id" : "provider.googleGenAi.model.parameters.temperature", "label" : "Temperature", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.temperature", + "name" : "provider.googleGenAi.model.parameters.temperature", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topP", + "id" : "provider.googleGenAi.model.parameters.topP", "label" : "top P", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topP", + "name" : "provider.googleGenAi.model.parameters.topP", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.googleVertexAi.model.parameters.topK", + "id" : "provider.googleGenAi.model.parameters.topK", "label" : "top K", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.googleVertexAi.model.parameters.topK", + "name" : "provider.googleGenAi.model.parameters.topK", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "google-vertex-ai", + "equals" : "googleGenAi", "type" : "simple" }, "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", @@ -1146,91 +1220,19 @@ "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", "type" : "Number" }, { - "id" : "provider.openaiCompatible.model.model", - "label" : "Model", - "description" : "Specify the model ID. Details in the documentation.", - "optional" : false, - "value" : "gpt-4o", - "constraints" : { - "notEmpty" : true - }, - "feel" : "optional", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.model", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "type" : "String" - }, { - "id" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "label" : "Maximum completion tokens", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.temperature", - "label" : "Temperature", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.temperature", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.topP", - "label" : "top P", - "optional" : true, - "feel" : "required", - "group" : "model", - "binding" : { - "name" : "provider.openaiCompatible.model.parameters.topP", - "type" : "zeebe:input" - }, - "condition" : { - "property" : "provider.type", - "equals" : "openaiCompatible", - "type" : "simple" - }, - "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - "type" : "Number" - }, { - "id" : "provider.openaiCompatible.model.parameters.customParameters", + "id" : "provider.openai.model.parameters.customParameters", "label" : "Custom parameters", "description" : "Map of additional request parameters to include.", "optional" : true, "feel" : "required", "group" : "model", "binding" : { - "name" : "provider.openaiCompatible.model.parameters.customParameters", + "name" : "provider.openai.model.parameters.customParameters", "type" : "zeebe:input" }, "condition" : { "property" : "provider.type", - "equals" : "openaiCompatible", + "equals" : "openai", "type" : "simple" }, "type" : "String" @@ -1682,7 +1684,7 @@ "id" : "version", "label" : "Version", "description" : "Version of the element template", - "value" : "10", + "value" : "12", "group" : "connector", "binding" : { "key" : "elementTemplateVersion", diff --git a/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-job-worker-10.json b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-job-worker-10.json new file mode 100644 index 00000000000..77b7001b799 --- /dev/null +++ b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-job-worker-10.json @@ -0,0 +1,1803 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "AI Agent Sub-process", + "id" : "io.camunda.connectors.agenticai.aiagent.jobworker.v1", + "description" : "Run a multi-step AI reasoning loop with dynamic tool selection", + "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], + "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-subprocess/", + "version" : 10, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:SubProcess" ], + "elementType" : { + "value" : "bpmn:AdHocSubProcess" + }, + "engines" : { + "camunda" : "^8.10" + }, + "groups" : [ { + "id" : "provider", + "label" : "Model provider", + "openByDefault" : false + }, { + "id" : "model", + "label" : "Model", + "openByDefault" : false + }, { + "id" : "systemPrompt", + "label" : "System prompt", + "tooltip" : "A system prompt is a set of foundational instructions given to a model before any user interaction begins. It defines the AI agent’s role, behavior, tone, and communication style, ensuring that responses remain consistent and aligned with the AI agent’s intended purpose. These instructions help shape how the model interprets and responds to user input throughout the conversation.", + "openByDefault" : false + }, { + "id" : "userPrompt", + "label" : "User prompt", + "tooltip" : "A user prompt is the message or question you give to the AI to start or continue a conversation. It tells the AI what you need, whether it's information, help with a task, or just a chat. The AI uses your prompt to understand how to respond.", + "openByDefault" : false + }, { + "id" : "tools", + "label" : "Tools", + "tooltip" : "Tools are optional features the AI Agent can use to perform specific tasks. Configure this if the agent should participate in a tools feedback loop.", + "openByDefault" : false + }, { + "id" : "memory", + "label" : "Memory", + "tooltip" : "Configuration of the Agent's short-term/conversational memory.", + "openByDefault" : false + }, { + "id" : "limits", + "label" : "Limits", + "openByDefault" : false + }, { + "id" : "events", + "label" : "Event handling", + "tooltip" : "Configure how event sub-process results are handled. Results are added as user messages to the running agent.", + "openByDefault" : false + }, { + "id" : "response", + "label" : "Response", + "tooltip" : "Configuration of the model response format and how to map the model response to the connector result.

Depending on the selection, the model response will be available as response.responseText or response.responseJson.

See documentation for details.", + "openByDefault" : false + }, { + "id" : "connector", + "label" : "Connector" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "value" : "io.camunda.agenticai:aiagent-job-worker:1", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "Hidden" + }, { + "id" : "outputCollection", + "binding" : { + "property" : "outputCollection", + "type" : "zeebe:adHoc" + }, + "value" : "toolCallResults", + "type" : "Hidden" + }, { + "id" : "outputElement", + "binding" : { + "property" : "outputElement", + "type" : "zeebe:adHoc" + }, + "value" : "={\n id: toolCall._meta.id,\n name: toolCall._meta.name,\n content: toolCallResult\n}", + "type" : "Hidden" + }, { + "id" : "provider.type", + "label" : "Provider", + "description" : "Specify the LLM provider to use.", + "value" : "anthropic", + "group" : "provider", + "binding" : { + "name" : "provider.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Anthropic", + "value" : "anthropic" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Azure OpenAI", + "value" : "azureOpenAi" + }, { + "name" : "Google Vertex AI", + "value" : "google-vertex-ai" + }, { + "name" : "OpenAI", + "value" : "openai" + }, { + "name" : "OpenAI Compatible", + "value" : "openaiCompatible" + } ] + }, { + "id" : "provider.anthropic.endpoint", + "label" : "Endpoint", + "description" : "Optional custom API endpoint", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.apiKey", + "label" : "Anthropic API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.region", + "label" : "Region", + "description" : "Specify the AWS region (example: eu-west-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy. Learn more at the documentation page", + "value" : "credentials", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "API Key", + "value" : "apiKey" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "provider.bedrock.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key tailored to a user, equipped with the necessary permissions", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.apiKey", + "label" : "API Key", + "description" : "Provide an API Key with permissions to interact with your AWS Bedrock Instance", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.endpoint", + "label" : "Endpoint", + "description" : "Specify Azure OpenAI endpoint. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Azure OpenAI authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] + }, { + "id" : "provider.azureOpenAi.authentication.apiKey", + "label" : "API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] + }, { + "id" : "provider.googleVertexAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.authentication.jsonKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.googleVertexAi.authentication.type", + "equals" : "serviceAccountCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.apiKey", + "label" : "OpenAI API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.organizationId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", + "group" : "provider", + "binding" : { + "name" : "provider.openai.apiFamily", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] + }, { + "id" : "provider.openaiCompatible.endpoint", + "label" : "API endpoint", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Specify an endpoint to use the connector with an OpenAI compatible API. ", + "type" : "String" + }, { + "id" : "provider.openaiCompatible.authentication.apiKey", + "label" : "API key", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Leave blank if using HTTP headers for authentication.
If an Authorization header is specified in the headers, then the API key is ignored.", + "type" : "String" + }, { + "id" : "provider.openaiCompatible.headers", + "label" : "Headers", + "description" : "Map of HTTP headers to add to the request.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.headers", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openaiCompatible.queryParameters", + "label" : "Query Parameters", + "description" : "Map of query parameters to add to the request URL.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.queryParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openaiCompatible.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "placeholder" : "claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.anthropic.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.model", + "label" : "Model", + "description" : "Specify an inference profile ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "placeholder" : "global.anthropic.claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.bedrock.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to allow in the generated response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.azureOpenAi.model.deploymentName", + "label" : "Model deployment name", + "description" : "Specify the model deployment name. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.deploymentName", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.azureOpenAi.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.azureOpenAi.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "label" : "Maximum output tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "value" : "gpt-4o", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.model.parameters.maxCompletionTokens", + "label" : "Maximum completion tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.maxCompletionTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "value" : "gpt-4o", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", + "label" : "Maximum completion tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.parameters.customParameters", + "label" : "Custom parameters", + "description" : "Map of additional request parameters to include.", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.customParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.systemPrompt.prompt", + "label" : "System prompt", + "optional" : false, + "value" : "=\"You are **TaskAgent**, a helpful, generic chat agent that can handle a wide variety of customer requests using your own domain knowledge **and** any tools explicitly provided to you at runtime.\n\nIf tools are provided, you should prefer them instead of guessing an answer. You can call the same tool multiple times by providing different input values. Don't guess any tools which were not explicitly configured. If no tool matches the request, try to generate an answer. If you're not able to find a good answer, return with a message stating why you're not able to.\n\nWrap minimal, inspectable reasoning in *exactly* this XML template:\n\n\n…briefly state the customer’s need and current state…\n…list candidate tools, justify which you will call next and why…\n\n\nReveal **no** additional private reasoning outside these tags.\"", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "systemPrompt", + "binding" : { + "name" : "data.systemPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.prompt", + "label" : "User prompt", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.documents", + "label" : "Documents", + "description" : "Documents to be included in the user prompt.", + "optional" : true, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.documents", + "type" : "zeebe:input" + }, + "tooltip" : "Referenced documents will be automatically added to the user prompt. See documentation for details and supported file types.", + "type" : "String" + }, { + "id" : "agentContext", + "label" : "Agent context", + "description" : "Initial agent context from previous interactions. Avoid reusing context variables across agents to prevent issues with stale data or tool access.", + "optional" : false, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "agentContext", + "type" : "zeebe:input" + }, + "tooltip" : "The agent context variable containing all relevant data for the agent to support the feedback loop between user requests, tool calls and LLM responses. Make sure this variable points to the context variable which is returned from the agent response. See documentation for details.", + "type" : "Text" + }, { + "id" : "data.memory.storage.type", + "label" : "Memory storage type", + "description" : "Specify how to store the conversation memory.", + "value" : "in-process", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "In Process (part of agent context)", + "value" : "in-process" + }, { + "name" : "Camunda Document Storage", + "value" : "camunda-document" + }, { + "name" : "AWS AgentCore Memory", + "value" : "aws-agentcore" + }, { + "name" : "Custom Implementation (Hybrid/Self-Managed only)", + "value" : "custom" + } ] + }, { + "id" : "data.memory.storage.timeToLive", + "label" : "Document TTL", + "description" : "How long to retain the conversation document as ISO-8601 duration (example: P14D).", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.timeToLive", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "tooltip" : "Will use the cluster default TTL (time-to-live) if not specified. Make sure to set this value to a reasonable duration matching your process lifecycle.", + "type" : "String" + }, { + "id" : "data.memory.storage.customProperties", + "label" : "Custom document properties", + "description" : "An optional map of custom properties to be stored with the conversation document.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.customProperties", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.region", + "label" : "Region", + "description" : "Specify the AWS region (example: us-east-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy for AgentCore Memory access.", + "value" : "credentials", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "data.memory.storage.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key with permissions for bedrock-agentcore:CreateEvent and bedrock-agentcore:ListEvents", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.memoryId", + "label" : "Memory ID", + "description" : "The ID of the pre-provisioned AgentCore Memory resource.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.memoryId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.actorId", + "label" : "Actor ID", + "description" : "Identifier of the actor associated with events (e.g., end-user or agent/user combination).", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.actorId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.storeType", + "label" : "Implementation type", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.storeType", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.parameters", + "label" : "Parameters", + "description" : "Parameters for the custom memory storage implementation.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.parameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.contextWindowSize", + "label" : "Context window size", + "description" : "Maximum number of recent conversation messages which are passed to the model.", + "optional" : false, + "value" : 20, + "feel" : "static", + "group" : "memory", + "binding" : { + "name" : "data.memory.contextWindowSize", + "type" : "zeebe:input" + }, + "tooltip" : "Use this to limit the number of messages which are sent to the model. The agent will only send the most recent messages up to the configured limit to the LLM. Older messages will be kept in the conversation store, but not sent to the model. See documentation for details.", + "type" : "Number" + }, { + "id" : "data.limits.maxModelCalls", + "label" : "Maximum model calls", + "description" : "Maximum number of calls to the model as a safety limit to prevent infinite loops.", + "optional" : false, + "value" : 10, + "feel" : "static", + "group" : "limits", + "binding" : { + "name" : "data.limits.maxModelCalls", + "type" : "zeebe:input" + }, + "type" : "Number" + }, { + "id" : "data.events.behavior", + "label" : "Event handling behavior", + "description" : "Behavior on completing an event sub-process.", + "optional" : false, + "value" : "WAIT_FOR_TOOL_CALL_RESULTS", + "constraints" : { + "notEmpty" : true + }, + "group" : "events", + "binding" : { + "name" : "data.events.behavior", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Wait for tool call results", + "value" : "WAIT_FOR_TOOL_CALL_RESULTS" + }, { + "name" : "Cancel tool calls", + "value" : "INTERRUPT_TOOL_CALLS" + } ] + }, { + "id" : "data.response.format.type", + "label" : "Response format", + "description" : "Specify the response format. Support for JSON mode varies by provider.", + "value" : "text", + "group" : "response", + "binding" : { + "name" : "data.response.format.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Text", + "value" : "text" + }, { + "name" : "JSON", + "value" : "json" + } ] + }, { + "id" : "data.response.format.parseJson", + "label" : "Parse text as JSON", + "description" : "Tries to parse the LLM response text as JSON object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.format.parseJson", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "text", + "type" : "simple" + }, + "tooltip" : "Use this option in combination with models which don't support native JSON mode/structured tool calling (e.g. Anthropic). Make sure to instruct the model to return valid JSON in the system prompt. The parsed JSON will be available as response.responseJson.

If parsing fails, null will be returned as JSON response, but the text content will still be available as response.responseText.", + "type" : "Boolean" + }, { + "id" : "data.response.format.schema", + "label" : "Response JSON schema", + "description" : "An optional response JSON Schema to instruct the model how to structure the JSON output.", + "optional" : true, + "feel" : "required", + "group" : "response", + "binding" : { + "name" : "data.response.format.schema", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "tooltip" : "If supported by the model, the response will be structured according to the provided schema. A parsed version of the response will be available as response.responseJson.", + "type" : "String" + }, { + "id" : "data.response.format.schemaName", + "label" : "Response JSON schema name", + "description" : "An optional name for the response JSON Schema to make the model aware of the expected output.", + "optional" : true, + "value" : "Response", + "feel" : "optional", + "group" : "response", + "binding" : { + "name" : "data.response.format.schemaName", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.response.includeAssistantMessage", + "label" : "Include assistant message", + "description" : "Include the full assistant message as part of the result object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.includeAssistantMessage", + "type" : "zeebe:input" + }, + "tooltip" : "In addition to the text content, the assistant message may include multiple additional content blocks and metadata (such as token usage). The message will be available as response.responseMessage.", + "type" : "Boolean" + }, { + "id" : "data.response.includeAgentContext", + "label" : "Include agent context", + "description" : "Include the agent context as part of the result object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.includeAgentContext", + "type" : "zeebe:input" + }, + "tooltip" : "Use this option if you need to re-inject the previous agent context into a future agent execution, for example when modeling a user feedback loop between an agent and a user task.", + "type" : "Boolean" + }, { + "id" : "version", + "label" : "Version", + "description" : "Version of the element template", + "value" : "10", + "group" : "connector", + "binding" : { + "key" : "elementTemplateVersion", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "id", + "label" : "ID", + "description" : "ID of the element template", + "value" : "io.camunda.connectors.agenticai.aiagent.jobworker.v1", + "group" : "connector", + "binding" : { + "key" : "elementTemplateId", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in. Details in the documentation.", + "value" : "agent", + "group" : "output", + "binding" : { + "source" : "=agent", + "type" : "zeebe:output" + }, + "type" : "String" + }, { + "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", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "binding" : { + "name" : "agent", + "type" : "zeebe:input" + }, + "type" : "Hidden" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTYiIGZpbGw9IiNBNTZFRkYiLz4KPG1hc2sgaWQ9InBhdGgtMi1vdXRzaWRlLTFfMTg1XzYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjQiIHk9IjQiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iYmxhY2siPgo8cmVjdCBmaWxsPSJ3aGl0ZSIgeD0iNCIgeT0iNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIvPgo8L21hc2s+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yMC4wMTA1IDEyLjA5ODdDMTguNDkgMTAuNTg5NCAxNy4xNTk0IDguMTA4MTQgMTYuMTc5OSA2LjAxMTAzQzE2LjE1MiA2LjAwNDUxIDE2LjExNzYgNiAxNi4wNzk0IDZDMTYuMDQxMSA2IDE2LjAwNjYgNi4wMDQ1MSAxNS45Nzg4IDYuMDExMDRDMTQuOTk5NCA4LjEwODE0IDEzLjY2OTcgMTAuNTg4OSAxMi4xNDgxIDEyLjA5ODFDMTAuNjI2OSAxMy42MDcxIDguMTI1NjggMTQuOTI2NCA2LjAxMTU3IDE1Ljg5ODFDNi4wMDQ3NCAxNS45MjYxIDYgMTUuOTYxMSA2IDE2QzYgMTYuMDM4NyA2LjAwNDY4IDE2LjA3MzYgNi4wMTE0NCAxNi4xMDE0QzguMTI1MTkgMTcuMDcyOSAxMC42MjYyIDE4LjM5MTkgMTIuMTQ3NyAxOS45MDE2QzEzLjY2OTcgMjEuNDEwNyAxNC45OTk2IDIzLjg5MiAxNS45NzkxIDI1Ljk4OUMxNi4wMDY4IDI1Ljk5NTYgMTYuMDQxMSAyNiAxNi4wNzkzIDI2QzE2LjExNzUgMjYgMTYuMTUxOSAyNS45OTU0IDE2LjE3OTYgMjUuOTg5QzE3LjE1OTEgMjMuODkyIDE4LjQ4ODggMjEuNDExIDIwLjAwOTkgMTkuOTAyMU0yMC4wMDk5IDE5LjkwMjFDMjEuNTI1MyAxOC4zOTg3IDIzLjk0NjUgMTcuMDY2OSAyNS45OTE1IDE2LjA4MjRDMjUuOTk2NSAxNi4wNTkzIDI2IDE2LjAzMSAyNiAxNS45OTk3QzI2IDE1Ljk2ODQgMjUuOTk2NSAxNS45NDAzIDI1Ljk5MTUgMTUuOTE3MUMyMy45NDc0IDE0LjkzMjcgMjEuNTI1OSAxMy42MDEgMjAuMDEwNSAxMi4wOTg3IiBzdHJva2U9IiM0OTFEOEIiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgbWFzaz0idXJsKCNwYXRoLTItb3V0c2lkZS0xXzE4NV82KSIvPgo8L3N2Zz4K" + } +} \ No newline at end of file diff --git a/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-job-worker-11.json b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-job-worker-11.json new file mode 100644 index 00000000000..b7d505c44ad --- /dev/null +++ b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-job-worker-11.json @@ -0,0 +1,1700 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "AI Agent Sub-process", + "id" : "io.camunda.connectors.agenticai.aiagent.jobworker.v1", + "description" : "Run a multi-step AI reasoning loop with dynamic tool selection", + "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], + "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-subprocess/", + "version" : 11, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:SubProcess" ], + "elementType" : { + "value" : "bpmn:AdHocSubProcess" + }, + "engines" : { + "camunda" : "^8.10" + }, + "groups" : [ { + "id" : "provider", + "label" : "Model provider", + "openByDefault" : false + }, { + "id" : "model", + "label" : "Model", + "openByDefault" : false + }, { + "id" : "systemPrompt", + "label" : "System prompt", + "tooltip" : "A system prompt is a set of foundational instructions given to a model before any user interaction begins. It defines the AI agent’s role, behavior, tone, and communication style, ensuring that responses remain consistent and aligned with the AI agent’s intended purpose. These instructions help shape how the model interprets and responds to user input throughout the conversation.", + "openByDefault" : false + }, { + "id" : "userPrompt", + "label" : "User prompt", + "tooltip" : "A user prompt is the message or question you give to the AI to start or continue a conversation. It tells the AI what you need, whether it's information, help with a task, or just a chat. The AI uses your prompt to understand how to respond.", + "openByDefault" : false + }, { + "id" : "tools", + "label" : "Tools", + "tooltip" : "Tools are optional features the AI Agent can use to perform specific tasks. Configure this if the agent should participate in a tools feedback loop.", + "openByDefault" : false + }, { + "id" : "memory", + "label" : "Memory", + "tooltip" : "Configuration of the Agent's short-term/conversational memory.", + "openByDefault" : false + }, { + "id" : "limits", + "label" : "Limits", + "openByDefault" : false + }, { + "id" : "events", + "label" : "Event handling", + "tooltip" : "Configure how event sub-process results are handled. Results are added as user messages to the running agent.", + "openByDefault" : false + }, { + "id" : "response", + "label" : "Response", + "tooltip" : "Configuration of the model response format and how to map the model response to the connector result.

Depending on the selection, the model response will be available as response.responseText or response.responseJson.

See documentation for details.", + "openByDefault" : false + }, { + "id" : "connector", + "label" : "Connector" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "value" : "io.camunda.agenticai:aiagent-job-worker:1", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "Hidden" + }, { + "id" : "outputCollection", + "binding" : { + "property" : "outputCollection", + "type" : "zeebe:adHoc" + }, + "value" : "toolCallResults", + "type" : "Hidden" + }, { + "id" : "outputElement", + "binding" : { + "property" : "outputElement", + "type" : "zeebe:adHoc" + }, + "value" : "={\n id: toolCall._meta.id,\n name: toolCall._meta.name,\n content: toolCallResult\n}", + "type" : "Hidden" + }, { + "id" : "provider.type", + "label" : "Provider", + "description" : "Specify the LLM provider to use.", + "value" : "anthropic", + "group" : "provider", + "binding" : { + "name" : "provider.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Anthropic", + "value" : "anthropic" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Google GenAI", + "value" : "googleGenAi" + }, { + "name" : "OpenAI", + "value" : "openai" + } ] + }, { + "id" : "provider.anthropic.endpoint", + "label" : "Endpoint", + "description" : "Optional custom API endpoint", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.type", + "label" : "Authentication", + "description" : "Specify the Anthropic authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] + }, { + "id" : "provider.anthropic.authentication.apiKey", + "label" : "Anthropic API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.region", + "label" : "Region", + "description" : "Specify the AWS region (example: eu-west-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy. Learn more at the documentation page", + "value" : "credentials", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "API Key", + "value" : "apiKey" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "provider.bedrock.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key tailored to a user, equipped with the necessary permissions", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.apiKey", + "label" : "API Key", + "description" : "Provide an API Key with permissions to interact with your AWS Bedrock Instance", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] + }, { + "id" : "provider.googleGenAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.authentication.jsonKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.googleGenAi.authentication.type", + "equals" : "serviceAccountCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.type", + "label" : "Authentication", + "description" : "Specify the OpenAI authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] + }, { + "id" : "provider.openai.authentication.apiKey", + "label" : "API key", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.organizationId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", + "group" : "provider", + "binding" : { + "name" : "provider.openai.apiFamily", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] + }, { + "id" : "provider.openai.endpoint", + "label" : "Endpoint", + "description" : "Optional. Override the default OpenAI base URL (e.g. for an OpenAI proxy or gateway). Leave blank to use the SDK default.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.headers", + "label" : "Headers", + "description" : "Map of HTTP headers to add to the request.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openai.headers", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.queryParameters", + "label" : "Query Parameters", + "description" : "Map of query parameters to add to the request URL.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openai.queryParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "placeholder" : "claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.anthropic.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.model", + "label" : "Model", + "description" : "Specify an inference profile ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "placeholder" : "global.anthropic.claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.bedrock.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to allow in the generated response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.model.parameters.maxOutputTokens", + "label" : "Maximum output tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.maxOutputTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "value" : "gpt-4o", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.model.parameters.maxCompletionTokens", + "label" : "Maximum completion tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.maxCompletionTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.customParameters", + "label" : "Custom parameters", + "description" : "Map of additional request parameters to include.", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.customParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.systemPrompt.prompt", + "label" : "System prompt", + "optional" : false, + "value" : "=\"You are **TaskAgent**, a helpful, generic chat agent that can handle a wide variety of customer requests using your own domain knowledge **and** any tools explicitly provided to you at runtime.\n\nIf tools are provided, you should prefer them instead of guessing an answer. You can call the same tool multiple times by providing different input values. Don't guess any tools which were not explicitly configured. If no tool matches the request, try to generate an answer. If you're not able to find a good answer, return with a message stating why you're not able to.\n\nWrap minimal, inspectable reasoning in *exactly* this XML template:\n\n\n…briefly state the customer’s need and current state…\n…list candidate tools, justify which you will call next and why…\n\n\nReveal **no** additional private reasoning outside these tags.\"", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "systemPrompt", + "binding" : { + "name" : "data.systemPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.prompt", + "label" : "User prompt", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.documents", + "label" : "Documents", + "description" : "Documents to be included in the user prompt.", + "optional" : true, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.documents", + "type" : "zeebe:input" + }, + "tooltip" : "Referenced documents will be automatically added to the user prompt. See documentation for details and supported file types.", + "type" : "String" + }, { + "id" : "agentContext", + "label" : "Agent context", + "description" : "Initial agent context from previous interactions. Avoid reusing context variables across agents to prevent issues with stale data or tool access.", + "optional" : false, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "agentContext", + "type" : "zeebe:input" + }, + "tooltip" : "The agent context variable containing all relevant data for the agent to support the feedback loop between user requests, tool calls and LLM responses. Make sure this variable points to the context variable which is returned from the agent response. See documentation for details.", + "type" : "Text" + }, { + "id" : "data.memory.storage.type", + "label" : "Memory storage type", + "description" : "Specify how to store the conversation memory.", + "value" : "in-process", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "In Process (part of agent context)", + "value" : "in-process" + }, { + "name" : "Camunda Document Storage", + "value" : "camunda-document" + }, { + "name" : "AWS AgentCore Memory", + "value" : "aws-agentcore" + }, { + "name" : "Custom Implementation (Hybrid/Self-Managed only)", + "value" : "custom" + } ] + }, { + "id" : "data.memory.storage.timeToLive", + "label" : "Document TTL", + "description" : "How long to retain the conversation document as ISO-8601 duration (example: P14D).", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.timeToLive", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "tooltip" : "Will use the cluster default TTL (time-to-live) if not specified. Make sure to set this value to a reasonable duration matching your process lifecycle.", + "type" : "String" + }, { + "id" : "data.memory.storage.customProperties", + "label" : "Custom document properties", + "description" : "An optional map of custom properties to be stored with the conversation document.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.customProperties", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.region", + "label" : "Region", + "description" : "Specify the AWS region (example: us-east-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy for AgentCore Memory access.", + "value" : "credentials", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "data.memory.storage.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key with permissions for bedrock-agentcore:CreateEvent and bedrock-agentcore:ListEvents", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.memoryId", + "label" : "Memory ID", + "description" : "The ID of the pre-provisioned AgentCore Memory resource.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.memoryId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.actorId", + "label" : "Actor ID", + "description" : "Identifier of the actor associated with events (e.g., end-user or agent/user combination).", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.actorId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.storeType", + "label" : "Implementation type", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.storeType", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.parameters", + "label" : "Parameters", + "description" : "Parameters for the custom memory storage implementation.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.parameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.contextWindowSize", + "label" : "Context window size", + "description" : "Maximum number of recent conversation messages which are passed to the model.", + "optional" : false, + "value" : 20, + "feel" : "static", + "group" : "memory", + "binding" : { + "name" : "data.memory.contextWindowSize", + "type" : "zeebe:input" + }, + "tooltip" : "Use this to limit the number of messages which are sent to the model. The agent will only send the most recent messages up to the configured limit to the LLM. Older messages will be kept in the conversation store, but not sent to the model. See documentation for details.", + "type" : "Number" + }, { + "id" : "data.limits.maxModelCalls", + "label" : "Maximum model calls", + "description" : "Maximum number of calls to the model as a safety limit to prevent infinite loops.", + "optional" : false, + "value" : 10, + "feel" : "static", + "group" : "limits", + "binding" : { + "name" : "data.limits.maxModelCalls", + "type" : "zeebe:input" + }, + "type" : "Number" + }, { + "id" : "data.events.behavior", + "label" : "Event handling behavior", + "description" : "Behavior on completing an event sub-process.", + "optional" : false, + "value" : "WAIT_FOR_TOOL_CALL_RESULTS", + "constraints" : { + "notEmpty" : true + }, + "group" : "events", + "binding" : { + "name" : "data.events.behavior", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Wait for tool call results", + "value" : "WAIT_FOR_TOOL_CALL_RESULTS" + }, { + "name" : "Cancel tool calls", + "value" : "INTERRUPT_TOOL_CALLS" + } ] + }, { + "id" : "data.response.format.type", + "label" : "Response format", + "description" : "Specify the response format. Support for JSON mode varies by provider.", + "value" : "text", + "group" : "response", + "binding" : { + "name" : "data.response.format.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Text", + "value" : "text" + }, { + "name" : "JSON", + "value" : "json" + } ] + }, { + "id" : "data.response.format.parseJson", + "label" : "Parse text as JSON", + "description" : "Tries to parse the LLM response text as JSON object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.format.parseJson", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "text", + "type" : "simple" + }, + "tooltip" : "Use this option in combination with models which don't support native JSON mode/structured tool calling (e.g. Anthropic). Make sure to instruct the model to return valid JSON in the system prompt. The parsed JSON will be available as response.responseJson.

If parsing fails, null will be returned as JSON response, but the text content will still be available as response.responseText.", + "type" : "Boolean" + }, { + "id" : "data.response.format.schema", + "label" : "Response JSON schema", + "description" : "An optional response JSON Schema to instruct the model how to structure the JSON output.", + "optional" : true, + "feel" : "required", + "group" : "response", + "binding" : { + "name" : "data.response.format.schema", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "tooltip" : "If supported by the model, the response will be structured according to the provided schema. A parsed version of the response will be available as response.responseJson.", + "type" : "String" + }, { + "id" : "data.response.format.schemaName", + "label" : "Response JSON schema name", + "description" : "An optional name for the response JSON Schema to make the model aware of the expected output.", + "optional" : true, + "value" : "Response", + "feel" : "optional", + "group" : "response", + "binding" : { + "name" : "data.response.format.schemaName", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.response.includeAssistantMessage", + "label" : "Include assistant message", + "description" : "Include the full assistant message as part of the result object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.includeAssistantMessage", + "type" : "zeebe:input" + }, + "tooltip" : "In addition to the text content, the assistant message may include multiple additional content blocks and metadata (such as token usage). The message will be available as response.responseMessage.", + "type" : "Boolean" + }, { + "id" : "data.response.includeAgentContext", + "label" : "Include agent context", + "description" : "Include the agent context as part of the result object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.includeAgentContext", + "type" : "zeebe:input" + }, + "tooltip" : "Use this option if you need to re-inject the previous agent context into a future agent execution, for example when modeling a user feedback loop between an agent and a user task.", + "type" : "Boolean" + }, { + "id" : "version", + "label" : "Version", + "description" : "Version of the element template", + "value" : "11", + "group" : "connector", + "binding" : { + "key" : "elementTemplateVersion", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "id", + "label" : "ID", + "description" : "ID of the element template", + "value" : "io.camunda.connectors.agenticai.aiagent.jobworker.v1", + "group" : "connector", + "binding" : { + "key" : "elementTemplateId", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in. Details in the documentation.", + "value" : "agent", + "group" : "output", + "binding" : { + "source" : "=agent", + "type" : "zeebe:output" + }, + "type" : "String" + }, { + "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", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "binding" : { + "name" : "agent", + "type" : "zeebe:input" + }, + "type" : "Hidden" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTYiIGZpbGw9IiNBNTZFRkYiLz4KPG1hc2sgaWQ9InBhdGgtMi1vdXRzaWRlLTFfMTg1XzYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjQiIHk9IjQiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iYmxhY2siPgo8cmVjdCBmaWxsPSJ3aGl0ZSIgeD0iNCIgeT0iNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIvPgo8L21hc2s+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yMC4wMTA1IDEyLjA5ODdDMTguNDkgMTAuNTg5NCAxNy4xNTk0IDguMTA4MTQgMTYuMTc5OSA2LjAxMTAzQzE2LjE1MiA2LjAwNDUxIDE2LjExNzYgNiAxNi4wNzk0IDZDMTYuMDQxMSA2IDE2LjAwNjYgNi4wMDQ1MSAxNS45Nzg4IDYuMDExMDRDMTQuOTk5NCA4LjEwODE0IDEzLjY2OTcgMTAuNTg4OSAxMi4xNDgxIDEyLjA5ODFDMTAuNjI2OSAxMy42MDcxIDguMTI1NjggMTQuOTI2NCA2LjAxMTU3IDE1Ljg5ODFDNi4wMDQ3NCAxNS45MjYxIDYgMTUuOTYxMSA2IDE2QzYgMTYuMDM4NyA2LjAwNDY4IDE2LjA3MzYgNi4wMTE0NCAxNi4xMDE0QzguMTI1MTkgMTcuMDcyOSAxMC42MjYyIDE4LjM5MTkgMTIuMTQ3NyAxOS45MDE2QzEzLjY2OTcgMjEuNDEwNyAxNC45OTk2IDIzLjg5MiAxNS45NzkxIDI1Ljk4OUMxNi4wMDY4IDI1Ljk5NTYgMTYuMDQxMSAyNiAxNi4wNzkzIDI2QzE2LjExNzUgMjYgMTYuMTUxOSAyNS45OTU0IDE2LjE3OTYgMjUuOTg5QzE3LjE1OTEgMjMuODkyIDE4LjQ4ODggMjEuNDExIDIwLjAwOTkgMTkuOTAyMU0yMC4wMDk5IDE5LjkwMjFDMjEuNTI1MyAxOC4zOTg3IDIzLjk0NjUgMTcuMDY2OSAyNS45OTE1IDE2LjA4MjRDMjUuOTk2NSAxNi4wNTkzIDI2IDE2LjAzMSAyNiAxNS45OTk3QzI2IDE1Ljk2ODQgMjUuOTk2NSAxNS45NDAzIDI1Ljk5MTUgMTUuOTE3MUMyMy45NDc0IDE0LjkzMjcgMjEuNTI1OSAxMy42MDEgMjAuMDEwNSAxMi4wOTg3IiBzdHJva2U9IiM0OTFEOEIiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgbWFzaz0idXJsKCNwYXRoLTItb3V0c2lkZS0xXzE4NV82KSIvPgo8L3N2Zz4K" + } +} \ No newline at end of file diff --git a/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-outbound-connector-10.json b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-outbound-connector-10.json new file mode 100644 index 00000000000..6ae10b471a5 --- /dev/null +++ b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-outbound-connector-10.json @@ -0,0 +1,1782 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "AI Agent Task", + "id" : "io.camunda.connectors.agenticai.aiagent.v1", + "description" : "Execute a single AI-powered action with tool calling capabilities", + "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], + "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-task/", + "version" : 10, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:Task" ], + "elementType" : { + "value" : "bpmn:ServiceTask" + }, + "engines" : { + "camunda" : "^8.10" + }, + "groups" : [ { + "id" : "provider", + "label" : "Model provider", + "openByDefault" : false + }, { + "id" : "model", + "label" : "Model", + "openByDefault" : false + }, { + "id" : "systemPrompt", + "label" : "System prompt", + "tooltip" : "A system prompt is a set of foundational instructions given to a model before any user interaction begins. It defines the AI agent’s role, behavior, tone, and communication style, ensuring that responses remain consistent and aligned with the AI agent’s intended purpose. These instructions help shape how the model interprets and responds to user input throughout the conversation.", + "openByDefault" : false + }, { + "id" : "userPrompt", + "label" : "User prompt", + "tooltip" : "A user prompt is the message or question you give to the AI to start or continue a conversation. It tells the AI what you need, whether it's information, help with a task, or just a chat. The AI uses your prompt to understand how to respond.", + "openByDefault" : false + }, { + "id" : "tools", + "label" : "Tools", + "tooltip" : "Tools are optional features the AI Agent can use to perform specific tasks. Configure this if the agent should participate in a tools feedback loop.", + "openByDefault" : false + }, { + "id" : "memory", + "label" : "Memory", + "tooltip" : "Configuration of the Agent's short-term/conversational memory.", + "openByDefault" : false + }, { + "id" : "limits", + "label" : "Limits", + "openByDefault" : false + }, { + "id" : "response", + "label" : "Response", + "tooltip" : "Configuration of the model response format and how to map the model response to the connector result.

Depending on the selection, the model response will be available as response.responseText or response.responseJson.

See documentation for details.", + "openByDefault" : false + }, { + "id" : "connector", + "label" : "Connector" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "value" : "io.camunda.agenticai:aiagent:1", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "Hidden" + }, { + "id" : "provider.type", + "label" : "Provider", + "description" : "Specify the LLM provider to use.", + "value" : "anthropic", + "group" : "provider", + "binding" : { + "name" : "provider.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Anthropic", + "value" : "anthropic" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Azure OpenAI", + "value" : "azureOpenAi" + }, { + "name" : "Google Vertex AI", + "value" : "google-vertex-ai" + }, { + "name" : "OpenAI", + "value" : "openai" + }, { + "name" : "OpenAI Compatible", + "value" : "openaiCompatible" + } ] + }, { + "id" : "provider.anthropic.endpoint", + "label" : "Endpoint", + "description" : "Optional custom API endpoint", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.apiKey", + "label" : "Anthropic API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.region", + "label" : "Region", + "description" : "Specify the AWS region (example: eu-west-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy. Learn more at the documentation page", + "value" : "credentials", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "API Key", + "value" : "apiKey" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "provider.bedrock.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key tailored to a user, equipped with the necessary permissions", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.apiKey", + "label" : "API Key", + "description" : "Provide an API Key with permissions to interact with your AWS Bedrock Instance", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.endpoint", + "label" : "Endpoint", + "description" : "Specify Azure OpenAI endpoint. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Azure OpenAI authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] + }, { + "id" : "provider.azureOpenAi.authentication.apiKey", + "label" : "API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.azureOpenAi.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.azureOpenAi.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] + }, { + "id" : "provider.googleVertexAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleVertexAi.authentication.jsonKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.googleVertexAi.authentication.type", + "equals" : "serviceAccountCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.apiKey", + "label" : "OpenAI API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.organizationId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", + "group" : "provider", + "binding" : { + "name" : "provider.openai.apiFamily", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] + }, { + "id" : "provider.openaiCompatible.endpoint", + "label" : "API endpoint", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Specify an endpoint to use the connector with an OpenAI compatible API. ", + "type" : "String" + }, { + "id" : "provider.openaiCompatible.authentication.apiKey", + "label" : "API key", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Leave blank if using HTTP headers for authentication.
If an Authorization header is specified in the headers, then the API key is ignored.", + "type" : "String" + }, { + "id" : "provider.openaiCompatible.headers", + "label" : "Headers", + "description" : "Map of HTTP headers to add to the request.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.headers", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openaiCompatible.queryParameters", + "label" : "Query Parameters", + "description" : "Map of query parameters to add to the request URL.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.queryParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openaiCompatible.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openaiCompatible.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "placeholder" : "claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.anthropic.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.model", + "label" : "Model", + "description" : "Specify an inference profile ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "placeholder" : "global.anthropic.claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.bedrock.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to allow in the generated response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.azureOpenAi.model.deploymentName", + "label" : "Model deployment name", + "description" : "Specify the model deployment name. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.deploymentName", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.azureOpenAi.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.azureOpenAi.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.azureOpenAi.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.azureOpenAi.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "azureOpenAi", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "label" : "Maximum output tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.maxOutputTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleVertexAi.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleVertexAi.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "google-vertex-ai", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "value" : "gpt-4o", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.model.parameters.maxCompletionTokens", + "label" : "Maximum completion tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.maxCompletionTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "value" : "gpt-4o", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", + "label" : "Maximum completion tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.maxCompletionTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openaiCompatible.model.parameters.customParameters", + "label" : "Custom parameters", + "description" : "Map of additional request parameters to include.", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openaiCompatible.model.parameters.customParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openaiCompatible", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.systemPrompt.prompt", + "label" : "System prompt", + "optional" : false, + "value" : "=\"You are **TaskAgent**, a helpful, generic chat agent that can handle a wide variety of customer requests using your own domain knowledge **and** any tools explicitly provided to you at runtime.\n\nIf tools are provided, you should prefer them instead of guessing an answer. You can call the same tool multiple times by providing different input values. Don't guess any tools which were not explicitly configured. If no tool matches the request, try to generate an answer. If you're not able to find a good answer, return with a message stating why you're not able to.\n\nWrap minimal, inspectable reasoning in *exactly* this XML template:\n\n\n…briefly state the customer’s need and current state…\n…list candidate tools, justify which you will call next and why…\n\n\nReveal **no** additional private reasoning outside these tags.\"", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "systemPrompt", + "binding" : { + "name" : "data.systemPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.prompt", + "label" : "User prompt", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.documents", + "label" : "Documents", + "description" : "Documents to be included in the user prompt.", + "optional" : true, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.documents", + "type" : "zeebe:input" + }, + "tooltip" : "Referenced documents will be automatically added to the user prompt. See documentation for details and supported file types.", + "type" : "String" + }, { + "id" : "data.tools.containerElementId", + "label" : "Ad-hoc sub-process ID", + "description" : "ID of the sub-process that contains the tools the AI agent can use.", + "optional" : true, + "feel" : "optional", + "group" : "tools", + "binding" : { + "name" : "data.tools.containerElementId", + "type" : "zeebe:input" + }, + "tooltip" : "Add an ad-hoc sub-process ID to attach the AI agent to the tools. Ensure your process includes a tools feedback loop routing into the ad-hoc sub-process and back to the AI agent connector. See documentation for details.", + "type" : "String" + }, { + "id" : "data.tools.toolCallResults", + "label" : "Tool call results", + "description" : "Tool call results as returned by the sub-process.", + "optional" : true, + "feel" : "required", + "group" : "tools", + "binding" : { + "name" : "data.tools.toolCallResults", + "type" : "zeebe:input" + }, + "tooltip" : "This defines where to handle tool call results returned by the ad-hoc sub-process. Model this as part of your process and route it into the tools feedback loop. See documentation for details.", + "type" : "Text" + }, { + "id" : "data.agentContext", + "label" : "Agent context", + "description" : "Avoid reusing context variables across agents to prevent issues with stale data or tool access.", + "optional" : false, + "value" : "=agent.context", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.context", + "type" : "zeebe:input" + }, + "tooltip" : "The agent context variable containing all relevant data for the agent to support the feedback loop between user requests, tool calls and LLM responses. Make sure this variable points to the context variable which is returned from the agent response. See documentation for details.", + "type" : "Text" + }, { + "id" : "data.memory.storage.type", + "label" : "Memory storage type", + "description" : "Specify how to store the conversation memory.", + "value" : "in-process", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "In Process (part of agent context)", + "value" : "in-process" + }, { + "name" : "Camunda Document Storage", + "value" : "camunda-document" + }, { + "name" : "AWS AgentCore Memory", + "value" : "aws-agentcore" + }, { + "name" : "Custom Implementation (Hybrid/Self-Managed only)", + "value" : "custom" + } ] + }, { + "id" : "data.memory.storage.timeToLive", + "label" : "Document TTL", + "description" : "How long to retain the conversation document as ISO-8601 duration (example: P14D).", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.timeToLive", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "tooltip" : "Will use the cluster default TTL (time-to-live) if not specified. Make sure to set this value to a reasonable duration matching your process lifecycle.", + "type" : "String" + }, { + "id" : "data.memory.storage.customProperties", + "label" : "Custom document properties", + "description" : "An optional map of custom properties to be stored with the conversation document.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.customProperties", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.region", + "label" : "Region", + "description" : "Specify the AWS region (example: us-east-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy for AgentCore Memory access.", + "value" : "credentials", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "data.memory.storage.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key with permissions for bedrock-agentcore:CreateEvent and bedrock-agentcore:ListEvents", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.memoryId", + "label" : "Memory ID", + "description" : "The ID of the pre-provisioned AgentCore Memory resource.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.memoryId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.actorId", + "label" : "Actor ID", + "description" : "Identifier of the actor associated with events (e.g., end-user or agent/user combination).", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.actorId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.storeType", + "label" : "Implementation type", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.storeType", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.parameters", + "label" : "Parameters", + "description" : "Parameters for the custom memory storage implementation.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.parameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.contextWindowSize", + "label" : "Context window size", + "description" : "Maximum number of recent conversation messages which are passed to the model.", + "optional" : false, + "value" : 20, + "feel" : "static", + "group" : "memory", + "binding" : { + "name" : "data.memory.contextWindowSize", + "type" : "zeebe:input" + }, + "tooltip" : "Use this to limit the number of messages which are sent to the model. The agent will only send the most recent messages up to the configured limit to the LLM. Older messages will be kept in the conversation store, but not sent to the model. See documentation for details.", + "type" : "Number" + }, { + "id" : "data.limits.maxModelCalls", + "label" : "Maximum model calls", + "description" : "Maximum number of calls to the model as a safety limit to prevent infinite loops.", + "optional" : false, + "value" : 10, + "feel" : "static", + "group" : "limits", + "binding" : { + "name" : "data.limits.maxModelCalls", + "type" : "zeebe:input" + }, + "type" : "Number" + }, { + "id" : "data.response.format.type", + "label" : "Response format", + "description" : "Specify the response format. Support for JSON mode varies by provider.", + "value" : "text", + "group" : "response", + "binding" : { + "name" : "data.response.format.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Text", + "value" : "text" + }, { + "name" : "JSON", + "value" : "json" + } ] + }, { + "id" : "data.response.format.parseJson", + "label" : "Parse text as JSON", + "description" : "Tries to parse the LLM response text as JSON object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.format.parseJson", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "text", + "type" : "simple" + }, + "tooltip" : "Use this option in combination with models which don't support native JSON mode/structured tool calling (e.g. Anthropic). Make sure to instruct the model to return valid JSON in the system prompt. The parsed JSON will be available as response.responseJson.

If parsing fails, null will be returned as JSON response, but the text content will still be available as response.responseText.", + "type" : "Boolean" + }, { + "id" : "data.response.format.schema", + "label" : "Response JSON schema", + "description" : "An optional response JSON Schema to instruct the model how to structure the JSON output.", + "optional" : true, + "feel" : "required", + "group" : "response", + "binding" : { + "name" : "data.response.format.schema", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "tooltip" : "If supported by the model, the response will be structured according to the provided schema. A parsed version of the response will be available as response.responseJson.", + "type" : "String" + }, { + "id" : "data.response.format.schemaName", + "label" : "Response JSON schema name", + "description" : "An optional name for the response JSON Schema to make the model aware of the expected output.", + "optional" : true, + "value" : "Response", + "feel" : "optional", + "group" : "response", + "binding" : { + "name" : "data.response.format.schemaName", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.response.includeAssistantMessage", + "label" : "Include assistant message", + "description" : "Include the full assistant message as part of the result object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.includeAssistantMessage", + "type" : "zeebe:input" + }, + "tooltip" : "In addition to the text content, the assistant message may include multiple additional content blocks and metadata (such as token usage). The message will be available as response.responseMessage.", + "type" : "Boolean" + }, { + "id" : "version", + "label" : "Version", + "description" : "Version of the element template", + "value" : "10", + "group" : "connector", + "binding" : { + "key" : "elementTemplateVersion", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "id", + "label" : "ID", + "description" : "ID of the element template", + "value" : "io.camunda.connectors.agenticai.aiagent.v1", + "group" : "connector", + "binding" : { + "key" : "elementTemplateId", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in. Details in the documentation.", + "value" : "agent", + "group" : "output", + "binding" : { + "key" : "resultVariable", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "id" : "resultExpression", + "label" : "Result expression", + "description" : "Expression to map the response into process variables. Details in the documentation.", + "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", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTYiIGZpbGw9IiNBNTZFRkYiLz4KPG1hc2sgaWQ9InBhdGgtMi1vdXRzaWRlLTFfMTg1XzYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjQiIHk9IjQiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iYmxhY2siPgo8cmVjdCBmaWxsPSJ3aGl0ZSIgeD0iNCIgeT0iNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIvPgo8L21hc2s+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yMC4wMTA1IDEyLjA5ODdDMTguNDkgMTAuNTg5NCAxNy4xNTk0IDguMTA4MTQgMTYuMTc5OSA2LjAxMTAzQzE2LjE1MiA2LjAwNDUxIDE2LjExNzYgNiAxNi4wNzk0IDZDMTYuMDQxMSA2IDE2LjAwNjYgNi4wMDQ1MSAxNS45Nzg4IDYuMDExMDRDMTQuOTk5NCA4LjEwODE0IDEzLjY2OTcgMTAuNTg4OSAxMi4xNDgxIDEyLjA5ODFDMTAuNjI2OSAxMy42MDcxIDguMTI1NjggMTQuOTI2NCA2LjAxMTU3IDE1Ljg5ODFDNi4wMDQ3NCAxNS45MjYxIDYgMTUuOTYxMSA2IDE2QzYgMTYuMDM4NyA2LjAwNDY4IDE2LjA3MzYgNi4wMTE0NCAxNi4xMDE0QzguMTI1MTkgMTcuMDcyOSAxMC42MjYyIDE4LjM5MTkgMTIuMTQ3NyAxOS45MDE2QzEzLjY2OTcgMjEuNDEwNyAxNC45OTk2IDIzLjg5MiAxNS45NzkxIDI1Ljk4OUMxNi4wMDY4IDI1Ljk5NTYgMTYuMDQxMSAyNiAxNi4wNzkzIDI2QzE2LjExNzUgMjYgMTYuMTUxOSAyNS45OTU0IDE2LjE3OTYgMjUuOTg5QzE3LjE1OTEgMjMuODkyIDE4LjQ4ODggMjEuNDExIDIwLjAwOTkgMTkuOTAyMU0yMC4wMDk5IDE5LjkwMjFDMjEuNTI1MyAxOC4zOTg3IDIzLjk0NjUgMTcuMDY2OSAyNS45OTE1IDE2LjA4MjRDMjUuOTk2NSAxNi4wNTkzIDI2IDE2LjAzMSAyNiAxNS45OTk3QzI2IDE1Ljk2ODQgMjUuOTk2NSAxNS45NDAzIDI1Ljk5MTUgMTUuOTE3MUMyMy45NDc0IDE0LjkzMjcgMjEuNTI1OSAxMy42MDEgMjAuMDEwNSAxMi4wOTg3IiBzdHJva2U9IiM0OTFEOEIiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgbWFzaz0idXJsKCNwYXRoLTItb3V0c2lkZS0xXzE4NV82KSIvPgo8L3N2Zz4K" + } +} \ No newline at end of file diff --git a/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-outbound-connector-11.json b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-outbound-connector-11.json new file mode 100644 index 00000000000..522e0b5ff29 --- /dev/null +++ b/connectors/agentic-ai/element-templates/versioned/agenticai-aiagent-outbound-connector-11.json @@ -0,0 +1,1679 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "AI Agent Task", + "id" : "io.camunda.connectors.agenticai.aiagent.v1", + "description" : "Execute a single AI-powered action with tool calling capabilities", + "keywords" : [ "AI", "AI Agent", "agentic orchestration" ], + "documentationRef" : "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-task/", + "version" : 11, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:Task" ], + "elementType" : { + "value" : "bpmn:ServiceTask" + }, + "engines" : { + "camunda" : "^8.10" + }, + "groups" : [ { + "id" : "provider", + "label" : "Model provider", + "openByDefault" : false + }, { + "id" : "model", + "label" : "Model", + "openByDefault" : false + }, { + "id" : "systemPrompt", + "label" : "System prompt", + "tooltip" : "A system prompt is a set of foundational instructions given to a model before any user interaction begins. It defines the AI agent’s role, behavior, tone, and communication style, ensuring that responses remain consistent and aligned with the AI agent’s intended purpose. These instructions help shape how the model interprets and responds to user input throughout the conversation.", + "openByDefault" : false + }, { + "id" : "userPrompt", + "label" : "User prompt", + "tooltip" : "A user prompt is the message or question you give to the AI to start or continue a conversation. It tells the AI what you need, whether it's information, help with a task, or just a chat. The AI uses your prompt to understand how to respond.", + "openByDefault" : false + }, { + "id" : "tools", + "label" : "Tools", + "tooltip" : "Tools are optional features the AI Agent can use to perform specific tasks. Configure this if the agent should participate in a tools feedback loop.", + "openByDefault" : false + }, { + "id" : "memory", + "label" : "Memory", + "tooltip" : "Configuration of the Agent's short-term/conversational memory.", + "openByDefault" : false + }, { + "id" : "limits", + "label" : "Limits", + "openByDefault" : false + }, { + "id" : "response", + "label" : "Response", + "tooltip" : "Configuration of the model response format and how to map the model response to the connector result.

Depending on the selection, the model response will be available as response.responseText or response.responseJson.

See documentation for details.", + "openByDefault" : false + }, { + "id" : "connector", + "label" : "Connector" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "value" : "io.camunda.agenticai:aiagent:1", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "Hidden" + }, { + "id" : "provider.type", + "label" : "Provider", + "description" : "Specify the LLM provider to use.", + "value" : "anthropic", + "group" : "provider", + "binding" : { + "name" : "provider.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Anthropic", + "value" : "anthropic" + }, { + "name" : "AWS Bedrock", + "value" : "bedrock" + }, { + "name" : "Google GenAI", + "value" : "googleGenAi" + }, { + "name" : "OpenAI", + "value" : "openai" + } ] + }, { + "id" : "provider.anthropic.endpoint", + "label" : "Endpoint", + "description" : "Optional custom API endpoint", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.type", + "label" : "Authentication", + "description" : "Specify the Anthropic authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] + }, { + "id" : "provider.anthropic.authentication.apiKey", + "label" : "Anthropic API key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.anthropic.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.anthropic.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.anthropic.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.region", + "label" : "Region", + "description" : "Specify the AWS region (example: eu-west-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy. Learn more at the documentation page", + "value" : "credentials", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "API Key", + "value" : "apiKey" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "provider.bedrock.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key tailored to a user, equipped with the necessary permissions", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.authentication.apiKey", + "label" : "API Key", + "description" : "Provide an API Key with permissions to interact with your AWS Bedrock Instance", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.bedrock.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.bedrock.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.bedrock.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.projectId", + "label" : "Project ID", + "description" : "Specify Google Cloud project ID", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.region", + "label" : "Region", + "description" : "Specify the region where AI inference should take place", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.authentication.type", + "label" : "Authentication", + "description" : "Specify the Google Vertex AI authentication strategy.", + "value" : "serviceAccountCredentials", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Service account credentials", + "value" : "serviceAccountCredentials" + }, { + "name" : "Application default credentials (Hybrid/Self-Managed only)", + "value" : "applicationDefaultCredentials" + } ] + }, { + "id" : "provider.googleGenAi.authentication.jsonKey", + "label" : "JSON key of the service account", + "description" : "This is the key of the service account in JSON format.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.googleGenAi.authentication.jsonKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.googleGenAi.authentication.type", + "equals" : "serviceAccountCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.type", + "label" : "Authentication", + "description" : "Specify the OpenAI authentication strategy.", + "value" : "apiKey", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "API key", + "value" : "apiKey" + }, { + "name" : "Client credentials", + "value" : "clientCredentials" + } ] + }, { + "id" : "provider.openai.authentication.apiKey", + "label" : "API key", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.apiKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.organizationId", + "label" : "Organization ID", + "description" : "For members of multiple organizations. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.organizationId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.projectId", + "label" : "Project ID", + "description" : "For accounts with multiple projects. Details in the documentation.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.projectId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "apiKey", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.clientId", + "label" : "Client ID", + "description" : "ID of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.clientSecret", + "label" : "Client secret", + "description" : "Secret of a Microsoft Entra application", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.tenantId", + "label" : "Tenant ID", + "description" : "ID of a Microsoft Entra tenant. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.tenantId", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.authentication.authorityHost", + "label" : "Authority host", + "description" : "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.authentication.authorityHost", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "provider.openai.authentication.type", + "equals" : "clientCredentials", + "type" : "simple" + }, { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "provider.openai.timeouts.timeout", + "label" : "Timeout", + "description" : "Timeout specification for API calls to the model provider defined as ISO-8601 duration (example: PT60S).", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.timeouts.timeout", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.apiFamily", + "label" : "API", + "description" : "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + "optional" : true, + "value" : "completions", + "group" : "provider", + "binding" : { + "name" : "provider.openai.apiFamily", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Chat Completions (default)", + "value" : "completions" + }, { + "name" : "Responses", + "value" : "responses" + } ] + }, { + "id" : "provider.openai.endpoint", + "label" : "Endpoint", + "description" : "Optional. Override the default OpenAI base URL (e.g. for an OpenAI proxy or gateway). Leave blank to use the SDK default.", + "optional" : true, + "feel" : "optional", + "group" : "provider", + "binding" : { + "name" : "provider.openai.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.headers", + "label" : "Headers", + "description" : "Map of HTTP headers to add to the request.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openai.headers", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.queryParameters", + "label" : "Query Parameters", + "description" : "Map of query parameters to add to the request URL.", + "optional" : true, + "feel" : "required", + "group" : "provider", + "binding" : { + "name" : "provider.openai.queryParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.anthropic.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "placeholder" : "claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.anthropic.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.anthropic.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.anthropic.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "anthropic", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.model", + "label" : "Model", + "description" : "Specify an inference profile ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "placeholder" : "global.anthropic.claude-sonnet-4-6", + "type" : "String" + }, { + "id" : "provider.bedrock.model.parameters.maxTokens", + "label" : "Maximum tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.maxTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to allow in the generated response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.bedrock.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.bedrock.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "bedrock", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.googleGenAi.model.parameters.maxOutputTokens", + "label" : "Maximum output tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.maxOutputTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Maximum number of tokens that can be generated in the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Controls the degree of randomness in token selection.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 1. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.googleGenAi.model.parameters.topK", + "label" : "top K", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.googleGenAi.model.parameters.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "googleGenAi", + "type" : "simple" + }, + "tooltip" : "Integer greater than 0. Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.model", + "label" : "Model", + "description" : "Specify the model ID. Details in the documentation.", + "optional" : false, + "value" : "gpt-4o", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.model", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "provider.openai.model.parameters.maxCompletionTokens", + "label" : "Maximum completion tokens", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.maxCompletionTokens", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.temperature", + "label" : "Temperature", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.temperature", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.topP", + "label" : "top P", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.topP", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "tooltip" : "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", + "type" : "Number" + }, { + "id" : "provider.openai.model.parameters.customParameters", + "label" : "Custom parameters", + "description" : "Map of additional request parameters to include.", + "optional" : true, + "feel" : "required", + "group" : "model", + "binding" : { + "name" : "provider.openai.model.parameters.customParameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "provider.type", + "equals" : "openai", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.systemPrompt.prompt", + "label" : "System prompt", + "optional" : false, + "value" : "=\"You are **TaskAgent**, a helpful, generic chat agent that can handle a wide variety of customer requests using your own domain knowledge **and** any tools explicitly provided to you at runtime.\n\nIf tools are provided, you should prefer them instead of guessing an answer. You can call the same tool multiple times by providing different input values. Don't guess any tools which were not explicitly configured. If no tool matches the request, try to generate an answer. If you're not able to find a good answer, return with a message stating why you're not able to.\n\nWrap minimal, inspectable reasoning in *exactly* this XML template:\n\n\n…briefly state the customer’s need and current state…\n…list candidate tools, justify which you will call next and why…\n\n\nReveal **no** additional private reasoning outside these tags.\"", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "systemPrompt", + "binding" : { + "name" : "data.systemPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.prompt", + "label" : "User prompt", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.prompt", + "type" : "zeebe:input" + }, + "type" : "Text" + }, { + "id" : "data.userPrompt.documents", + "label" : "Documents", + "description" : "Documents to be included in the user prompt.", + "optional" : true, + "feel" : "required", + "group" : "userPrompt", + "binding" : { + "name" : "data.userPrompt.documents", + "type" : "zeebe:input" + }, + "tooltip" : "Referenced documents will be automatically added to the user prompt. See documentation for details and supported file types.", + "type" : "String" + }, { + "id" : "data.tools.containerElementId", + "label" : "Ad-hoc sub-process ID", + "description" : "ID of the sub-process that contains the tools the AI agent can use.", + "optional" : true, + "feel" : "optional", + "group" : "tools", + "binding" : { + "name" : "data.tools.containerElementId", + "type" : "zeebe:input" + }, + "tooltip" : "Add an ad-hoc sub-process ID to attach the AI agent to the tools. Ensure your process includes a tools feedback loop routing into the ad-hoc sub-process and back to the AI agent connector. See documentation for details.", + "type" : "String" + }, { + "id" : "data.tools.toolCallResults", + "label" : "Tool call results", + "description" : "Tool call results as returned by the sub-process.", + "optional" : true, + "feel" : "required", + "group" : "tools", + "binding" : { + "name" : "data.tools.toolCallResults", + "type" : "zeebe:input" + }, + "tooltip" : "This defines where to handle tool call results returned by the ad-hoc sub-process. Model this as part of your process and route it into the tools feedback loop. See documentation for details.", + "type" : "Text" + }, { + "id" : "data.agentContext", + "label" : "Agent context", + "description" : "Avoid reusing context variables across agents to prevent issues with stale data or tool access.", + "optional" : false, + "value" : "=agent.context", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.context", + "type" : "zeebe:input" + }, + "tooltip" : "The agent context variable containing all relevant data for the agent to support the feedback loop between user requests, tool calls and LLM responses. Make sure this variable points to the context variable which is returned from the agent response. See documentation for details.", + "type" : "Text" + }, { + "id" : "data.memory.storage.type", + "label" : "Memory storage type", + "description" : "Specify how to store the conversation memory.", + "value" : "in-process", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "In Process (part of agent context)", + "value" : "in-process" + }, { + "name" : "Camunda Document Storage", + "value" : "camunda-document" + }, { + "name" : "AWS AgentCore Memory", + "value" : "aws-agentcore" + }, { + "name" : "Custom Implementation (Hybrid/Self-Managed only)", + "value" : "custom" + } ] + }, { + "id" : "data.memory.storage.timeToLive", + "label" : "Document TTL", + "description" : "How long to retain the conversation document as ISO-8601 duration (example: P14D).", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.timeToLive", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "tooltip" : "Will use the cluster default TTL (time-to-live) if not specified. Make sure to set this value to a reasonable duration matching your process lifecycle.", + "type" : "String" + }, { + "id" : "data.memory.storage.customProperties", + "label" : "Custom document properties", + "description" : "An optional map of custom properties to be stored with the conversation document.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.customProperties", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "camunda-document", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.region", + "label" : "Region", + "description" : "Specify the AWS region (example: us-east-1)", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.region", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.endpoint", + "label" : "Endpoint", + "description" : "Custom API endpoint for VPC/PrivateLink configurations, AWS GovCloud, or other non-standard deployments.", + "optional" : true, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.endpoint", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.type", + "label" : "Authentication", + "description" : "Specify the AWS authentication strategy for AgentCore Memory access.", + "value" : "credentials", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.type", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Credentials", + "value" : "credentials" + }, { + "name" : "Default Credentials Chain (Hybrid/Self-Managed only)", + "value" : "defaultCredentialsChain" + } ] + }, { + "id" : "data.memory.storage.authentication.accessKey", + "label" : "Access key", + "description" : "Provide an IAM access key with permissions for bedrock-agentcore:CreateEvent and bedrock-agentcore:ListEvents", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.accessKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.authentication.secretKey", + "label" : "Secret key", + "description" : "Provide the secret key for the IAM access key", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.authentication.secretKey", + "type" : "zeebe:input" + }, + "condition" : { + "allMatch" : [ { + "property" : "data.memory.storage.authentication.type", + "equals" : "credentials", + "type" : "simple" + }, { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + } ] + }, + "type" : "String" + }, { + "id" : "data.memory.storage.memoryId", + "label" : "Memory ID", + "description" : "The ID of the pre-provisioned AgentCore Memory resource.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.memoryId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.actorId", + "label" : "Actor ID", + "description" : "Identifier of the actor associated with events (e.g., end-user or agent/user combination).", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.actorId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "aws-agentcore", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.storeType", + "label" : "Implementation type", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.storeType", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.storage.parameters", + "label" : "Parameters", + "description" : "Parameters for the custom memory storage implementation.", + "optional" : true, + "feel" : "required", + "group" : "memory", + "binding" : { + "name" : "data.memory.storage.parameters", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.memory.storage.type", + "equals" : "custom", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.memory.contextWindowSize", + "label" : "Context window size", + "description" : "Maximum number of recent conversation messages which are passed to the model.", + "optional" : false, + "value" : 20, + "feel" : "static", + "group" : "memory", + "binding" : { + "name" : "data.memory.contextWindowSize", + "type" : "zeebe:input" + }, + "tooltip" : "Use this to limit the number of messages which are sent to the model. The agent will only send the most recent messages up to the configured limit to the LLM. Older messages will be kept in the conversation store, but not sent to the model. See documentation for details.", + "type" : "Number" + }, { + "id" : "data.limits.maxModelCalls", + "label" : "Maximum model calls", + "description" : "Maximum number of calls to the model as a safety limit to prevent infinite loops.", + "optional" : false, + "value" : 10, + "feel" : "static", + "group" : "limits", + "binding" : { + "name" : "data.limits.maxModelCalls", + "type" : "zeebe:input" + }, + "type" : "Number" + }, { + "id" : "data.response.format.type", + "label" : "Response format", + "description" : "Specify the response format. Support for JSON mode varies by provider.", + "value" : "text", + "group" : "response", + "binding" : { + "name" : "data.response.format.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Text", + "value" : "text" + }, { + "name" : "JSON", + "value" : "json" + } ] + }, { + "id" : "data.response.format.parseJson", + "label" : "Parse text as JSON", + "description" : "Tries to parse the LLM response text as JSON object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.format.parseJson", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "text", + "type" : "simple" + }, + "tooltip" : "Use this option in combination with models which don't support native JSON mode/structured tool calling (e.g. Anthropic). Make sure to instruct the model to return valid JSON in the system prompt. The parsed JSON will be available as response.responseJson.

If parsing fails, null will be returned as JSON response, but the text content will still be available as response.responseText.", + "type" : "Boolean" + }, { + "id" : "data.response.format.schema", + "label" : "Response JSON schema", + "description" : "An optional response JSON Schema to instruct the model how to structure the JSON output.", + "optional" : true, + "feel" : "required", + "group" : "response", + "binding" : { + "name" : "data.response.format.schema", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "tooltip" : "If supported by the model, the response will be structured according to the provided schema. A parsed version of the response will be available as response.responseJson.", + "type" : "String" + }, { + "id" : "data.response.format.schemaName", + "label" : "Response JSON schema name", + "description" : "An optional name for the response JSON Schema to make the model aware of the expected output.", + "optional" : true, + "value" : "Response", + "feel" : "optional", + "group" : "response", + "binding" : { + "name" : "data.response.format.schemaName", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "data.response.format.type", + "equals" : "json", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "data.response.includeAssistantMessage", + "label" : "Include assistant message", + "description" : "Include the full assistant message as part of the result object.", + "optional" : true, + "feel" : "static", + "group" : "response", + "binding" : { + "name" : "data.response.includeAssistantMessage", + "type" : "zeebe:input" + }, + "tooltip" : "In addition to the text content, the assistant message may include multiple additional content blocks and metadata (such as token usage). The message will be available as response.responseMessage.", + "type" : "Boolean" + }, { + "id" : "version", + "label" : "Version", + "description" : "Version of the element template", + "value" : "11", + "group" : "connector", + "binding" : { + "key" : "elementTemplateVersion", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "id", + "label" : "ID", + "description" : "ID of the element template", + "value" : "io.camunda.connectors.agenticai.aiagent.v1", + "group" : "connector", + "binding" : { + "key" : "elementTemplateId", + "type" : "zeebe:taskHeader" + }, + "type" : "Hidden" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in. Details in the documentation.", + "value" : "agent", + "group" : "output", + "binding" : { + "key" : "resultVariable", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "id" : "resultExpression", + "label" : "Result expression", + "description" : "Expression to map the response into process variables. Details in the documentation.", + "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", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTYiIGZpbGw9IiNBNTZFRkYiLz4KPG1hc2sgaWQ9InBhdGgtMi1vdXRzaWRlLTFfMTg1XzYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjQiIHk9IjQiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iYmxhY2siPgo8cmVjdCBmaWxsPSJ3aGl0ZSIgeD0iNCIgeT0iNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIvPgo8L21hc2s+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjAuMDEwNSAxMi4wOTg3QzE4LjQ5IDEwLjU4OTQgMTcuMTU5NCA4LjEwODE0IDE2LjE3OTkgNi4wMTEwM0MxNi4xNTIgNi4wMDQ1MSAxNi4xMTc2IDYgMTYuMDc5NCA2QzE2LjA0MTEgNiAxNi4wMDY2IDYuMDA0NTEgMTUuOTc4OCA2LjAxMTA0QzE0Ljk5OTQgOC4xMDgxNCAxMy42Njk3IDEwLjU4ODkgMTIuMTQ4MSAxMi4wOTgxQzEwLjYyNjkgMTMuNjA3MSA4LjEyNTY4IDE0LjkyNjQgNi4wMTE1NyAxNS44OTgxQzYuMDA0NzQgMTUuOTI2MSA2IDE1Ljk2MTEgNiAxNkM2IDE2LjAzODcgNi4wMDQ2OCAxNi4wNzM2IDYuMDExNDQgMTYuMTAxNEM4LjEyNTE5IDE3LjA3MjkgMTAuNjI2MiAxOC4zOTE5IDEyLjE0NzcgMTkuOTAxNkMxMy42Njk3IDIxLjQxMDcgMTQuOTk5NiAyMy44OTIgMTUuOTc5MSAyNS45ODlDMTYuMDA2OCAyNS45OTU2IDE2LjA0MTEgMjYgMTYuMDc5MyAyNkMxNi4xMTc1IDI2IDE2LjE1MTkgMjUuOTk1NCAxNi4xNzk2IDI1Ljk4OUMxNy4xNTkxIDIzLjg5MiAxOC40ODg4IDIxLjQxMSAyMC4wMDk5IDE5LjkwMjFNMjAuMDA5OSAxOS45MDIxQzIxLjUyNTMgMTguMzk4NyAyMy45NDY1IDE3LjA2NjkgMjUuOTkxNSAxNi4wODI0QzI1Ljk5NjUgMTYuMDU5MyAyNiAxNi4wMzEgMjYgMTUuOTk5N0MyNiAxNS45Njg0IDI1Ljk5NjUgMTUuOTQwMyAyNS45OTE1IDE1LjkxNzFDMjMuOTQ3NCAxNC45MzI3IDIxLjUyNTkgMTMuNjAxIDIwLjAxMDUgMTIuMDk4NyIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yMC4wMTA1IDEyLjA5ODdDMTguNDkgMTAuNTg5NCAxNy4xNTk0IDguMTA4MTQgMTYuMTc5OSA2LjAxMTAzQzE2LjE1MiA2LjAwNDUxIDE2LjExNzYgNiAxNi4wNzk0IDZDMTYuMDQxMSA2IDE2LjAwNjYgNi4wMDQ1MSAxNS45Nzg4IDYuMDExMDRDMTQuOTk5NCA4LjEwODE0IDEzLjY2OTcgMTAuNTg4OSAxMi4xNDgxIDEyLjA5ODFDMTAuNjI2OSAxMy42MDcxIDguMTI1NjggMTQuOTI2NCA2LjAxMTU3IDE1Ljg5ODFDNi4wMDQ3NCAxNS45MjYxIDYgMTUuOTYxMSA2IDE2QzYgMTYuMDM4NyA2LjAwNDY4IDE2LjA3MzYgNi4wMTE0NCAxNi4xMDE0QzguMTI1MTkgMTcuMDcyOSAxMC42MjYyIDE4LjM5MTkgMTIuMTQ3NyAxOS45MDE2QzEzLjY2OTcgMjEuNDEwNyAxNC45OTk2IDIzLjg5MiAxNS45NzkxIDI1Ljk4OUMxNi4wMDY4IDI1Ljk5NTYgMTYuMDQxMSAyNiAxNi4wNzkzIDI2QzE2LjExNzUgMjYgMTYuMTUxOSAyNS45OTU0IDE2LjE3OTYgMjUuOTg5QzE3LjE1OTEgMjMuODkyIDE4LjQ4ODggMjEuNDExIDIwLjAwOTkgMTkuOTAyMU0yMC4wMDk5IDE5LjkwMjFDMjEuNTI1MyAxOC4zOTg3IDIzLjk0NjUgMTcuMDY2OSAyNS45OTE1IDE2LjA4MjRDMjUuOTk2NSAxNi4wNTkzIDI2IDE2LjAzMSAyNiAxNS45OTk3QzI2IDE1Ljk2ODQgMjUuOTk2NSAxNS45NDAzIDI1Ljk5MTUgMTUuOTE3MUMyMy45NDc0IDE0LjkzMjcgMjEuNTI1OSAxMy42MDEgMjAuMDEwNSAxMi4wOTg3IiBzdHJva2U9IiM0OTFEOEIiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgbWFzaz0idXJsKCNwYXRoLTItb3V0c2lkZS0xXzE4NV82KSIvPgo8L3N2Zz4K" + } +} \ No newline at end of file diff --git a/connectors/agentic-ai/examples/ai-agent/ad-hoc-sub-process/ai-agent-chat-with-tools/ai-agent-chat-with-tools.bpmn b/connectors/agentic-ai/examples/ai-agent/ad-hoc-sub-process/ai-agent-chat-with-tools/ai-agent-chat-with-tools.bpmn index 2c4e813e866..076ae564e5e 100644 --- a/connectors/agentic-ai/examples/ai-agent/ad-hoc-sub-process/ai-agent-chat-with-tools/ai-agent-chat-with-tools.bpmn +++ b/connectors/agentic-ai/examples/ai-agent/ad-hoc-sub-process/ai-agent-chat-with-tools/ai-agent-chat-with-tools.bpmn @@ -1,5 +1,5 @@ - + @@ -39,17 +39,14 @@ =userSatisfied - + - - - - - - + + + @@ -66,7 +63,7 @@ - + @@ -248,9 +245,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -278,6 +315,9 @@ + + + @@ -326,6 +366,9 @@ + + + diff --git a/connectors/agentic-ai/pom.xml b/connectors/agentic-ai/pom.xml index 5ec4bdbc365..6ce947240e6 100644 --- a/connectors/agentic-ai/pom.xml +++ b/connectors/agentic-ai/pom.xml @@ -22,6 +22,8 @@ 5.0.5 4.3.1 0.3.3.Final + 2.16.1 + 4.17.0 @@ -132,6 +134,28 @@ langchain4j-http-client-jdk + + + com.anthropic + anthropic-java-core + ${version.anthropic-java} + + + com.anthropic + anthropic-java-client-okhttp + ${version.anthropic-java} + + + com.openai + openai-java-core + ${version.openai-java} + + + com.openai + openai-java-client-okhttp + ${version.openai-java} + + io.modelcontextprotocol.sdk @@ -293,18 +317,18 @@ - com.google.guava - guava + io.camunda.connector + jackson-datatype-document test - io.camunda.connector - connector-object-mapper + com.google.guava + guava test io.camunda.connector - jackson-datatype-document + connector-object-mapper test diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandler.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandler.java index 4c8eebadc05..2dfb11c10ac 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandler.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandler.java @@ -12,21 +12,29 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.a2a.client.common.model.result.A2aArtifact; +import io.camunda.connector.agenticai.a2a.client.common.model.result.A2aMessage; import io.camunda.connector.agenticai.a2a.client.common.model.result.A2aSendMessageResult; +import io.camunda.connector.agenticai.a2a.client.common.model.result.A2aTask; import io.camunda.connector.agenticai.a2a.client.outbound.model.A2aStandaloneOperationConfiguration; import io.camunda.connector.agenticai.a2a.client.outbound.model.A2aStandaloneOperationConfiguration.FetchAgentCardOperationConfiguration; import io.camunda.connector.agenticai.aiagent.model.AgentContext; import io.camunda.connector.agenticai.aiagent.tool.GatewayToolDefinitionUpdates; import io.camunda.connector.agenticai.aiagent.tool.GatewayToolDiscoveryInitiationResult; import io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandler; +import io.camunda.connector.agenticai.model.message.content.Content; +import io.camunda.connector.agenticai.model.message.content.DocumentContent; import io.camunda.connector.agenticai.model.tool.GatewayToolDefinition; import io.camunda.connector.agenticai.model.tool.ToolCall; import io.camunda.connector.agenticai.model.tool.ToolCallResult; import io.camunda.connector.agenticai.model.tool.ToolDefinition; import io.camunda.connector.agenticai.util.CollectionUtils; +import io.camunda.connector.api.document.Document; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; @@ -231,6 +239,56 @@ private ToolCallResult toolCallResultFromA2aSendMessage(ToolCallResult toolCallR return toolCallResultBuilder.build(); } + @Override + public List extractDocuments(ToolCallResult toolCallResult) { + if (!(toolCallResult.content() instanceof A2aSendMessageResult result)) { + LOGGER.debug( + "A2A tool call result content is not an A2aSendMessageResult ({}), skipping document extraction. toolCallId={}, toolName={}", + toolCallResult.content() != null + ? toolCallResult.content().getClass().getSimpleName() + : null, + toolCallResult.id(), + toolCallResult.name()); + return List.of(); + } + + final var documents = new ArrayList(); + collectDocumentsFromResult(result, documents); + return documents; + } + + private void collectDocumentsFromResult(A2aSendMessageResult result, List documents) { + switch (result) { + case A2aMessage message -> collectDocumentsFromContents(message.contents(), documents); + case A2aTask task -> { + Optional.ofNullable(task.artifacts()) + .ifPresent( + artifacts -> + artifacts.forEach( + artifact -> collectDocumentsFromArtifact(artifact, documents))); + Optional.ofNullable(task.history()) + .ifPresent( + history -> + history.forEach(message -> collectDocumentsFromResult(message, documents))); + } + } + } + + private void collectDocumentsFromArtifact(A2aArtifact artifact, List documents) { + collectDocumentsFromContents(artifact.contents(), documents); + } + + private void collectDocumentsFromContents(List contents, List documents) { + if (contents == null) { + return; + } + for (Content content : contents) { + if (content instanceof DocumentContent documentContent) { + documents.add(documentContent.document()); + } + } + } + /** * Creates a description for the tool definition by serializing the agent card and appending a * fixed explanation of the tool call result structure. diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/AiAgentFunction.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/AiAgentFunction.java index ecc263489d3..cfc0d9d20ad 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/AiAgentFunction.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/AiAgentFunction.java @@ -38,7 +38,7 @@ documentationRef = "https://docs.camunda.io/docs/8.10/components/connectors/out-of-the-box-connectors/agentic-ai-aiagent-task/", engineVersion = "^8.10", - version = 10, + version = 12, inputDataClass = OutboundConnectorAgentRequest.class, outputDataClass = AgentResponse.class, defaultResultVariable = "agent", diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerImpl.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerImpl.java index 35c82b6d7f7..a00a0f6721c 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerImpl.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerImpl.java @@ -20,6 +20,7 @@ import io.camunda.connector.agenticai.aiagent.systemprompt.SystemPromptComposer; import io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandlerRegistry; import io.camunda.connector.agenticai.model.message.AssistantMessage; +import io.camunda.connector.agenticai.model.message.DocumentXmlTag; import io.camunda.connector.agenticai.model.message.Message; import io.camunda.connector.agenticai.model.message.SystemMessage; import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; @@ -224,6 +225,16 @@ private Message createEventMessage( }); } + // extract documents from event content and add as document content blocks + // events originate from BPMN event sub-processes (not a gateway handler), so walk the raw tree + var eventDocuments = ContentTreeDocumentWalker.extractDocumentsFromContent(eventContent); + if (!eventDocuments.isEmpty()) { + for (var doc : eventDocuments) { + userMessageContent.add(textContent(DocumentXmlTag.from(doc).toXml())); + userMessageContent.add(DocumentContent.documentContent(doc)); + } + } + return UserMessage.builder() .content(userMessageContent) .metadata(defaultMessageMetadata()) diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/BaseAgentRequestHandler.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/BaseAgentRequestHandler.java index 0354a2344bf..721cbedbd65 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/BaseAgentRequestHandler.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/BaseAgentRequestHandler.java @@ -9,7 +9,8 @@ import io.camunda.connector.agenticai.aiagent.agent.AgentInitializationResult.AgentContextInitializationResult; import io.camunda.connector.agenticai.aiagent.agent.AgentInitializationResult.AgentDiscoveryInProgressInitializationResult; import io.camunda.connector.agenticai.aiagent.agent.AgentInitializationResult.AgentResponseInitializationResult; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkAdapter; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationSession; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStore; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistry; @@ -45,7 +46,7 @@ public abstract class BaseAgentRequestHandler< private final AgentLimitsValidator limitsValidator; private final AgentMessagesHandler messagesHandler; private final GatewayToolHandlerRegistry gatewayToolHandlers; - private final AiFrameworkAdapter framework; + private final ChatClient chatClient; private final AgentResponseHandler responseHandler; public BaseAgentRequestHandler( @@ -54,14 +55,14 @@ public BaseAgentRequestHandler( AgentLimitsValidator limitsValidator, AgentMessagesHandler messagesHandler, GatewayToolHandlerRegistry gatewayToolHandlers, - AiFrameworkAdapter framework, + ChatClient chatClient, AgentResponseHandler responseHandler) { this.agentInitializer = agentInitializer; this.conversationStoreRegistry = conversationStoreRegistry; this.limitsValidator = limitsValidator; this.messagesHandler = messagesHandler; this.gatewayToolHandlers = gatewayToolHandlers; - this.framework = framework; + this.chatClient = chatClient; this.responseHandler = responseHandler; } @@ -166,13 +167,13 @@ private AgentResponse processConversation( handleAddedUserMessages(executionContext, agentContext, userMessages); - // call framework with memory - LOGGER.debug("Executing chat request with AI framework"); - final var frameworkChatResponse = - framework.executeChatRequest(executionContext, agentContext, runtimeMemory); - agentContext = frameworkChatResponse.agentContext(); + // dispatch via the chat client SPI; bridge / native impls live behind it + LOGGER.debug("Executing chat request"); + final var chatClientResult = + chatClient.chat(executionContext, agentContext, runtimeMemory, ChatStreamListener.NOOP); + agentContext = chatClientResult.agentContext(); - final var assistantMessage = frameworkChatResponse.assistantMessage(); + final var assistantMessage = chatClientResult.assistantMessage(); LOGGER.debug( "Received assistant message containing {} tool call requests", assistantMessage.toolCalls() != null ? assistantMessage.toolCalls().size() : 0); diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/ContentTreeDocumentWalker.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/ContentTreeDocumentWalker.java new file mode 100644 index 00000000000..58452794289 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/ContentTreeDocumentWalker.java @@ -0,0 +1,61 @@ +/* + * 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.agenticai.aiagent.agent; + +import io.camunda.connector.api.document.Document; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Stateless utility that recursively walks an arbitrary content tree and collects {@link Document} + * instances. Handles {@link Document}, {@link Map}, {@link Collection}, and {@code Object[]}; all + * other types are skipped. + * + *

Used as the default extraction strategy by {@link + * io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandler#extractDocuments} and as the + * fallback in {@link ToolCallResultDocumentExtractor} for tool call results not managed by any + * gateway handler. Public on purpose so that gateway handler implementations whose typed content + * wraps raw user-generated subtrees (e.g. arbitrary maps from a downstream system) can delegate to + * it for those subtrees. + */ +public final class ContentTreeDocumentWalker { + + private ContentTreeDocumentWalker() {} + + public static List extractDocumentsFromContent(Object content) { + if (content == null) { + return List.of(); + } + + final var documents = new ArrayList(); + collectDocuments(content, documents); + return documents; + } + + private static void collectDocuments(Object node, List documents) { + if (node == null) { + return; + } + + switch (node) { + case Document document -> documents.add(document); + case Map map -> map.values().forEach(value -> collectDocuments(value, documents)); + case Collection collection -> + collection.forEach(element -> collectDocuments(element, documents)); + case Object[] array -> { + for (Object element : array) { + collectDocuments(element, documents); + } + } + default -> { + // scalars and other types - nothing to extract + } + } + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandler.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandler.java index e7b84914188..3ff76ebc8be 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandler.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandler.java @@ -9,7 +9,7 @@ import io.camunda.connector.agenticai.aiagent.AiAgentJobWorker; import io.camunda.connector.agenticai.aiagent.AiAgentSubProcessConnectorResponse; import io.camunda.connector.agenticai.aiagent.AiAgentSubProcessConnectorResponse.ToolCallElementActivation; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkAdapter; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistry; import io.camunda.connector.agenticai.aiagent.model.AgentContext; import io.camunda.connector.agenticai.aiagent.model.AgentResponse; @@ -39,7 +39,7 @@ public JobWorkerAgentRequestHandler( AgentLimitsValidator limitsValidator, AgentMessagesHandler messagesHandler, GatewayToolHandlerRegistry gatewayToolHandlers, - AiFrameworkAdapter framework, + ChatClient chatClient, AgentResponseHandler responseHandler) { super( agentInitializer, @@ -47,7 +47,7 @@ public JobWorkerAgentRequestHandler( limitsValidator, messagesHandler, gatewayToolHandlers, - framework, + chatClient, responseHandler); } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandler.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandler.java index 93721a801ce..ac7b2a854f1 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandler.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandler.java @@ -9,7 +9,7 @@ import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_NO_USER_MESSAGE_CONTENT; import io.camunda.connector.agenticai.aiagent.AiAgentTaskConnectorResponse; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkAdapter; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistry; import io.camunda.connector.agenticai.aiagent.model.AgentContext; import io.camunda.connector.agenticai.aiagent.model.AgentResponse; @@ -30,7 +30,7 @@ public OutboundConnectorAgentRequestHandler( AgentLimitsValidator limitsValidator, AgentMessagesHandler messagesHandler, GatewayToolHandlerRegistry gatewayToolHandlers, - AiFrameworkAdapter framework, + ChatClient chatClient, AgentResponseHandler responseHandler) { super( agentInitializer, @@ -38,7 +38,7 @@ public OutboundConnectorAgentRequestHandler( limitsValidator, messagesHandler, gatewayToolHandlers, - framework, + chatClient, responseHandler); } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/ToolCallResultDocumentExtractor.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/ToolCallResultDocumentExtractor.java new file mode 100644 index 00000000000..88fe4647270 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/agent/ToolCallResultDocumentExtractor.java @@ -0,0 +1,74 @@ +/* + * 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.agenticai.aiagent.agent; + +import io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandlerRegistry; +import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.api.document.Document; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Extracts {@link Document} instances from a list of tool call results, grouped by tool call. + * + *

For each result, the extractor walks the {@code content()} tree using {@link + * ContentTreeDocumentWalker} by default. When a result belongs to a {@link + * io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandler} that overrides {@link + * io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandler#extractDocuments(ToolCallResult) + * extractDocuments}, the handler's typed extraction is used instead — gateway handlers contribute + * extraction for the results they manage; the generic walker handles everything else. + */ +public class ToolCallResultDocumentExtractor { + + private static final Logger LOGGER = + LoggerFactory.getLogger(ToolCallResultDocumentExtractor.class); + + /** Documents extracted from a single tool call result, grouped with the tool call identity. */ + public record ToolCallDocuments( + String toolCallId, String toolCallName, List documents) {} + + private final GatewayToolHandlerRegistry gatewayToolHandlers; + + public ToolCallResultDocumentExtractor(GatewayToolHandlerRegistry gatewayToolHandlers) { + this.gatewayToolHandlers = gatewayToolHandlers; + } + + /** + * Extracts all {@link Document} instances from the given tool call results, grouped by tool call. + * Tool calls without documents are excluded from the result. Order is preserved. + */ + public List extractDocuments(List toolCallResults) { + final var result = new ArrayList(); + int totalDocuments = 0; + + for (ToolCallResult toolCallResult : toolCallResults) { + final var documents = extractFromToolCallResult(toolCallResult); + if (!documents.isEmpty()) { + result.add(new ToolCallDocuments(toolCallResult.id(), toolCallResult.name(), documents)); + totalDocuments += documents.size(); + } + } + + LOGGER.debug( + "Tool call document extraction: processed {} result(s), extracted documents from {} ({} document(s) total)", + toolCallResults.size(), + result.size(), + totalDocuments); + + return result; + } + + private List extractFromToolCallResult(ToolCallResult toolCallResult) { + return gatewayToolHandlers + .handlerForToolDefinition(toolCallResult.name()) + .map(handler -> handler.extractDocuments(toolCallResult)) + .orElseGet( + () -> ContentTreeDocumentWalker.extractDocumentsFromContent(toolCallResult.content())); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/AiFrameworkAdapter.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/AiFrameworkAdapter.java deleted file mode 100644 index 732e1884529..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/AiFrameworkAdapter.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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.agenticai.aiagent.framework; - -import io.camunda.connector.agenticai.aiagent.memory.runtime.RuntimeMemory; -import io.camunda.connector.agenticai.aiagent.model.AgentContext; -import io.camunda.connector.agenticai.aiagent.model.AgentExecutionContext; - -public interface AiFrameworkAdapter> { - R executeChatRequest( - AgentExecutionContext executionContext, - AgentContext agentContext, - RuntimeMemory runtimeMemory); -} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/AiFrameworkChatResponse.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/AiFrameworkChatResponse.java deleted file mode 100644 index 50b04538f45..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/AiFrameworkChatResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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.agenticai.aiagent.framework; - -import io.camunda.connector.agenticai.aiagent.model.AgentContext; -import io.camunda.connector.agenticai.model.message.AssistantMessage; - -public interface AiFrameworkChatResponse { - AgentContext agentContext(); - - AssistantMessage assistantMessage(); - - T rawChatResponse(); -} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/ChatClientImpl.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/ChatClientImpl.java new file mode 100644 index 00000000000..6c61ae9012c --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/ChatClientImpl.java @@ -0,0 +1,129 @@ +/* + * 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.agenticai.aiagent.framework; + +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClientResult; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiRegistry; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.strategy.ToolCallResultStrategy; +import io.camunda.connector.agenticai.aiagent.memory.runtime.RuntimeMemory; +import io.camunda.connector.agenticai.aiagent.model.AgentContext; +import io.camunda.connector.agenticai.aiagent.model.AgentExecutionContext; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseConfiguration; +import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; +import io.camunda.connector.agenticai.model.message.UserMessage; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; + +public class ChatClientImpl implements ChatClient { + + private final ChatModelApiRegistry registry; + private final ToolCallResultStrategy toolCallResultStrategy; + + public ChatClientImpl( + ChatModelApiRegistry registry, ToolCallResultStrategy toolCallResultStrategy) { + this.registry = registry; + this.toolCallResultStrategy = toolCallResultStrategy; + } + + @Override + public ChatClientResult chat( + AgentExecutionContext executionContext, + AgentContext agentContext, + RuntimeMemory runtimeMemory, + ChatStreamListener listener) { + final var api = registry.resolve(executionContext.provider()); + final var capabilities = api.capabilities(); + + final var initialRequest = + new ChatRequest( + runtimeMemory.filteredMessages(), + agentContext.toolDefinitions(), + Optional.ofNullable(executionContext.response()) + .map(ResponseConfiguration::format) + .orElse(null)); + + final var routed = toolCallResultStrategy.apply(initialRequest, capabilities); + persistSyntheticContextMessages(runtimeMemory, routed.syntheticContextMessages()); + + final var options = new ChatOptions(null, null, null, Map.of()); + + final var chatResponse = + joinChat( + api.complete( + routed.request(), options, listener != null ? listener : ChatStreamListener.NOOP)); + + final var assistantMessage = chatResponse.assistantMessage(); + final var usage = + assistantMessage.usage() != null + ? assistantMessage.usage() + : AgentMetrics.TokenUsage.empty(); + final var updatedAgentContext = + agentContext.withMetrics( + agentContext.metrics().incrementModelCalls(1).incrementTokenUsage(usage)); + + return new ChatClientResult(updatedAgentContext, assistantMessage); + } + + /** + * Inserts synthetic context messages into {@link RuntimeMemory} immediately after the most recent + * {@link ToolCallResultMessage}. Preserves the {@code [TCRM, syntheticUM, eventUMs]} order that + * {@code MessageWindowRuntimeMemory}'s eviction relies on (synthetic UMs immediately follow their + * anchor TCR so eviction co-removes them). + * + *

TODO(adr-005): the {@code clear() + addMessages(all)} dance is ugly. Replace with either an + * {@code insertAfter(predicate, messages)} method on {@link RuntimeMemory}, or move synthetic-UM + * insertion ownership to {@code AgentMessagesHandler} (with a deferred slot the strategy fills), + * or fold {@code ChatClient}'s routing responsibility into {@code BaseAgentRequestHandler}. See + * the {@code ChatClient}↔{@code BARQ} TODO in ADR-005 §"Tool Call Result Routing". + */ + private static void persistSyntheticContextMessages( + RuntimeMemory runtimeMemory, List syntheticContextMessages) { + if (syntheticContextMessages.isEmpty()) { + return; + } + final var all = new ArrayList<>(runtimeMemory.allMessages()); + int anchorIdx = -1; + for (int i = all.size() - 1; i >= 0; i--) { + if (all.get(i) instanceof ToolCallResultMessage) { + anchorIdx = i; + break; + } + } + if (anchorIdx < 0) { + throw new IllegalStateException( + "Strategy produced synthetic context messages but no ToolCallResultMessage exists " + + "in runtime memory — strategy invariant violated"); + } + all.addAll(anchorIdx + 1, syntheticContextMessages); + runtimeMemory.clear(); + runtimeMemory.addMessages(all); + } + + private static io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse joinChat( + java.util.concurrent.CompletableFuture< + io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse> + future) { + try { + return future.join(); + } catch (CompletionException e) { + // unwrap so callers see the original ConnectorException (or other RuntimeException) rather + // than the CompletableFuture wrapper + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw e; + } + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/ChatModelApiRegistryImpl.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/ChatModelApiRegistryImpl.java new file mode 100644 index 00000000000..0b4f66b3e3d --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/ChatModelApiRegistryImpl.java @@ -0,0 +1,71 @@ +/* + * 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.agenticai.aiagent.framework; + +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiRegistry; +import io.camunda.connector.agenticai.aiagent.model.request.provider.ProviderConfiguration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ChatModelApiRegistryImpl implements ChatModelApiRegistry { + + private static final Logger LOGGER = LoggerFactory.getLogger(ChatModelApiRegistryImpl.class); + + private final Map> factoriesByProviderType; + + public ChatModelApiRegistryImpl(List> factories) { + this.factoriesByProviderType = indexByProviderType(factories); + } + + private static Map> indexByProviderType( + List> factories) { + final Map> index = new HashMap<>(); + for (ChatModelApiFactory factory : factories) { + final var providerType = factory.providerType(); + final var existing = index.get(providerType); + if (existing != null) { + throw new IllegalStateException( + "Two chat model API factories claim provider type '%s': %s and %s. To override the default factory, exclude the built-in bean (e.g. via @ConditionalOnMissingBean) before contributing your own." + .formatted( + providerType, existing.getClass().getName(), factory.getClass().getName())); + } + index.put(providerType, factory); + LOGGER.debug( + "Registered chat model API factory for provider type '{}': {} (apiFamily={})", + providerType, + factory.getClass().getName(), + factory.apiFamily()); + } + return Map.copyOf(index); + } + + @Override + @SuppressWarnings("unchecked") + public ChatModelApi resolve(ProviderConfiguration configuration) { + final var providerType = configuration.providerType(); + final var factory = factoriesByProviderType.get(providerType); + if (factory == null) { + throw new IllegalStateException( + "No chat model API factory registered for provider type '%s'".formatted(providerType)); + } + if (!factory.configurationType().isInstance(configuration)) { + throw new IllegalStateException( + "Chat model API factory %s claims provider type '%s' but expects configurationType %s, got %s" + .formatted( + factory.getClass().getName(), + providerType, + factory.configurationType().getName(), + configuration.getClass().getName())); + } + return ((ChatModelApiFactory) factory).create(configuration); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesApiConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesApiConfiguration.java new file mode 100644 index 00000000000..605a59b5221 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesApiConfiguration.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.agenticai.aiagent.framework.anthropic; + +import com.anthropic.client.AnthropicClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.ModelCapabilitiesResolver; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; +import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsConfigurationProperties; +import io.camunda.connector.runtime.annotation.ConnectorsObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Spring configuration that registers the native Anthropic Messages factory under the same bean + * name as the LangChain4j bridge factory ({@code langchain4JAnthropicChatModelApiFactory}). The + * bridge bean is gated on {@code @ConditionalOnMissingBean(name = ...)} so this native bean wins + * automatically whenever the SDK is on the classpath. + */ +@Configuration +@ConditionalOnClass(AnthropicClient.class) +public class AnthropicMessagesApiConfiguration { + + @Bean(name = "langchain4JAnthropicChatModelApiFactory") + @ConditionalOnMissingBean(name = "langchain4JAnthropicChatModelApiFactory") + public ChatModelApiFactory anthropicMessagesChatModelApiFactory( + @ConnectorsObjectMapper ObjectMapper objectMapper, + ModelCapabilitiesResolver capabilitiesResolver, + AgenticAiConnectorsConfigurationProperties properties) { + return new AnthropicMessagesChatModelApiFactory( + objectMapper, + capabilitiesResolver, + properties.aiagent().chatModel().api().defaultTimeout()); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApi.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApi.java new file mode 100644 index 00000000000..e349238a4c5 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApi.java @@ -0,0 +1,513 @@ +/* + * 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.agenticai.aiagent.framework.anthropic; + +import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.core.JsonValue; +import com.anthropic.models.messages.Base64ImageSource; +import com.anthropic.models.messages.Base64PdfSource; +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.ContentBlockParam; +import com.anthropic.models.messages.DocumentBlockParam; +import com.anthropic.models.messages.ImageBlockParam; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.MessageParam; +import com.anthropic.models.messages.TextBlockParam; +import com.anthropic.models.messages.Tool; +import com.anthropic.models.messages.ToolResultBlockParam; +import com.anthropic.models.messages.ToolUnion; +import com.anthropic.models.messages.ToolUseBlockParam; +import com.anthropic.models.messages.Usage; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.content.ContentTextSerializer; +import io.camunda.connector.agenticai.aiagent.framework.multimodal.DocumentModality; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.agenticai.model.message.AssistantMessage; +import io.camunda.connector.agenticai.model.message.AssistantMessageBuilder; +import io.camunda.connector.agenticai.model.message.StopReason; +import io.camunda.connector.agenticai.model.message.SystemMessage; +import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; +import io.camunda.connector.agenticai.model.message.UserMessage; +import io.camunda.connector.agenticai.model.message.content.Content; +import io.camunda.connector.agenticai.model.message.content.DocumentContent; +import io.camunda.connector.agenticai.model.message.content.TextContent; +import io.camunda.connector.agenticai.model.tool.ToolCall; +import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.agenticai.model.tool.ToolDefinition; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.error.ConnectorException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; + +/** + * Native {@link ChatModelApi} for the Anthropic Messages API (direct backend), driving the {@code + * anthropic-java} SDK's blocking {@code messages().create(...)} endpoint. + * + *

Phase B scope (text-only): user / assistant / tool-result content is restricted to text; + * multimodal content blocks, reasoning round-tripping and prompt caching are deferred to Phase E. + * {@link ChatOptions#reasoning()} and {@link ChatOptions#cacheRetention()} are accepted but ignored + * in this phase. Streaming is not yet wired in either — {@link ChatStreamListener} is accepted but + * no events are emitted. + */ +public class AnthropicMessagesChatModelApi implements ChatModelApi { + + private static final long DEFAULT_MAX_TOKENS = 4096L; + + private final AnthropicClient client; + private final String model; + private final ObjectMapper objectMapper; + private final ModelCapabilities capabilities; + @Nullable private final Long configuredMaxTokens; + @Nullable private final Double temperature; + @Nullable private final Double topP; + @Nullable private final Long topK; + + public AnthropicMessagesChatModelApi( + AnthropicClient client, + String model, + ObjectMapper objectMapper, + ModelCapabilities capabilities, + @Nullable Long configuredMaxTokens, + @Nullable Double temperature, + @Nullable Double topP, + @Nullable Long topK) { + this.client = Objects.requireNonNull(client, "client"); + this.model = Objects.requireNonNull(model, "model"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + this.capabilities = Objects.requireNonNull(capabilities, "capabilities"); + this.configuredMaxTokens = configuredMaxTokens; + this.temperature = temperature; + this.topP = topP; + this.topK = topK; + } + + @Override + public ModelCapabilities capabilities() { + return capabilities; + } + + @Override + public CompletableFuture complete( + ChatRequest request, ChatOptions options, ChatStreamListener listener) { + try { + final var params = buildParams(request, options); + final var response = client.messages().create(params); + return CompletableFuture.completedFuture(new ChatResponse(toAssistantMessage(response))); + } catch (RuntimeException e) { + return CompletableFuture.failedFuture(wrapModelCallFailure(e)); + } + } + + private MessageCreateParams buildParams(ChatRequest request, ChatOptions options) { + final var builder = + MessageCreateParams.builder().model(model).maxTokens(resolveMaxTokens(options)); + + Optional.ofNullable(temperature).ifPresent(builder::temperature); + Optional.ofNullable(topP).ifPresent(builder::topP); + Optional.ofNullable(topK).ifPresent(builder::topK); + + final var messages = request.messages(); + if (messages != null) { + for (var message : messages) { + switch (message) { + case SystemMessage system -> builder.system(systemPrompt(system)); + case UserMessage user -> builder.addMessage(toMessageParam(user)); + case AssistantMessage assistant -> builder.addMessage(toMessageParam(assistant)); + case ToolCallResultMessage toolResult -> builder.addMessage(toMessageParam(toolResult)); + default -> + throw new IllegalArgumentException( + "Unsupported message type: " + message.getClass().getSimpleName()); + } + } + } + + final var toolDefinitions = request.toolDefinitions(); + if (toolDefinitions != null && !toolDefinitions.isEmpty()) { + builder.tools(toolDefinitions.stream().map(this::toToolUnion).toList()); + } + + return builder.build(); + } + + private long resolveMaxTokens(ChatOptions options) { + if (options != null && options.maxOutputTokens() != null) { + return options.maxOutputTokens().longValue(); + } + return configuredMaxTokens != null ? configuredMaxTokens : DEFAULT_MAX_TOKENS; + } + + private static String systemPrompt(SystemMessage system) { + if (system.content() == null || system.content().isEmpty()) { + return ""; + } + if (system.content().size() == 1 && system.content().getFirst() instanceof TextContent t) { + return t.text(); + } + throw new IllegalArgumentException( + "SystemMessage currently only supports a single TextContent block."); + } + + private MessageParam toMessageParam(UserMessage message) { + final var blocks = messageContentBlocks(message.content()); + return MessageParam.builder().role(MessageParam.Role.USER).contentOfBlockParams(blocks).build(); + } + + private MessageParam toMessageParam(AssistantMessage message) { + final var blocks = new ArrayList(); + if (message.content() != null) { + blocks.addAll(textOnlyBlocks(message.content())); + } + if (message.toolCalls() != null) { + for (var call : message.toolCalls()) { + blocks.add(toolUseBlock(call)); + } + } + return MessageParam.builder() + .role(MessageParam.Role.ASSISTANT) + .contentOfBlockParams(blocks) + .build(); + } + + private MessageParam toMessageParam(ToolCallResultMessage message) { + final var blocks = message.results().stream().map(this::toolResultBlock).toList(); + return MessageParam.builder().role(MessageParam.Role.USER).contentOfBlockParams(blocks).build(); + } + + /** + * User-message blocks: text + multimodal documents (image, document/PDF). The {@link + * io.camunda.connector.agenticai.aiagent.framework.strategy.ToolCallResultStrategy} has already + * validated user-message documents against the model's {@code userMessageModalities}, so any + * {@link DocumentContent} reaching this point is known to be supported. + */ + private List messageContentBlocks(List content) { + if (content == null) { + return List.of(); + } + final var blocks = new ArrayList(); + for (var c : content) { + blocks.add(messageContentBlock(c)); + } + return blocks; + } + + private ContentBlockParam messageContentBlock(Content content) { + if (content instanceof DocumentContent doc) { + final var modality = DocumentModality.of(doc.document()); + return switch (modality) { + case IMAGE -> ContentBlockParam.ofImage(imageBlockParam(doc.document())); + case DOCUMENT -> ContentBlockParam.ofDocument(documentBlockParam(doc.document())); + default -> + throw new IllegalArgumentException( + "Document modality " + + modality + + " is not supported in Anthropic user/tool messages " + + "(only image + document emit natively); the strategy should have routed " + + "this document to a synthetic UserMessage or rejected it."); + }; + } + return textOnlyBlock(content); + } + + /** + * Assistant-message blocks: text only (assistant turns we send back to the model don't carry + * documents). Used by the assistant-message conversion path. + */ + private List textOnlyBlocks(List content) { + if (content == null) { + return List.of(); + } + final var blocks = new ArrayList(); + for (var c : content) { + blocks.add(textOnlyBlock(c)); + } + return blocks; + } + + private ContentBlockParam textOnlyBlock(Content content) { + return ContentBlockParam.ofText( + TextBlockParam.builder().text(ContentTextSerializer.toText(content, objectMapper)).build()); + } + + private static ImageBlockParam imageBlockParam(Document document) { + final var mimeType = document.metadata().getContentType(); + return ImageBlockParam.builder() + .source( + Base64ImageSource.builder() + .data(document.asBase64()) + .mediaType(toAnthropicImageMediaType(mimeType)) + .build()) + .build(); + } + + private static DocumentBlockParam documentBlockParam(Document document) { + return DocumentBlockParam.builder() + .source(Base64PdfSource.builder().data(document.asBase64()).build()) + .build(); + } + + private static Base64ImageSource.MediaType toAnthropicImageMediaType(String mimeType) { + return switch (mimeType.toLowerCase().trim()) { + case "image/jpeg" -> Base64ImageSource.MediaType.IMAGE_JPEG; + case "image/png" -> Base64ImageSource.MediaType.IMAGE_PNG; + case "image/gif" -> Base64ImageSource.MediaType.IMAGE_GIF; + case "image/webp" -> Base64ImageSource.MediaType.IMAGE_WEBP; + default -> + throw new IllegalArgumentException( + "Unsupported image MIME type for Anthropic image source: " + mimeType); + }; + } + + private static ContentBlockParam toolUseBlock(ToolCall call) { + final var inputBuilder = ToolUseBlockParam.Input.builder(); + if (call.arguments() != null) { + call.arguments() + .forEach((key, value) -> inputBuilder.putAdditionalProperty(key, JsonValue.from(value))); + } + return ContentBlockParam.ofToolUse( + ToolUseBlockParam.builder() + .id(call.id()) + .name(call.name()) + .input(inputBuilder.build()) + .build()); + } + + private ContentBlockParam toolResultBlock(ToolCallResult result) { + final var b = ToolResultBlockParam.builder().toolUseId(result.id()); + final var inlineBlocks = toolResultContentBlocks(result); + if (inlineBlocks != null) { + b.contentOfBlocks(inlineBlocks); + } else { + // Always serialise via our @ConnectorsObjectMapper (document-aware) instead of the SDK's + // internal mapper — `contentAsJson(content)` would route through Anthropic's own + // ObjectMapper which can't serialise CamundaDocument and friends. + b.content(serializedToolResultText(result)); + } + final var interrupted = + result.properties() != null + && Boolean.TRUE.equals(result.properties().get(ToolCallResult.PROPERTY_INTERRUPTED)); + if (interrupted) { + b.isError(true); + } + return ContentBlockParam.ofToolResult(b.build()); + } + + /** + * Builds the {@code [text, image, document]} block list for a tool result whose {@code + * contentBlocks} were populated by the strategy with inline-supported documents. Returns null + * when no inline routing happened — caller falls back to the string / JSON content path. + * + *

Shape: one text block with the serialised tool-result content (so the model still sees the + * structured JSON the result came from, with document references in place), followed by one image + * / document block per inline document. + */ + private List toolResultContentBlocks(ToolCallResult result) { + if (result.contentBlocks() == null || result.contentBlocks().isEmpty()) { + return null; + } + final var blocks = new ArrayList(); + blocks.add( + ToolResultBlockParam.Content.Block.ofText( + TextBlockParam.builder().text(serializedToolResultText(result)).build())); + for (var block : result.contentBlocks()) { + if (!(block instanceof DocumentContent doc)) { + throw new IllegalArgumentException( + "Unsupported inline tool-result content block type: " + + block.getClass().getSimpleName()); + } + final var modality = DocumentModality.of(doc.document()); + switch (modality) { + case IMAGE -> + blocks.add(ToolResultBlockParam.Content.Block.ofImage(imageBlockParam(doc.document()))); + case DOCUMENT -> + blocks.add( + ToolResultBlockParam.Content.Block.ofDocument(documentBlockParam(doc.document()))); + default -> + throw new IllegalArgumentException( + "Document modality " + + modality + + " is not supported in Anthropic tool result blocks (only image + document " + + "emit natively); the strategy should have routed this document to a " + + "synthetic UserMessage."); + } + } + return blocks; + } + + private String serializedToolResultText(ToolCallResult result) { + final var content = result.content(); + if (content == null) { + return ToolCallResult.CONTENT_NO_RESULT; + } + if (content instanceof String s) { + return s; + } + try { + return objectMapper.writeValueAsString(content); + } catch (JsonProcessingException e) { + throw new ConnectorException( + ERROR_CODE_FAILED_MODEL_CALL, + "Failed to serialise tool call result content to JSON for tool '%s': %s" + .formatted(result.name(), e.getOriginalMessage())); + } + } + + private ToolUnion toToolUnion(ToolDefinition definition) { + final var tool = + Tool.builder().name(definition.name()).inputSchema(toInputSchema(definition.inputSchema())); + if (definition.description() != null) { + tool.description(definition.description()); + } + return ToolUnion.ofTool(tool.build()); + } + + private static final Set KNOWN_SCHEMA_KEYS = Set.of("type", "properties", "required"); + + @SuppressWarnings("unchecked") + private static Tool.InputSchema toInputSchema(Map schemaMap) { + final var builder = Tool.InputSchema.builder(); + if (schemaMap == null || schemaMap.isEmpty()) { + return builder.build(); + } + + final var type = schemaMap.get("type"); + if (type != null) { + builder.type(JsonValue.from(type)); + } + + final var properties = schemaMap.get("properties"); + if (properties instanceof Map propsMap) { + final var pb = Tool.InputSchema.Properties.builder(); + ((Map) propsMap) + .forEach((key, value) -> pb.putAdditionalProperty(key, JsonValue.from(value))); + builder.properties(pb.build()); + } + + final var required = schemaMap.get("required"); + if (required instanceof List reqList) { + builder.required(reqList.stream().map(String::valueOf).toList()); + } + + schemaMap.forEach( + (key, value) -> { + if (!KNOWN_SCHEMA_KEYS.contains(key)) { + builder.putAdditionalProperty(key, JsonValue.from(value)); + } + }); + + return builder.build(); + } + + private AssistantMessage toAssistantMessage(Message message) { + final var builder = AssistantMessage.builder(); + builder.metadata(Map.of("timestamp", ZonedDateTime.now())); + + if (StringUtils.isNotBlank(message.id())) { + builder.messageId(message.id()); + } + final var modelId = message.model().asString(); + if (StringUtils.isNotBlank(modelId)) { + builder.modelId(modelId); + } + + final var contentBlocks = new ArrayList(); + final var toolCalls = new ArrayList(); + for (ContentBlock block : message.content()) { + if (block.isText()) { + final var text = block.asText().text(); + if (StringUtils.isNotBlank(text)) { + contentBlocks.add(TextContent.textContent(text)); + } + } else if (block.isToolUse()) { + final var toolUse = block.asToolUse(); + toolCalls.add(toToolCall(toolUse.id(), toolUse.name(), toolUse._input())); + } + // ignore other block types (thinking / server-tool-* / etc.) for the text-only first cut + } + if (!contentBlocks.isEmpty()) { + builder.content(contentBlocks); + } + builder.toolCalls(toolCalls); + + message + .stopReason() + .map(AnthropicMessagesChatModelApi::toStopReason) + .ifPresent(builder::stopReason); + builder.usage(toTokenUsage(message.usage())); + + return finalizeBuilder(builder); + } + + private static AssistantMessage finalizeBuilder(AssistantMessageBuilder builder) { + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static ToolCall toToolCall(String id, String name, JsonValue input) { + final var arguments = + Optional.ofNullable(input.convert(Map.class)) + .map(map -> (Map) map) + .orElseGet(LinkedHashMap::new); + return ToolCall.builder().id(id).name(name).arguments(arguments).build(); + } + + private static StopReason toStopReason(com.anthropic.models.messages.StopReason stopReason) { + final var known = stopReason.known(); + if (known == null) { + return null; + } + return switch (known) { + case END_TURN, STOP_SEQUENCE -> StopReason.STOP; + case MAX_TOKENS -> StopReason.LENGTH; + case TOOL_USE -> StopReason.TOOL_USE; + case PAUSE_TURN -> StopReason.STOP; + case REFUSAL -> StopReason.CONTENT_FILTERED; + }; + } + + private static AgentMetrics.TokenUsage toTokenUsage(Usage usage) { + if (usage == null) { + return AgentMetrics.TokenUsage.empty(); + } + final var builder = + AgentMetrics.TokenUsage.builder() + .inputTokenCount((int) usage.inputTokens()) + .outputTokenCount((int) usage.outputTokens()); + usage.cacheReadInputTokens().ifPresent(v -> builder.cacheReadInputTokenCount(v.intValue())); + usage + .cacheCreationInputTokens() + .ifPresent(v -> builder.cacheCreationInputTokenCount(v.intValue())); + return builder.build(); + } + + private static ConnectorException wrapModelCallFailure(RuntimeException e) { + final var message = + Optional.ofNullable(e.getMessage()) + .filter(StringUtils::isNotBlank) + .orElseGet(() -> e.getClass().getSimpleName()); + return new ConnectorException( + ERROR_CODE_FAILED_MODEL_CALL, "Anthropic Messages call failed: %s".formatted(message), e); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApiFactory.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApiFactory.java new file mode 100644 index 00000000000..654250145fd --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApiFactory.java @@ -0,0 +1,122 @@ +/* + * 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.agenticai.aiagent.framework.anthropic; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.ModelCapabilitiesResolver; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication.AnthropicApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicConnection; +import java.time.Duration; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; + +/** + * Native Anthropic Messages factory for the {@code anthropic} discriminator (direct backend). + * Builds an {@link AnthropicClient} using the published OkHttp transport from a {@link + * AnthropicProviderConfiguration} and produces an {@link AnthropicMessagesChatModelApi} per + * invocation. + * + *

Replaces the LangChain4j bridge factory at this discriminator. Cloud backends (Bedrock / + * Vertex / Foundry) will land in Phase G via additional factory variants. + */ +public class AnthropicMessagesChatModelApiFactory + implements ChatModelApiFactory { + + public static final String API_FAMILY = "anthropic-messages"; + + private final ObjectMapper objectMapper; + private final ModelCapabilitiesResolver capabilitiesResolver; + @Nullable private final Duration defaultTimeout; + + public AnthropicMessagesChatModelApiFactory( + ObjectMapper objectMapper, + ModelCapabilitiesResolver capabilitiesResolver, + @Nullable Duration defaultTimeout) { + this.objectMapper = objectMapper; + this.capabilitiesResolver = capabilitiesResolver; + this.defaultTimeout = defaultTimeout; + } + + @Override + public String providerType() { + return AnthropicProviderConfiguration.ANTHROPIC_ID; + } + + @Override + public String apiFamily() { + return API_FAMILY; + } + + @Override + public Class configurationType() { + return AnthropicProviderConfiguration.class; + } + + @Override + public ChatModelApi create(AnthropicProviderConfiguration configuration) { + final var connection = configuration.anthropic(); + final var client = buildClient(connection); + final var parameters = connection.model().parameters(); + final var capabilities = + capabilitiesResolver.resolve(API_FAMILY, connection.model().model(), Optional.empty()); + return new AnthropicMessagesChatModelApi( + client, + connection.model().model(), + objectMapper, + capabilities, + parameters != null && parameters.maxTokens() != null + ? parameters.maxTokens().longValue() + : null, + parameters != null ? parameters.temperature() : null, + parameters != null ? parameters.topP() : null, + parameters != null && parameters.topK() != null ? parameters.topK().longValue() : null); + } + + private AnthropicClient buildClient(AnthropicConnection connection) { + if (!(connection.authentication() instanceof AnthropicApiKeyAuthentication apiKeyAuth)) { + throw new IllegalArgumentException( + "Unsupported authentication type for DIRECT backend: " + + connection.authentication().getClass().getSimpleName()); + } + final var builder = AnthropicOkHttpClient.builder().apiKey(apiKeyAuth.apiKey()); + + if (StringUtils.isNotBlank(connection.endpoint())) { + builder.baseUrl(normalizeBaseUrl(connection.endpoint())); + } + + final var timeout = resolveTimeout(connection); + if (timeout != null) { + builder.timeout(timeout); + } + + return builder.build(); + } + + @Nullable + private Duration resolveTimeout(AnthropicConnection connection) { + return Optional.ofNullable(connection.timeouts()).map(t -> t.timeout()).orElse(defaultTimeout); + } + + /** + * The {@code anthropic-java} SDK expects {@code baseUrl} to be the host without the {@code /v1} + * prefix and appends the full {@code /v1/messages} path itself. The LangChain4j Anthropic client + * used the opposite convention (callers set {@code https://host/v1}, L4J appends just {@code + * /messages}). Strip a trailing {@code /v1} (with or without trailing slash) so existing + * element-template configurations keep working when users switch to the native impl. + */ + static String normalizeBaseUrl(String endpoint) { + final var trimmed = + endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + return trimmed.endsWith("/v1") ? trimmed.substring(0, trimmed.length() - 3) : trimmed; + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/CacheRetention.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/CacheRetention.java new file mode 100644 index 00000000000..969ad342247 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/CacheRetention.java @@ -0,0 +1,23 @@ +/* + * 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.agenticai.aiagent.framework.api; + +/** + * Prompt-cache retention preference passed via {@link ChatOptions}. + * + *

{@code SHORT} maps to each provider's default ephemeral retention, {@code LONG} to extended + * retention; {@code NONE} strips cache markers entirely. Concrete breakpoint placement is + * implementation-specific. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public enum CacheRetention { + NONE, + SHORT, + LONG +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatClient.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatClient.java new file mode 100644 index 00000000000..69bf174a652 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatClient.java @@ -0,0 +1,34 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import io.camunda.connector.agenticai.aiagent.memory.runtime.RuntimeMemory; +import io.camunda.connector.agenticai.aiagent.model.AgentContext; +import io.camunda.connector.agenticai.aiagent.model.AgentExecutionContext; + +/** + * High-level facade called by {@code BaseAgentRequestHandler}. Resolves the {@link ChatModelApi} + * for the request, applies the tool-result strategy, assembles a {@link ChatRequest} + {@link + * ChatOptions} from the runtime memory and the execution / agent context, dispatches to {@link + * ChatModelApi#complete}, joins the resulting future, increments {@link AgentContext#metrics()} + * based on the assistant message, and wraps everything in a {@link ChatClientResult}. + * + *

The asynchronous nature of {@link ChatModelApi#complete} is an implementation detail — callers + * see a synchronous facade matching the previous {@code AiFrameworkAdapter} contract. In-process + * observability hooks attach via {@link ChatStreamListener}; the public surface is blocking. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public interface ChatClient { + + ChatClientResult chat( + AgentExecutionContext executionContext, + AgentContext agentContext, + RuntimeMemory runtimeMemory, + ChatStreamListener listener); +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatClientResult.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatClientResult.java new file mode 100644 index 00000000000..1a45f64f83f --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatClientResult.java @@ -0,0 +1,21 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import io.camunda.connector.agenticai.aiagent.model.AgentContext; +import io.camunda.connector.agenticai.model.message.AssistantMessage; + +/** + * Result of {@link ChatClient#chat}. Carries the agent context with model-call metrics and token + * usage already incremented, plus the assistant message produced by the underlying {@link + * ChatModelApi}. Replaces {@code AiFrameworkChatResponse} at the {@code BaseAgentRequestHandler} + * call site so the cutover stays a 1:1 swap. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public record ChatClientResult(AgentContext agentContext, AssistantMessage assistantMessage) {} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApi.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApi.java new file mode 100644 index 00000000000..6677e42b566 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApi.java @@ -0,0 +1,29 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import java.util.concurrent.CompletableFuture; + +/** + * Per-job model client with the resolved provider configuration baked in. Created on demand by a + * {@link ChatModelApiFactory} and reused across capability lookups, tool-result strategy + * application, and the {@link #complete} call within a single request. + * + *

Implementations drive the underlying SDK's streaming endpoint internally and expose a blocking + * {@link CompletableFuture} surface to callers. The optional {@link ChatStreamListener} receives + * discriminated stream events for in-process observability. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public interface ChatModelApi { + + ModelCapabilities capabilities(); + + CompletableFuture complete( + ChatRequest request, ChatOptions options, ChatStreamListener listener); +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApiFactory.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApiFactory.java new file mode 100644 index 00000000000..fc06dfce386 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApiFactory.java @@ -0,0 +1,40 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import io.camunda.connector.agenticai.aiagent.model.request.provider.ProviderConfiguration; + +/** + * Stateless factory that produces per-job {@link ChatModelApi} instances for a single {@link + * ProviderConfiguration#providerType()} discriminator. The bridge for a multi-provider framework + * (e.g. LangChain4j) is registered as one factory bean per discriminator it handles. + * + *

The {@link ChatModelApiRegistry} indexes factories by {@link #providerType()} and dispatches + * by exact match. Two factories claiming the same discriminator fail at startup; user overrides + * happen via {@code @ConditionalOnMissingBean} on the built-in bean rather than silent shadowing. + * + *

{@link #apiFamily()} is informational telemetry (logs, {@link + * io.camunda.connector.agenticai.aiagent.framework.api.event.ChatModelEvent.StartEvent}) — not used + * for routing. {@link #configurationType()} acts as a defensive runtime check before the registry's + * unchecked cast — a friendlier error than {@link ClassCastException} when a factory is + * accidentally registered against the wrong discriminator. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + * + * @param the {@link ProviderConfiguration} subtype this factory handles + */ +public interface ChatModelApiFactory { + + String providerType(); + + String apiFamily(); + + Class configurationType(); + + ChatModelApi create(C configuration); +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApiRegistry.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApiRegistry.java new file mode 100644 index 00000000000..b56fc4babf4 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatModelApiRegistry.java @@ -0,0 +1,26 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import io.camunda.connector.agenticai.aiagent.model.request.provider.ProviderConfiguration; + +/** + * Resolves a {@link ChatModelApi} for a given {@link ProviderConfiguration}. Composed of all + * registered {@link ChatModelApiFactory} beans, indexed by the strings each factory advertises via + * {@link ChatModelApiFactory#providerTypes()}; lookup is exact-match on the configuration's + * provider type. + * + *

Unknown provider types fail fast — there is no factory to resolve, so requests can never + * start. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public interface ChatModelApiRegistry { + + ChatModelApi resolve(ProviderConfiguration configuration); +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatOptions.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatOptions.java new file mode 100644 index 00000000000..7711d1f3c19 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatOptions.java @@ -0,0 +1,38 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import java.util.Map; +import org.springframework.lang.Nullable; + +/** + * Per-call tunables passed alongside a {@link ChatRequest}. The shape is intentionally narrow: + * fields here are the ones with consistent semantics across every wire-protocol family we + * implement. Sampling parameters that are partially supported (e.g. {@code temperature}, {@code + * topP}, {@code topK}, {@code seed}, {@code frequencyPenalty}) live in {@link #providerOptions} + * under their well-known keys; each {@link ChatModelApi} implementation reads the keys it cares + * about and ignores the rest. + * + *

Note on {@link #maxOutputTokens} and reasoning tokens: on most providers + * (OpenAI Chat Completions, OpenAI Responses, Google GenAI, AWS Bedrock) reasoning / thinking + * tokens count toward this cap — a small value combined with reasoning enabled can leave zero room + * for visible output. On Anthropic Claude 4+ thinking tokens do not affect the {@code max_tokens} + * cap but are still billed in full. Implementations document any provider-specific clamping or + * adjustment they apply. + * + *

Resolution of the actual value sent on the wire happens in {@code ChatClient}: explicit + * caller-supplied value wins, otherwise the resolved {@link ModelCapabilities#maxOutputTokens()} is + * used as a fallback, otherwise the implementation supplies its own per-API default. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public record ChatOptions( + @Nullable Integer maxOutputTokens, + @Nullable ReasoningConfig reasoning, + @Nullable CacheRetention cacheRetention, + Map providerOptions) {} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatRequest.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatRequest.java new file mode 100644 index 00000000000..d5c7b8fb0b6 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatRequest.java @@ -0,0 +1,32 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration; +import io.camunda.connector.agenticai.model.message.Message; +import io.camunda.connector.agenticai.model.tool.ToolDefinition; +import java.util.List; +import org.springframework.lang.Nullable; + +/** + * Inputs to a {@link ChatModelApi#complete} call assembled by {@code ChatClient}: the conversation + * messages (system message inline at the head when present), resolved tool definitions, and the + * requested response format. Per-call tunables (max output tokens, reasoning, cache retention, + * vendor escape hatches) live on {@link ChatOptions} so a request can be reused while options vary. + * + *

{@code responseFormat} reuses the connector-config type {@link ResponseFormatConfiguration} + * directly. Implementations translate the {@code Json}/{@code Text} variants onto the provider's + * native shape; providers without a native structured-output mode (Anthropic Messages today) treat + * the JSON variant as best-effort and rely on the system prompt to constrain output. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public record ChatRequest( + List messages, + List toolDefinitions, + @Nullable ResponseFormatConfiguration responseFormat) {} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatResponse.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatResponse.java new file mode 100644 index 00000000000..4b11e73d2a6 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatResponse.java @@ -0,0 +1,21 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import io.camunda.connector.agenticai.model.message.AssistantMessage; + +/** + * Output of {@link ChatModelApi#complete}. Carries the assembled assistant message; {@link + * AssistantMessage#stopReason()} and {@link AssistantMessage#usage()} convey the normalized stop + * reason and per-call token usage. Model-side terminal failures (refusal, content filter, malformed + * tool-use) populate the message with {@code stopReason = ERROR}; transport / SDK / auth failures + * complete the future exceptionally instead. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public record ChatResponse(AssistantMessage assistantMessage) {} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatStreamListener.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatStreamListener.java new file mode 100644 index 00000000000..a6655e18a05 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ChatStreamListener.java @@ -0,0 +1,26 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import io.camunda.connector.agenticai.aiagent.framework.api.event.ChatModelEvent; + +/** + * In-process observability hook invoked for every {@link ChatModelEvent} produced while a {@link + * ChatModelApi} drives a provider's streaming endpoint. Listeners default to {@link #NOOP}; the + * listener is intentionally not exposed as a reactive type — the public chat surface remains a + * blocking {@code CompletableFuture}. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +@FunctionalInterface +public interface ChatStreamListener { + + ChatStreamListener NOOP = event -> {}; + + void onEvent(ChatModelEvent event); +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ModelCapabilities.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ModelCapabilities.java new file mode 100644 index 00000000000..b2188a2cbf3 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ModelCapabilities.java @@ -0,0 +1,45 @@ +/* + * 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.agenticai.aiagent.framework.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import org.springframework.lang.Nullable; + +/** + * Per-model capability descriptor materialised from the capability matrix YAML (or a connector + * config override). Drives runtime decisions like tool-result strategy selection, reasoning + * negotiation, and cache-marker placement. The vocabulary for {@link Modality} is fixed; modality + * lists per location are symmetric so every location has an explicit answer. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public record ModelCapabilities( + List userMessageModalities, + List toolResultModalities, + List assistantMessageModalities, + boolean supportsReasoning, + boolean supportsReasoningSignatureRoundtrip, + boolean supportsPromptCaching, + boolean supportsParallelToolCalls, + @Nullable Integer contextWindow, + @Nullable Integer maxOutputTokens) { + + public enum Modality { + @JsonProperty("text") + TEXT, + @JsonProperty("image") + IMAGE, + @JsonProperty("document") + DOCUMENT, + @JsonProperty("audio") + AUDIO, + @JsonProperty("video") + VIDEO + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ReasoningConfig.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ReasoningConfig.java new file mode 100644 index 00000000000..4436220771e --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/ReasoningConfig.java @@ -0,0 +1,36 @@ +/* + * 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.agenticai.aiagent.framework.api; + +/** + * Two-tier reasoning configuration: high-level {@link Effort} or explicit token {@link + * ReasoningBudget}, with {@link ReasoningDisabled} to opt out. Per-implementation translation maps + * this onto provider-native fields (Anthropic adaptive effort / thinking budget, OpenAI Responses + * reasoning effort, Gemini thinking budget, etc.). + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public sealed interface ReasoningConfig + permits ReasoningConfig.ReasoningEffort, + ReasoningConfig.ReasoningBudget, + ReasoningConfig.ReasoningDisabled { + + enum Effort { + MINIMAL, + LOW, + MEDIUM, + HIGH, + X_HIGH + } + + record ReasoningEffort(Effort level) implements ReasoningConfig {} + + record ReasoningBudget(int tokens) implements ReasoningConfig {} + + record ReasoningDisabled() implements ReasoningConfig {} +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/event/ChatModelEvent.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/event/ChatModelEvent.java new file mode 100644 index 00000000000..4a01dd08f87 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/api/event/ChatModelEvent.java @@ -0,0 +1,70 @@ +/* + * 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.agenticai.aiagent.framework.api.event; + +import io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import org.springframework.lang.Nullable; + +/** + * Discriminated stream events emitted by a {@code ChatModelApi} implementation while driving a + * provider's streaming endpoint. Listeners receive every event in order; {@link DoneEvent} carries + * the assembled {@link ChatResponse}, while {@link ErrorEvent} carries the error message plus any + * partial content / usage accumulated before the failure. + * + *

Part of the ADR-005 Phase 1 SPI scaffolding. Wired by ChatClientImpl, dispatched via + * ChatModelApiRegistry. + */ +public sealed interface ChatModelEvent + permits ChatModelEvent.StartEvent, + ChatModelEvent.TextStartEvent, + ChatModelEvent.TextDeltaEvent, + ChatModelEvent.TextEndEvent, + ChatModelEvent.ReasoningStartEvent, + ChatModelEvent.ReasoningDeltaEvent, + ChatModelEvent.ReasoningEndEvent, + ChatModelEvent.ToolCallStartEvent, + ChatModelEvent.ToolCallArgumentsDeltaEvent, + ChatModelEvent.ToolCallEndEvent, + ChatModelEvent.UsageEvent, + ChatModelEvent.DoneEvent, + ChatModelEvent.ErrorEvent { + + record StartEvent(@Nullable String apiFamily, @Nullable String modelId) + implements ChatModelEvent {} + + record TextStartEvent(int blockIndex) implements ChatModelEvent {} + + record TextDeltaEvent(int blockIndex, String delta) implements ChatModelEvent {} + + record TextEndEvent(int blockIndex) implements ChatModelEvent {} + + record ReasoningStartEvent(int blockIndex) implements ChatModelEvent {} + + record ReasoningDeltaEvent(int blockIndex, String delta, @Nullable String signatureDelta) + implements ChatModelEvent {} + + record ReasoningEndEvent(int blockIndex) implements ChatModelEvent {} + + record ToolCallStartEvent(int blockIndex, String toolCallId, String toolName) + implements ChatModelEvent {} + + record ToolCallArgumentsDeltaEvent(int blockIndex, String argumentsDelta) + implements ChatModelEvent {} + + record ToolCallEndEvent(int blockIndex) implements ChatModelEvent {} + + record UsageEvent(AgentMetrics.TokenUsage usage) implements ChatModelEvent {} + + record DoneEvent(ChatResponse response) implements ChatModelEvent {} + + record ErrorEvent( + String errorMessage, + @Nullable ChatResponse partialResponse, + @Nullable AgentMetrics.TokenUsage partialUsage) + implements ChatModelEvent {} +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/AgenticAiCapabilitiesConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/AgenticAiCapabilitiesConfiguration.java new file mode 100644 index 00000000000..a1b6480295e --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/AgenticAiCapabilitiesConfiguration.java @@ -0,0 +1,76 @@ +/* + * 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.agenticai.aiagent.framework.capabilities; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.runtime.annotation.ConnectorsObjectMapper; +import java.io.IOException; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.context.EnvironmentAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; + +/** + * Spring configuration for the model capability matrix. + * + *

Bundled defaults from {@code resources/capabilities/model-capabilities.yaml} are loaded as a + * low-precedence {@link PropertySource} via {@link #setEnvironment(Environment)} — invoked when + * Spring instantiates this configuration bean and therefore robust against runtimes that skip + * Spring Boot's {@code EnvironmentPostProcessor} discovery (e.g. when the connector jar is loaded + * after the host application context has already started). Library-consumer overrides under the + * same {@code camunda.connector.agenticai.aiagent.framework.capabilities.*} prefix land on top. + */ +@Configuration +@EnableConfigurationProperties(AgenticAiFrameworkProperties.class) +public class AgenticAiCapabilitiesConfiguration implements EnvironmentAware { + + private static final String BUNDLED_RESOURCE = "capabilities/model-capabilities.yaml"; + private static final String PROPERTY_SOURCE_NAME = "agentic-ai-bundled-capability-matrix"; + + @Override + public void setEnvironment(Environment environment) { + if (!(environment instanceof ConfigurableEnvironment configurable)) { + return; + } + if (configurable.getPropertySources().contains(PROPERTY_SOURCE_NAME)) { + return; + } + final var resource = new ClassPathResource(BUNDLED_RESOURCE); + if (!resource.exists()) { + return; + } + try { + final List> sources = + new YamlPropertySourceLoader().load(PROPERTY_SOURCE_NAME, resource); + sources.forEach(configurable.getPropertySources()::addLast); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to load bundled capability matrix from classpath:" + BUNDLED_RESOURCE, e); + } + } + + @Bean + @ConditionalOnMissingBean + public CapabilityMatrix aiAgentCapabilityMatrix( + AgenticAiFrameworkProperties properties, @ConnectorsObjectMapper ObjectMapper objectMapper) { + return CapabilityMatrixFactory.build(properties, objectMapper); + } + + @Bean + @ConditionalOnMissingBean + public ModelCapabilitiesResolver aiAgentModelCapabilitiesResolver( + CapabilityMatrix matrix, @ConnectorsObjectMapper ObjectMapper objectMapper) { + return new ModelCapabilitiesResolver(matrix, objectMapper); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/AgenticAiFrameworkProperties.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/AgenticAiFrameworkProperties.java new file mode 100644 index 00000000000..204cc792ec4 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/AgenticAiFrameworkProperties.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.agenticai.aiagent.framework.capabilities; + +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.lang.Nullable; + +/** + * Spring Boot configuration binding for the agentic-ai framework. The {@code capabilities} map is + * populated from two layers, deep-merged by Spring Boot: + * + *

    + *
  1. Bundled defaults: {@code resources/capabilities/model-capabilities.yaml}, registered as a + * low-precedence {@link org.springframework.core.env.PropertySource} by {@link + * AgenticAiCapabilitiesConfiguration#setEnvironment} when the configuration bean is + * instantiated. + *
  2. Application overrides: any property under {@code + * camunda.connector.agenticai.aiagent.framework.capabilities.*} declared by the library + * consumer (typically in their own {@code application.yml}). Library consumers can override + * an individual model's capability fields, replace a sub-modality list, or add a brand-new + * model entry under any api family without restating the bundled matrix. + *
+ * + * Map keys under {@code models} carry the discriminator: keys containing {@code *} are treated as + * glob patterns, otherwise as model ids. Optional explicit {@code id} / {@code pattern} fields + * inside an entry override the key derivation when needed. + * + *

Capability sub-trees ({@code defaults} and per-entry {@code capabilities}) are bound to the + * sparse {@link ModelCapabilitiesYaml} record so Spring Boot's relaxed binding can rebuild modality + * lists from indexed property keys; the resolver projects them onto a flat {@link + * io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities} via Jackson tree merge at + * lookup time. + */ +@ConfigurationProperties("camunda.connector.agenticai.aiagent.framework") +public record AgenticAiFrameworkProperties(Map capabilities) { + + public AgenticAiFrameworkProperties { + capabilities = capabilities == null ? Map.of() : Map.copyOf(capabilities); + } + + public record ApiFamilyProperties( + @Nullable ModelCapabilitiesYaml defaults, Map models) { + + public ApiFamilyProperties { + models = models == null ? Map.of() : Map.copyOf(models); + } + } + + public record ModelEntryProperties( + @Nullable String id, + List pattern, + List aliases, + @Nullable ModelCapabilitiesYaml capabilities) { + + public ModelEntryProperties { + aliases = aliases == null ? List.of() : List.copyOf(aliases); + pattern = pattern == null ? List.of() : List.copyOf(pattern); + } + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrix.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrix.java new file mode 100644 index 00000000000..0385bbf0369 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrix.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.agenticai.aiagent.framework.capabilities; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.Map; +import org.springframework.lang.Nullable; + +/** + * Built capability matrix used by the {@link ModelCapabilitiesResolver}. Materialised at startup + * from {@link AgenticAiFrameworkProperties} (Spring Boot config) by {@link + * CapabilityMatrixFactory}: bundled defaults from the classpath YAML are loaded as a low-precedence + * {@link org.springframework.core.env.PropertySource} and library-consumer overrides land on top + * before this matrix is built. + * + *

Capability sub-trees are kept as raw {@link JsonNode}s so the resolver can deep-merge + * (Spring-Boot semantics: maps deep-merge, lists replace, scalars override) at lookup time. + */ +public record CapabilityMatrix(Map families) { + + public CapabilityMatrix { + families = families == null ? Map.of() : Map.copyOf(families); + } + + public record ApiFamily(@Nullable JsonNode defaults, List models) { + public ApiFamily { + models = models == null ? List.of() : List.copyOf(models); + } + } + + public record ModelEntry( + @Nullable String id, + List aliases, + List patterns, + @Nullable JsonNode capabilities) { + public ModelEntry { + aliases = aliases == null ? List.of() : List.copyOf(aliases); + patterns = patterns == null ? List.of() : List.copyOf(patterns); + } + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrixFactory.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrixFactory.java new file mode 100644 index 00000000000..e4a6a0844f1 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrixFactory.java @@ -0,0 +1,106 @@ +/* + * 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.agenticai.aiagent.framework.capabilities; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.AgenticAiFrameworkProperties.ApiFamilyProperties; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.AgenticAiFrameworkProperties.ModelEntryProperties; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.CapabilityMatrix.ApiFamily; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.CapabilityMatrix.ModelEntry; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Materialises a {@link CapabilityMatrix} from {@link AgenticAiFrameworkProperties} bound by Spring + * Boot. Per-entry capability sub-trees ({@code defaults} and {@code capabilities}) are converted to + * {@link JsonNode} so the resolver can deep-merge them at lookup time. + * + *

Each {@code models} map entry must carry exactly one discriminator: + * + *

    + *
  • An explicit {@code pattern} field — a glob string (uses {@code *} only) or a list of globs. + * The entry matches when any glob in the list matches the requested model id; longest-match + * across entries wins. Aliases are not allowed for pattern entries. + *
  • An explicit {@code id} field — the entry matches that model id exactly. + *
  • Neither — the map key itself is treated as the model id. + *
+ * + *

{@code *} and {@code .} cannot appear in the map key because Spring Boot's map binding strips + * them; pattern entries must declare the glob in the {@code pattern} field while the map key stays + * a stable, override-friendly identifier. + */ +public final class CapabilityMatrixFactory { + + private CapabilityMatrixFactory() {} + + public static CapabilityMatrix build( + AgenticAiFrameworkProperties properties, ObjectMapper objectMapper) { + final Map families = new LinkedHashMap<>(); + properties + .capabilities() + .forEach( + (familyName, family) -> + families.put(familyName, buildFamily(familyName, family, objectMapper))); + return new CapabilityMatrix(families); + } + + private static ApiFamily buildFamily( + String familyName, ApiFamilyProperties family, ObjectMapper objectMapper) { + final JsonNode defaults = + family.defaults() == null ? null : objectMapper.valueToTree(family.defaults()); + final List entries = new ArrayList<>(); + family + .models() + .forEach( + (mapKey, entry) -> entries.add(buildEntry(familyName, mapKey, entry, objectMapper))); + return new ApiFamily(defaults, entries); + } + + private static ModelEntry buildEntry( + String familyName, String mapKey, ModelEntryProperties entry, ObjectMapper objectMapper) { + final boolean idExplicit = entry.id() != null && !entry.id().isBlank(); + final List patterns = nonBlank(entry.pattern()); + final boolean patternExplicit = !patterns.isEmpty(); + + if (idExplicit && patternExplicit) { + throw new IllegalStateException( + "Capability matrix entry '%s' under api family '%s' must specify at most one of `id` or `pattern`" + .formatted(mapKey, familyName)); + } + + final String id; + final List resolvedPatterns; + if (patternExplicit) { + id = null; + resolvedPatterns = patterns; + } else { + // id explicit, or id derived from the map key. + id = idExplicit ? entry.id() : mapKey; + resolvedPatterns = List.of(); + } + + if (!resolvedPatterns.isEmpty() && !entry.aliases().isEmpty()) { + throw new IllegalStateException( + "Capability matrix pattern entry '%s' under api family '%s' cannot declare aliases" + .formatted(mapKey, familyName)); + } + + final JsonNode capabilities = + entry.capabilities() == null + ? objectMapper.createObjectNode() + : objectMapper.valueToTree(entry.capabilities()); + + return new ModelEntry(id, entry.aliases(), resolvedPatterns, capabilities); + } + + private static List nonBlank(List values) { + return values.stream().filter(p -> p != null && !p.isBlank()).toList(); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesResolver.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesResolver.java new file mode 100644 index 00000000000..83b6f1ff95d --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesResolver.java @@ -0,0 +1,201 @@ +/* + * 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.agenticai.aiagent.framework.capabilities; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.CapabilityMatrix.ApiFamily; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.CapabilityMatrix.ModelEntry; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; + +/** + * Resolves a runtime {@link ModelCapabilities} from the capability matrix using the four-step chain + * defined in ADR-005: + * + *

    + *
  1. Connector config override (if present) + *
  2. Exact id or alias match within the api family + *
  3. Glob pattern match (longest match wins) + *
  4. Conservative defaults (text-only, all flags false, null limits) + *
+ * + * Pattern and default fall-throughs emit one INFO log per (api family, model id) so operators + * notice when they're running on best-effort capabilities. Exact / alias matches are silent — + * they're verified declarations. + */ +public class ModelCapabilitiesResolver { + + private static final Logger LOG = LoggerFactory.getLogger(ModelCapabilitiesResolver.class); + + static final ModelCapabilities CONSERVATIVE_DEFAULTS = + new ModelCapabilities( + List.of(Modality.TEXT), + List.of(Modality.TEXT), + List.of(Modality.TEXT), + false, + false, + false, + false, + null, + null); + + private static final ModelCapabilitiesYaml CONSERVATIVE_DEFAULTS_YAML = + new ModelCapabilitiesYaml( + new ModelCapabilitiesYaml.InputModalities(List.of(Modality.TEXT), List.of(Modality.TEXT)), + new ModelCapabilitiesYaml.OutputModalities(List.of(Modality.TEXT)), + false, + false, + false, + false, + null, + null); + + private final CapabilityMatrix matrix; + private final ObjectMapper mapper; + private final JsonNode conservativeBase; + private final Set loggedKeys = ConcurrentHashMap.newKeySet(); + + public ModelCapabilitiesResolver(CapabilityMatrix matrix, ObjectMapper mapper) { + this.matrix = matrix; + this.mapper = mapper; + this.conservativeBase = mapper.valueToTree(CONSERVATIVE_DEFAULTS_YAML); + } + + public ModelCapabilities resolve( + String apiFamily, String modelId, Optional override) { + if (override.isPresent()) { + return override.get(); + } + + final ApiFamily family = matrix.families().get(apiFamily); + if (family == null) { + logOnce( + "missing-family:" + apiFamily, + "No capability matrix entry for api family '{}'; using conservative defaults", + apiFamily); + return CONSERVATIVE_DEFAULTS; + } + + final ModelEntry exact = findExact(family.models(), modelId); + if (exact != null) { + return merge(family.defaults(), exact.capabilities()); + } + + final MatchedPattern matched = findLongestPattern(family.models(), modelId); + if (matched != null) { + logOnce( + "pattern:" + apiFamily + ":" + modelId, + "Capability matrix pattern '{}' matched model '{}' (api family '{}')", + matched.pattern(), + modelId, + apiFamily); + return merge(family.defaults(), matched.entry().capabilities()); + } + + logOnce( + "default:" + apiFamily + ":" + modelId, + "No capability matrix entry for model '{}' under api family '{}'; using conservative defaults", + modelId, + apiFamily); + return CONSERVATIVE_DEFAULTS; + } + + @Nullable + private static ModelEntry findExact(List models, String modelId) { + for (ModelEntry entry : models) { + if (modelId.equals(entry.id()) || entry.aliases().contains(modelId)) { + return entry; + } + } + return null; + } + + @Nullable + private static MatchedPattern findLongestPattern(List models, String modelId) { + ModelEntry bestEntry = null; + String bestPattern = null; + int bestLength = -1; + for (ModelEntry entry : models) { + for (String pattern : entry.patterns()) { + if (matchesGlob(pattern, modelId) && pattern.length() > bestLength) { + bestEntry = entry; + bestPattern = pattern; + bestLength = pattern.length(); + } + } + } + return bestEntry == null ? null : new MatchedPattern(bestEntry, bestPattern); + } + + private record MatchedPattern(ModelEntry entry, String pattern) {} + + static boolean matchesGlob(String glob, String value) { + final String[] parts = glob.split("\\*", -1); + final StringBuilder regex = new StringBuilder("^"); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + regex.append(".*"); + } + regex.append(Pattern.quote(parts[i])); + } + regex.append('$'); + return Pattern.matches(regex.toString(), value); + } + + private ModelCapabilities merge( + @Nullable JsonNode familyDefaults, @Nullable JsonNode modelOverrides) { + final JsonNode merged = deepMerge(deepMerge(conservativeBase, familyDefaults), modelOverrides); + try { + return mapper.treeToValue(merged, ModelCapabilitiesYaml.class).toModelCapabilities(); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to materialise model capabilities", e); + } + } + + /** + * Spring-Boot-style deep merge: object maps merge recursively, list and scalar values from the + * overlay replace the base verbatim. + */ + static JsonNode deepMerge(@Nullable JsonNode base, @Nullable JsonNode overlay) { + if (overlay == null || overlay.isNull() || overlay.isMissingNode()) { + return base != null ? base : NullNode.getInstance(); + } + if (base == null + || base.isNull() + || base.isMissingNode() + || !base.isObject() + || !overlay.isObject()) { + return overlay; + } + final ObjectNode merged = base.deepCopy(); + overlay + .fields() + .forEachRemaining( + entry -> + merged.set( + entry.getKey(), deepMerge(merged.get(entry.getKey()), entry.getValue()))); + return merged; + } + + private void logOnce(String dedupKey, String format, Object... args) { + if (loggedKeys.add(dedupKey)) { + LOG.info(format, args); + } + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesYaml.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesYaml.java new file mode 100644 index 00000000000..123c546d465 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesYaml.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.agenticai.aiagent.framework.capabilities; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import java.util.List; +import org.springframework.lang.Nullable; + +/** + * Sparse capability block — bound by Spring Boot from {@code application.yml} (relaxed + * camel/snake-case binding) and serialised by Jackson to/from a {@link + * com.fasterxml.jackson.databind.JsonNode} tree (snake-case via {@link JsonNaming}, nulls preserved + * so the resolver's deep-merge can fall through to the base layer). + * + *

Each field is nullable: a missing field means "inherit from the lower layer" (api-family + * {@code defaults} → conservative defaults). The fully-merged result is projected onto the flat + * {@link ModelCapabilities} SPI shape via {@link #toModelCapabilities()}. + */ +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.ALWAYS) // keep null fields visible to deep-merge +public record ModelCapabilitiesYaml( + @Nullable InputModalities inputModalities, + @Nullable OutputModalities outputModalities, + @Nullable Boolean supportsReasoning, + @Nullable Boolean supportsReasoningSignatureRoundtrip, + @Nullable Boolean supportsPromptCaching, + @Nullable Boolean supportsParallelToolCalls, + @Nullable Integer contextWindow, + @Nullable Integer maxOutputTokens) { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonInclude(JsonInclude.Include.ALWAYS) + public record InputModalities( + @Nullable List userMessage, @Nullable List toolResult) {} + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonInclude(JsonInclude.Include.ALWAYS) + public record OutputModalities(@Nullable List assistantMessage) {} + + ModelCapabilities toModelCapabilities() { + return new ModelCapabilities( + userMessageModalities(), + toolResultModalities(), + assistantMessageModalities(), + Boolean.TRUE.equals(supportsReasoning), + Boolean.TRUE.equals(supportsReasoningSignatureRoundtrip), + Boolean.TRUE.equals(supportsPromptCaching), + Boolean.TRUE.equals(supportsParallelToolCalls), + contextWindow, + maxOutputTokens); + } + + private List userMessageModalities() { + return inputModalities != null && inputModalities.userMessage() != null + ? inputModalities.userMessage() + : List.of(Modality.TEXT); + } + + private List toolResultModalities() { + return inputModalities != null && inputModalities.toolResult() != null + ? inputModalities.toolResult() + : List.of(Modality.TEXT); + } + + private List assistantMessageModalities() { + return outputModalities != null && outputModalities.assistantMessage() != null + ? outputModalities.assistantMessage() + : List.of(Modality.TEXT); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/content/ContentTextSerializer.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/content/ContentTextSerializer.java new file mode 100644 index 00000000000..2e3f7beb183 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/content/ContentTextSerializer.java @@ -0,0 +1,55 @@ +/* + * 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.agenticai.aiagent.framework.content; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.model.message.content.Content; +import io.camunda.connector.agenticai.model.message.content.ObjectContent; +import io.camunda.connector.agenticai.model.message.content.TextContent; + +/** + * Renders a {@link Content} block to a text representation suitable for provider-native APIs that + * only accept text (system prompts, assistant content, the text portion of multimodal user + * messages, the text fallback for unsupported tool-result modalities). + * + *

Mirrors the LangChain4j bridge's {@code ContentConverterImpl}: a {@link TextContent} returns + * its text verbatim; an {@link ObjectContent} returns its inner value as-is when it's already a + * {@code String}, otherwise as Jackson-serialised JSON. Other content types must be handled by the + * caller before reaching this helper. + * + *

Callers are expected to pass the {@code @ConnectorsObjectMapper} so nested {@link + * io.camunda.connector.api.document.Document}s serialise to their reference shape rather than + * throwing. + */ +public final class ContentTextSerializer { + + private ContentTextSerializer() {} + + public static String toText(Content content, ObjectMapper objectMapper) { + if (content instanceof TextContent text) { + return text.text(); + } + if (content instanceof ObjectContent object) { + return objectContentToText(object, objectMapper); + } + throw new IllegalArgumentException( + "Unsupported content block for text serialization: " + content.getClass().getSimpleName()); + } + + public static String objectContentToText(ObjectContent content, ObjectMapper objectMapper) { + final var value = content.content(); + if (value instanceof String s) { + return s; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize ObjectContent value to JSON", e); + } + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterImpl.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterImpl.java index 246b9e67ebd..7f57aafc6fd 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterImpl.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterImpl.java @@ -9,48 +9,42 @@ import static io.camunda.connector.agenticai.util.JacksonExceptionMessageExtractor.humanReadableJsonProcessingExceptionMessage; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; -import dev.langchain4j.internal.Json; +import dev.langchain4j.model.anthropic.AnthropicTokenUsage; +import dev.langchain4j.model.bedrock.BedrockTokenUsage; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.ChatResponseMetadata; +import dev.langchain4j.model.openai.OpenAiTokenUsage; +import dev.langchain4j.model.output.FinishReason; import dev.langchain4j.model.output.TokenUsage; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolCallConverter; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; import io.camunda.connector.agenticai.model.message.AssistantMessage; import io.camunda.connector.agenticai.model.message.AssistantMessageBuilder; +import io.camunda.connector.agenticai.model.message.StopReason; import io.camunda.connector.agenticai.model.message.SystemMessage; import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; import io.camunda.connector.agenticai.model.message.UserMessage; import io.camunda.connector.agenticai.model.message.content.Content; import io.camunda.connector.agenticai.model.message.content.TextContent; -import io.camunda.connector.agenticai.util.ObjectMapperConstants; import io.camunda.connector.api.error.ConnectorException; import java.time.ZonedDateTime; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.util.CollectionUtils; public class ChatMessageConverterImpl implements ChatMessageConverter { - private static final Logger LOGGER = LoggerFactory.getLogger(ChatMessageConverterImpl.class); - private final ContentConverter contentConverter; private final ToolCallConverter toolCallConverter; - private final ObjectMapper objectMapper; public ChatMessageConverterImpl( - ContentConverter contentConverter, - ToolCallConverter toolCallConverter, - ObjectMapper objectMapper) { + ContentConverter contentConverter, ToolCallConverter toolCallConverter) { this.contentConverter = contentConverter; this.toolCallConverter = toolCallConverter; - this.objectMapper = objectMapper; } @Override @@ -132,11 +126,22 @@ public AssistantMessage toAssistantMessage(ChatResponse chatResponse) { protected AssistantMessageBuilder toAssistantMessageBuilder(ChatResponse chatResponse) { final var builder = AssistantMessage.builder(); - if (chatResponse.metadata() != null) { - builder.metadata( - Map.of( - "timestamp", ZonedDateTime.now(), - "framework", serializedChatResponseMetadata(chatResponse.metadata()))); + final ChatResponseMetadata metadata = chatResponse.metadata(); + if (metadata != null) { + builder.metadata(Map.of("timestamp", ZonedDateTime.now())); + + Optional.ofNullable(metadata.modelName()) + .filter(StringUtils::isNotBlank) + .ifPresent(builder::modelId); + Optional.ofNullable(metadata.id()) + .filter(StringUtils::isNotBlank) + .ifPresent(builder::messageId); + Optional.ofNullable(metadata.finishReason()) + .map(this::toStopReason) + .ifPresent(builder::stopReason); + Optional.ofNullable(metadata.tokenUsage()) + .map(this::toDomainTokenUsage) + .ifPresent(builder::usage); } final var aiMessage = chatResponse.aiMessage(); @@ -152,41 +157,47 @@ protected AssistantMessageBuilder toAssistantMessageBuilder(ChatResponse chatRes return builder; } - protected Map serializedChatResponseMetadata( - ChatResponseMetadata chatResponseMetadata) { - if (chatResponseMetadata == null) { - return Map.of(); - } - - final var metadata = new LinkedHashMap(); - Optional.ofNullable(chatResponseMetadata.id()) - .filter(StringUtils::isNotBlank) - .ifPresent(id -> metadata.put("id", id)); - Optional.ofNullable(chatResponseMetadata.finishReason()) - .ifPresent(finishReason -> metadata.put("finishReason", finishReason.name())); - - final var tokenUsage = serializedTokenUsage(chatResponseMetadata.tokenUsage()); - if (!tokenUsage.isEmpty()) { - metadata.put("tokenUsage", tokenUsage); - } - - return metadata; + private StopReason toStopReason(FinishReason finishReason) { + return switch (finishReason) { + case STOP -> StopReason.STOP; + case LENGTH -> StopReason.LENGTH; + case TOOL_EXECUTION -> StopReason.TOOL_USE; + case CONTENT_FILTER -> StopReason.CONTENT_FILTERED; + case OTHER -> null; + }; } - protected Map serializedTokenUsage(TokenUsage tokenUsage) { + AgentMetrics.TokenUsage toDomainTokenUsage(TokenUsage tokenUsage) { if (tokenUsage == null) { - return Map.of(); + return AgentMetrics.TokenUsage.empty(); } - try { - return objectMapper.readValue( - Json.toJson(tokenUsage), ObjectMapperConstants.STRING_OBJECT_MAP_TYPE_REFERENCE); - } catch (JsonProcessingException e) { - LOGGER.warn( - "Failed to deserialize token usage metadata: {}", - humanReadableJsonProcessingExceptionMessage(e)); - return Map.of(); + final var builder = + AgentMetrics.TokenUsage.builder() + .inputTokenCount(Optional.ofNullable(tokenUsage.inputTokenCount()).orElse(0)) + .outputTokenCount(Optional.ofNullable(tokenUsage.outputTokenCount()).orElse(0)); + + if (tokenUsage instanceof AnthropicTokenUsage anthropicTokenUsage) { + Optional.ofNullable(anthropicTokenUsage.cacheReadInputTokens()) + .ifPresent(builder::cacheReadInputTokenCount); + Optional.ofNullable(anthropicTokenUsage.cacheCreationInputTokens()) + .ifPresent(builder::cacheCreationInputTokenCount); + } else if (tokenUsage instanceof BedrockTokenUsage bedrockTokenUsage) { + Optional.ofNullable(bedrockTokenUsage.cacheReadInputTokens()) + .ifPresent(builder::cacheReadInputTokenCount); + // Bedrock uses "write" terminology; we expose it as "creation" to match Anthropic's semantics + Optional.ofNullable(bedrockTokenUsage.cacheWriteInputTokens()) + .ifPresent(builder::cacheCreationInputTokenCount); + } else if (tokenUsage instanceof OpenAiTokenUsage openAiTokenUsage) { + Optional.ofNullable(openAiTokenUsage.inputTokensDetails()) + .map(OpenAiTokenUsage.InputTokensDetails::cachedTokens) + .ifPresent(builder::cacheReadInputTokenCount); + Optional.ofNullable(openAiTokenUsage.outputTokensDetails()) + .map(OpenAiTokenUsage.OutputTokensDetails::reasoningTokens) + .ifPresent(builder::reasoningTokenCount); } + + return builder.build(); } @Override diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterImpl.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterImpl.java index 12854642afd..843a3c3ca6c 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterImpl.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterImpl.java @@ -9,21 +9,20 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentConverter; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentModule; import io.camunda.connector.agenticai.model.message.content.Content; import io.camunda.connector.agenticai.model.message.content.DocumentContent; import io.camunda.connector.agenticai.model.message.content.ObjectContent; +import io.camunda.connector.agenticai.model.message.content.ReasoningContent; import io.camunda.connector.agenticai.model.message.content.TextContent; public class ContentConverterImpl implements ContentConverter { private final DocumentToContentConverter documentToContentConverter; - private final ObjectMapper contentObjectMapper; + private final ObjectMapper objectMapper; public ContentConverterImpl( ObjectMapper objectMapper, DocumentToContentConverter documentToContentConverter) { this.documentToContentConverter = documentToContentConverter; - this.contentObjectMapper = - objectMapper.copy().registerModule(new DocumentToContentModule(documentToContentConverter)); + this.objectMapper = objectMapper; } @Override @@ -36,6 +35,9 @@ public dev.langchain4j.data.message.Content convertToContent(Content content) documentToContentConverter.convert(documentContent.document()); case ObjectContent objectContent -> new dev.langchain4j.data.message.TextContent(convertToString(objectContent.content())); + case ReasoningContent ignored -> + throw new UnsupportedOperationException( + "ReasoningContent is not supported by the LangChain4j framework integration"); }; } @@ -49,6 +51,6 @@ public String convertToString(Object content) throws JsonProcessingException { return stringContent; } - return contentObjectMapper.writeValueAsString(content); + return objectMapper.writeValueAsString(content); } } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkAdapter.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkAdapter.java deleted file mode 100644 index 5d8b1de6a49..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkAdapter.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * 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.agenticai.aiagent.framework.langchain4j; - -import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL; - -import dev.langchain4j.model.chat.ChatModel; -import dev.langchain4j.model.chat.request.ChatRequest; -import dev.langchain4j.model.chat.request.ResponseFormat; -import dev.langchain4j.model.chat.request.ResponseFormatType; -import dev.langchain4j.model.chat.request.json.JsonSchema; -import dev.langchain4j.model.chat.response.ChatResponse; -import dev.langchain4j.model.output.TokenUsage; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkAdapter; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.jsonschema.JsonSchemaConverter; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverter; -import io.camunda.connector.agenticai.aiagent.memory.runtime.RuntimeMemory; -import io.camunda.connector.agenticai.aiagent.model.AgentContext; -import io.camunda.connector.agenticai.aiagent.model.AgentExecutionContext; -import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; -import io.camunda.connector.agenticai.aiagent.model.request.ResponseConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.JsonResponseFormatConfiguration; -import io.camunda.connector.agenticai.model.message.AssistantMessage; -import io.camunda.connector.api.error.ConnectorException; -import java.util.Optional; -import org.apache.commons.lang3.StringUtils; - -public class Langchain4JAiFrameworkAdapter - implements AiFrameworkAdapter { - - private final ChatModelFactory chatModelFactory; - private final ChatMessageConverter chatMessageConverter; - private final ToolSpecificationConverter toolSpecificationConverter; - private final JsonSchemaConverter jsonSchemaConverter; - - public Langchain4JAiFrameworkAdapter( - ChatModelFactory chatModelFactory, - ChatMessageConverter chatMessageConverter, - ToolSpecificationConverter toolSpecificationConverter, - JsonSchemaConverter jsonSchemaConverter) { - this.chatModelFactory = chatModelFactory; - this.chatMessageConverter = chatMessageConverter; - this.toolSpecificationConverter = toolSpecificationConverter; - this.jsonSchemaConverter = jsonSchemaConverter; - } - - @Override - public Langchain4JAiFrameworkChatResponse executeChatRequest( - AgentExecutionContext executionContext, - AgentContext agentContext, - RuntimeMemory runtimeMemory) { - final var messages = chatMessageConverter.map(runtimeMemory.filteredMessages()); - final var toolSpecifications = - toolSpecificationConverter.asToolSpecifications(agentContext.toolDefinitions()); - - final var chatRequestBuilder = - ChatRequest.builder().messages(messages).toolSpecifications(toolSpecifications); - configureResponseFormat(chatRequestBuilder, executionContext.response()); - - final ChatModel chatModel = chatModelFactory.createChatModel(executionContext.provider()); - final ChatResponse chatResponse = doChat(chatModel, chatRequestBuilder); - final AssistantMessage assistantMessage = chatMessageConverter.toAssistantMessage(chatResponse); - - final var updatedAgentContext = - agentContext.withMetrics( - agentContext - .metrics() - .incrementModelCalls(1) - .incrementTokenUsage(tokenUsage(chatResponse.tokenUsage()))); - - return new Langchain4JAiFrameworkChatResponse( - updatedAgentContext, assistantMessage, chatResponse); - } - - private void configureResponseFormat( - ChatRequest.Builder chatRequestBuilder, ResponseConfiguration responseConfiguration) { - final var responseFormat = createResponseFormat(responseConfiguration); - if (responseFormat != null) { - chatRequestBuilder.responseFormat(responseFormat); - } - } - - private ResponseFormat createResponseFormat(ResponseConfiguration responseConfiguration) { - // do not explicitely configure response format to TEXT as (depending on the model) this might - // lead to exceptions - if (responseConfiguration != null - && responseConfiguration.format() != null - && responseConfiguration.format() instanceof JsonResponseFormatConfiguration jsonFormat) { - final var builder = ResponseFormat.builder().type(ResponseFormatType.JSON); - if (jsonFormat.schema() != null) { - final var jsonSchema = - JsonSchema.builder() - .name( - Optional.ofNullable(jsonFormat.schemaName()) - .filter(StringUtils::isNotBlank) - .orElse("Response")) - .rootElement(jsonSchemaConverter.mapToSchema(jsonFormat.schema())) - .build(); - builder.jsonSchema(jsonSchema); - } - - return builder.build(); - } - - return null; - } - - private ChatResponse doChat(ChatModel chatModel, ChatRequest.Builder chatRequestBuilder) { - try { - return chatModel.chat(chatRequestBuilder.build()); - } catch (Exception e) { - final var message = - Optional.ofNullable(e.getMessage()) - .filter(StringUtils::isNotBlank) - .orElseGet(() -> e.getClass().getSimpleName()); - - throw new ConnectorException( - ERROR_CODE_FAILED_MODEL_CALL, "Model call failed: %s".formatted(message), e); - } - } - - private AgentMetrics.TokenUsage tokenUsage(TokenUsage tokenUsage) { - if (tokenUsage == null) { - return AgentMetrics.TokenUsage.empty(); - } - - return AgentMetrics.TokenUsage.builder() - .inputTokenCount(Optional.ofNullable(tokenUsage.inputTokenCount()).orElse(0)) - .outputTokenCount(Optional.ofNullable(tokenUsage.outputTokenCount()).orElse(0)) - .build(); - } -} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkChatResponse.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkChatResponse.java deleted file mode 100644 index b666672a843..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkChatResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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.agenticai.aiagent.framework.langchain4j; - -import dev.langchain4j.model.chat.response.ChatResponse; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkChatResponse; -import io.camunda.connector.agenticai.aiagent.model.AgentContext; -import io.camunda.connector.agenticai.model.message.AssistantMessage; - -public record Langchain4JAiFrameworkChatResponse( - AgentContext agentContext, AssistantMessage assistantMessage, ChatResponse rawChatResponse) - implements AiFrameworkChatResponse {} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApi.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApi.java new file mode 100644 index 00000000000..33dfac1c29b --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApi.java @@ -0,0 +1,131 @@ +/* + * 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.agenticai.aiagent.framework.langchain4j; + +import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL; + +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.jsonschema.JsonSchemaConverter; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverter; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.JsonResponseFormatConfiguration; +import io.camunda.connector.api.error.ConnectorException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; + +/** + * LangChain4j-backed {@link ChatModelApi} used for every wire-protocol family today. Wraps a + * pre-resolved {@link ChatModel}; one instance per chat invocation, produced by {@link + * Langchain4JChatModelApiFactory#create}. + * + *

Public so customers can compose it from their own {@link + * io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory ChatModelApiFactory} + * bean — e.g. to wire up a LangChain4j-supported provider we don't ship by passing in their own + * resolved {@link ChatModel} alongside the framework's converter beans. + */ +public class Langchain4JChatModelApi implements ChatModelApi { + + private final ChatModel chatModel; + private final ChatMessageConverter chatMessageConverter; + private final ToolSpecificationConverter toolSpecificationConverter; + private final JsonSchemaConverter jsonSchemaConverter; + + public Langchain4JChatModelApi( + ChatModel chatModel, + ChatMessageConverter chatMessageConverter, + ToolSpecificationConverter toolSpecificationConverter, + JsonSchemaConverter jsonSchemaConverter) { + this.chatModel = chatModel; + this.chatMessageConverter = chatMessageConverter; + this.toolSpecificationConverter = toolSpecificationConverter; + this.jsonSchemaConverter = jsonSchemaConverter; + } + + @Override + public ModelCapabilities capabilities() { + // Conservative defaults — the bridge is model-agnostic. Native implementations will resolve + // capabilities from the matrix. + return new ModelCapabilities( + List.of(Modality.TEXT), + List.of(Modality.TEXT), + List.of(Modality.TEXT), + false, + false, + false, + true, + null, + null); + } + + @Override + public CompletableFuture complete( + io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest request, + ChatOptions options, + ChatStreamListener listener) { + try { + final var l4jMessages = chatMessageConverter.map(request.messages()); + final var toolSpecifications = + toolSpecificationConverter.asToolSpecifications(request.toolDefinitions()); + + final var l4jRequestBuilder = + ChatRequest.builder().messages(l4jMessages).toolSpecifications(toolSpecifications); + + final var l4jResponseFormat = toL4jResponseFormat(request.responseFormat()); + if (l4jResponseFormat != null) { + l4jRequestBuilder.responseFormat(l4jResponseFormat); + } + + final var l4jResponse = chatModel.chat(l4jRequestBuilder.build()); + final var assistantMessage = chatMessageConverter.toAssistantMessage(l4jResponse); + + return CompletableFuture.completedFuture(new ChatResponse(assistantMessage)); + } catch (Exception e) { + final var message = + Optional.ofNullable(e.getMessage()) + .filter(StringUtils::isNotBlank) + .orElseGet(() -> e.getClass().getSimpleName()); + return CompletableFuture.failedFuture( + new ConnectorException( + ERROR_CODE_FAILED_MODEL_CALL, "Model call failed: %s".formatted(message), e)); + } + } + + private @Nullable ResponseFormat toL4jResponseFormat( + @Nullable ResponseFormatConfiguration responseFormat) { + // Do not explicitly configure response format to TEXT — depending on the model this can lead + // to exceptions. Leaving the format unset preserves the previous adapter's behaviour. + if (!(responseFormat instanceof JsonResponseFormatConfiguration json)) { + return null; + } + + final var builder = ResponseFormat.builder().type(ResponseFormatType.JSON); + + if (json.schema() != null) { + final var name = StringUtils.isNotBlank(json.schemaName()) ? json.schemaName() : "Response"; + builder.jsonSchema( + JsonSchema.builder() + .name(name) + .rootElement(jsonSchemaConverter.mapToSchema(json.schema())) + .build()); + } + + return builder.build(); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApiFactory.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApiFactory.java new file mode 100644 index 00000000000..8024c56c283 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApiFactory.java @@ -0,0 +1,75 @@ +/* + * 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.agenticai.aiagent.framework.langchain4j; + +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.jsonschema.JsonSchemaConverter; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.ChatModelProvider; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverter; +import io.camunda.connector.agenticai.aiagent.model.request.provider.ProviderConfiguration; + +/** + * LangChain4j bridge factory: produces a {@link Langchain4JChatModelApi} for one specific {@link + * ProviderConfiguration} subtype using the matching {@link ChatModelProvider} bean. One factory + * bean per discriminator — the provider type, configuration class, and {@link ChatModelProvider} + * are wired explicitly per bean in {@code AgenticAiLangchain4JFrameworkConfiguration}, so there is + * no {@code switch} on provider type inside the factory. + * + *

Public so customers can compose it from their own {@link ChatModelApiFactory} bean — e.g. to + * wire a LangChain4j-supported provider we don't ship by passing in their own {@link + * ChatModelProvider} alongside the framework's converter beans. + */ +public class Langchain4JChatModelApiFactory + implements ChatModelApiFactory { + + public static final String API_FAMILY = "langchain4j"; + + private final String providerType; + private final Class configurationType; + private final ChatModelProvider chatModelProvider; + private final ChatMessageConverter chatMessageConverter; + private final ToolSpecificationConverter toolSpecificationConverter; + private final JsonSchemaConverter jsonSchemaConverter; + + public Langchain4JChatModelApiFactory( + String providerType, + Class configurationType, + ChatModelProvider chatModelProvider, + ChatMessageConverter chatMessageConverter, + ToolSpecificationConverter toolSpecificationConverter, + JsonSchemaConverter jsonSchemaConverter) { + this.providerType = providerType; + this.configurationType = configurationType; + this.chatModelProvider = chatModelProvider; + this.chatMessageConverter = chatMessageConverter; + this.toolSpecificationConverter = toolSpecificationConverter; + this.jsonSchemaConverter = jsonSchemaConverter; + } + + @Override + public String providerType() { + return providerType; + } + + @Override + public String apiFamily() { + return API_FAMILY; + } + + @Override + public Class configurationType() { + return configurationType; + } + + @Override + public ChatModelApi create(C configuration) { + final var chatModel = chatModelProvider.createChatModel(configuration); + return new Langchain4JChatModelApi( + chatModel, chatMessageConverter, toolSpecificationConverter, jsonSchemaConverter); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JChatModelConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JChatModelConfiguration.java index 60660112813..8b7bbadccba 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JChatModelConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JChatModelConfiguration.java @@ -9,7 +9,6 @@ import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelFactory; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelFactoryImpl; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelHttpProxySupport; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.AnthropicChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.AzureOpenAiChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.BedrockChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.ChatModelProvider; @@ -17,11 +16,9 @@ import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.GoogleVertexAiChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.OpenAiChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.OpenAiCompatibleChatModelProvider; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.OpenAiDispatchingChatModelProvider; import io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration; import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsConfigurationProperties; import io.camunda.connector.agenticai.common.AgenticAiHttpProxySupport; @@ -44,20 +41,14 @@ public ChatModelHttpProxySupport langchain4JChatModelHttpProxySupport( @Bean @ConditionalOnMissingBean - public ChatModelProvider langchain4JAnthropicChatModelProvider( + public ChatModelProvider langchain4JOpenAiChatModelProvider( AgenticAiConnectorsConfigurationProperties config, ChatModelHttpProxySupport chatModelHttpProxySupport) { - return new AnthropicChatModelProvider(config.aiagent().chatModel(), chatModelHttpProxySupport); - } - - @Bean - @ConditionalOnMissingBean - public ChatModelProvider - langchain4JAzureOpenAiChatModelProvider( - AgenticAiConnectorsConfigurationProperties config, - ChatModelHttpProxySupport chatModelHttpProxySupport) { - return new AzureOpenAiChatModelProvider( - config.aiagent().chatModel(), chatModelHttpProxySupport); + final var chatModelProperties = config.aiagent().chatModel(); + return new OpenAiDispatchingChatModelProvider( + new OpenAiChatModelProvider(chatModelProperties, chatModelHttpProxySupport), + new AzureOpenAiChatModelProvider(chatModelProperties, chatModelHttpProxySupport), + new OpenAiCompatibleChatModelProvider(chatModelProperties, chatModelHttpProxySupport)); } @Bean @@ -70,29 +61,11 @@ public ChatModelProvider langchain4JBedrockChatMod @Bean @ConditionalOnMissingBean - public ChatModelProvider + public ChatModelProvider langchain4JGoogleVertexAiChatModelProvider() { return new GoogleVertexAiChatModelProvider(); } - @Bean - @ConditionalOnMissingBean - public ChatModelProvider langchain4JOpenAiChatModelProvider( - AgenticAiConnectorsConfigurationProperties config, - ChatModelHttpProxySupport chatModelHttpProxySupport) { - return new OpenAiChatModelProvider(config.aiagent().chatModel(), chatModelHttpProxySupport); - } - - @Bean - @ConditionalOnMissingBean - public ChatModelProvider - langchain4JOpenAiCompatibleChatModelProvider( - AgenticAiConnectorsConfigurationProperties config, - ChatModelHttpProxySupport chatModelHttpProxySupport) { - return new OpenAiCompatibleChatModelProvider( - config.aiagent().chatModel(), chatModelHttpProxySupport); - } - @Bean @ConditionalOnMissingBean public ChatModelProviderRegistry langchain4JChatModelProviderRegistry( diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JFrameworkConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JFrameworkConfiguration.java index 4dc0d605b32..ae50a91cc6a 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JFrameworkConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/configuration/AgenticAiLangchain4JFrameworkConfiguration.java @@ -7,19 +7,23 @@ package io.camunda.connector.agenticai.aiagent.framework.langchain4j.configuration; import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatMessageConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatMessageConverterImpl; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelFactory; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ContentConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ContentConverterImpl; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.Langchain4JAiFrameworkAdapter; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.Langchain4JChatModelApiFactory; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentConverterImpl; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.jsonschema.JsonSchemaConverter; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.ChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolCallConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolCallConverterImpl; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverterImpl; +import io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; import io.camunda.connector.runtime.annotation.ConnectorsObjectMapper; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -52,8 +56,8 @@ public ContentConverter langchain4JContentConverter( @Bean @ConditionalOnMissingBean public ToolCallConverter langchain4JToolCallConverter( - @ConnectorsObjectMapper ObjectMapper objectMapper, ContentConverter contentConverter) { - return new ToolCallConverterImpl(objectMapper, contentConverter); + @ConnectorsObjectMapper ObjectMapper objectMapper) { + return new ToolCallConverterImpl(objectMapper); } @Bean @@ -73,20 +77,57 @@ public ToolSpecificationConverter langchain4JToolSpecificationConverter( @Bean @ConditionalOnMissingBean public ChatMessageConverter langchain4JChatMessageConverter( - ContentConverter contentConverter, - ToolCallConverter toolCallConverter, - @ConnectorsObjectMapper ObjectMapper objectMapper) { - return new ChatMessageConverterImpl(contentConverter, toolCallConverter, objectMapper); + ContentConverter contentConverter, ToolCallConverter toolCallConverter) { + return new ChatMessageConverterImpl(contentConverter, toolCallConverter); } @Bean - @ConditionalOnMissingBean - public Langchain4JAiFrameworkAdapter langchain4JAiFrameworkAdapter( - ChatModelFactory chatModelFactory, + @ConditionalOnMissingBean(name = "langchain4JBedrockChatModelApiFactory") + public ChatModelApiFactory langchain4JBedrockChatModelApiFactory( + ChatModelProvider provider, + ChatMessageConverter chatMessageConverter, + ToolSpecificationConverter toolSpecificationConverter, + JsonSchemaConverter jsonSchemaConverter) { + return new Langchain4JChatModelApiFactory<>( + BedrockProviderConfiguration.BEDROCK_ID, + BedrockProviderConfiguration.class, + provider, + chatMessageConverter, + toolSpecificationConverter, + jsonSchemaConverter); + } + + @Bean + @ConditionalOnMissingBean( + name = {"langchain4JAzureOpenAiChatModelApiFactory", "langchain4JOpenAiChatModelApiFactory"}) + public ChatModelApiFactory langchain4JAzureOpenAiChatModelApiFactory( + ChatModelProvider provider, ChatMessageConverter chatMessageConverter, ToolSpecificationConverter toolSpecificationConverter, JsonSchemaConverter jsonSchemaConverter) { - return new Langchain4JAiFrameworkAdapter( - chatModelFactory, chatMessageConverter, toolSpecificationConverter, jsonSchemaConverter); + return new Langchain4JChatModelApiFactory<>( + OpenAiProviderConfiguration.OPENAI_ID, + OpenAiProviderConfiguration.class, + provider, + chatMessageConverter, + toolSpecificationConverter, + jsonSchemaConverter); + } + + @Bean + @ConditionalOnMissingBean(name = "langchain4JGoogleVertexAiChatModelApiFactory") + public ChatModelApiFactory + langchain4JGoogleVertexAiChatModelApiFactory( + ChatModelProvider provider, + ChatMessageConverter chatMessageConverter, + ToolSpecificationConverter toolSpecificationConverter, + JsonSchemaConverter jsonSchemaConverter) { + return new Langchain4JChatModelApiFactory<>( + GoogleGenAiProviderConfiguration.GOOGLE_GENAI_ID, + GoogleGenAiProviderConfiguration.class, + provider, + chatMessageConverter, + toolSpecificationConverter, + jsonSchemaConverter); } } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentModule.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentModule.java deleted file mode 100644 index 32f66f714f1..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentModule.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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.agenticai.aiagent.framework.langchain4j.document; - -import com.fasterxml.jackson.databind.module.SimpleModule; -import io.camunda.connector.api.document.Document; - -public class DocumentToContentModule extends SimpleModule { - - private final DocumentToContentConverter documentConverter; - - public DocumentToContentModule(DocumentToContentConverter documentConverter) { - super(); - this.documentConverter = documentConverter; - } - - @Override - public void setupModule(SetupContext context) { - addSerializer(Document.class, new DocumentToContentSerializer(documentConverter)); - super.setupModule(context); - } -} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentResponseModel.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentResponseModel.java deleted file mode 100644 index e561ebf80d4..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentResponseModel.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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.agenticai.aiagent.framework.langchain4j.document; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -/** - * Adheres to the Claude schema for content blocks in user messages. We might need to revisit the - * structure depending on the supported models. - */ -@JsonInclude(JsonInclude.Include.NON_EMPTY) -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public record DocumentToContentResponseModel(String type, String mediaType, String data) {} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentSerializer.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentSerializer.java deleted file mode 100644 index b8c709d7144..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentSerializer.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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.agenticai.aiagent.framework.langchain4j.document; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import dev.langchain4j.data.message.Content; -import dev.langchain4j.data.message.ImageContent; -import dev.langchain4j.data.message.PdfFileContent; -import dev.langchain4j.data.message.TextContent; -import io.camunda.connector.api.document.Document; -import io.camunda.connector.api.document.DocumentMetadata; -import io.camunda.connector.api.document.DocumentReference.CamundaDocumentReference; -import java.io.IOException; - -/** - * Serializes a {@link Document} to a JSON structure containing the document content. The structure - * is similar to what Claude is using for document support. Depending on the content type, the - * document content is included as plain text or as base64-encoded data. - * - *

- * {
- *   "type": "text",
- *   "media_type": "text/plain",
- *   "data": "..."
- * }
- * 
- * - *

or - * - *

- * {
- *   "type": "base64",
- *   "media_type": "application/pdf",
- *   "data": "...base64..."
- * }
- * 
- * - *

Note: To make the behavior consistent, this serializer first converts the document to a {@link - * Content} block, so the supported formats are limited to the same set as the documents which can - * be provided as the user prompt. - */ -public class DocumentToContentSerializer extends JsonSerializer { - - private static final String TYPE_TEXT = "text"; - private static final String TYPE_BASE64 = "base64"; - - private final DocumentToContentConverter contentConverter; - - public DocumentToContentSerializer(DocumentToContentConverter contentConverter) { - this.contentConverter = contentConverter; - } - - @Override - public void serialize( - Document document, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) - throws IOException { - - final var reference = document.reference(); - if (!(reference instanceof CamundaDocumentReference camundaReference)) { - throw new IllegalArgumentException("Unsupported document reference type: " + reference); - } - - final var contentBlock = contentConverter.convert(document); - final var response = - convertContentBlock(camundaReference, camundaReference.getMetadata(), contentBlock); - - jsonGenerator.writeObject(response); - } - - private DocumentToContentResponseModel convertContentBlock( - CamundaDocumentReference reference, DocumentMetadata metadata, Content contentBlock) { - - return switch (contentBlock) { - case TextContent tc -> - new DocumentToContentResponseModel(TYPE_TEXT, metadata.getContentType(), tc.text()); - - case PdfFileContent tf -> - new DocumentToContentResponseModel( - TYPE_BASE64, metadata.getContentType(), tf.pdfFile().base64Data()); - - case ImageContent tf -> - new DocumentToContentResponseModel( - TYPE_BASE64, metadata.getContentType(), tf.image().base64Data()); - - default -> - throw new IllegalArgumentException( - "Unsupported content block type '%s' for document with reference '%s'" - .formatted(contentBlock.getClass().getSimpleName(), reference.toString())); - }; - } -} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AnthropicChatModelProvider.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AnthropicChatModelProvider.java index 85e0897618d..4faf37d032c 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AnthropicChatModelProvider.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AnthropicChatModelProvider.java @@ -12,6 +12,7 @@ import dev.langchain4j.model.chat.ChatModel; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelHttpProxySupport; import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication.AnthropicApiKeyAuthentication; import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsConfigurationProperties.ChatModelProperties; import java.util.Optional; import org.slf4j.Logger; @@ -40,9 +41,14 @@ public String type() { public ChatModel createChatModel(AnthropicProviderConfiguration anthropic) { final var connection = anthropic.anthropic(); + if (!(connection.authentication() instanceof AnthropicApiKeyAuthentication apiKeyAuth)) { + throw new IllegalArgumentException( + "Unsupported authentication type for Anthropic LangChain4j provider: " + + connection.authentication().getClass().getSimpleName()); + } final var builder = AnthropicChatModel.builder() - .apiKey(connection.authentication().apiKey()) + .apiKey(apiKeyAuth.apiKey()) .modelName(connection.model().model()) .timeout( deriveTimeoutSetting("Anthropic model call", config, connection.timeouts(), LOGGER)) diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AzureOpenAiChatModelProvider.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AzureOpenAiChatModelProvider.java index aaa81c2dfeb..c49ea9d420f 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AzureOpenAiChatModelProvider.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/AzureOpenAiChatModelProvider.java @@ -12,17 +12,23 @@ import dev.langchain4j.model.azure.AzureOpenAiChatModel; import dev.langchain4j.model.chat.ChatModel; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelHttpProxySupport; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration.AzureAuthentication.AzureApiKeyAuthentication; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration.AzureAuthentication.AzureClientCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiClientCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiBackend; import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsConfigurationProperties.ChatModelProperties; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * LangChain4j bridge provider for the Azure AI Foundry (FOUNDRY) backend of the unified {@link + * OpenAiProviderConfiguration}. Handles OpenAiProviderConfiguration instances where {@code backend + * == FOUNDRY} by building an {@link AzureOpenAiChatModel}. + */ public class AzureOpenAiChatModelProvider - implements ChatModelProvider { + implements ChatModelProvider { private static final Logger LOGGER = LoggerFactory.getLogger(AzureOpenAiChatModelProvider.class); @@ -37,16 +43,21 @@ public AzureOpenAiChatModelProvider( @Override public String type() { - return AzureOpenAiProviderConfiguration.AZURE_OPENAI_ID; + return OpenAiProviderConfiguration.OPENAI_ID; } @Override - public ChatModel createChatModel(AzureOpenAiProviderConfiguration azureOpenAi) { - final var connection = azureOpenAi.azureOpenAi(); + public ChatModel createChatModel(OpenAiProviderConfiguration openAi) { + if (openAi.openai().backend() != OpenAiBackend.FOUNDRY) { + throw new IllegalArgumentException( + "AzureOpenAiChatModelProvider only supports OpenAiBackend.FOUNDRY, got: " + + openAi.openai().backend()); + } + final var connection = openAi.openai(); final var builder = AzureOpenAiChatModel.builder() .endpoint(connection.endpoint()) - .deploymentName(connection.model().deploymentName()) + .deploymentName(connection.model().model()) .timeout( deriveTimeoutSetting( "Azure OpenAI model call", config, connection.timeouts(), LOGGER)); @@ -54,9 +65,8 @@ public ChatModel createChatModel(AzureOpenAiProviderConfiguration azureOpenAi) { proxySupport.createAzureProxyOptions(connection.endpoint()).ifPresent(builder::proxyOptions); switch (connection.authentication()) { - case AzureApiKeyAuthentication azureApiKeyAuthentication -> - builder.apiKey(azureApiKeyAuthentication.apiKey()); - case AzureClientCredentialsAuthentication auth -> { + case OpenAiApiKeyAuthentication apiKeyAuth -> builder.apiKey(apiKeyAuth.apiKey()); + case OpenAiClientCredentialsAuthentication auth -> { ClientSecretCredentialBuilder clientSecretCredentialBuilder = new ClientSecretCredentialBuilder() .clientId(auth.clientId()) @@ -71,7 +81,7 @@ public ChatModel createChatModel(AzureOpenAiProviderConfiguration azureOpenAi) { final var modelParameters = connection.model().parameters(); if (modelParameters != null) { - Optional.ofNullable(modelParameters.maxTokens()).ifPresent(builder::maxTokens); + Optional.ofNullable(modelParameters.maxCompletionTokens()).ifPresent(builder::maxTokens); Optional.ofNullable(modelParameters.temperature()).ifPresent(builder::temperature); Optional.ofNullable(modelParameters.topP()).ifPresent(builder::topP); } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/GoogleVertexAiChatModelProvider.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/GoogleVertexAiChatModelProvider.java index 0c7ddc3a31c..1052c678c6d 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/GoogleVertexAiChatModelProvider.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/GoogleVertexAiChatModelProvider.java @@ -9,8 +9,8 @@ import com.google.auth.oauth2.ServiceAccountCredentials; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GoogleVertexAiAuthentication.ServiceAccountCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GoogleGenAiAuthentication.ServiceAccountCredentialsAuthentication; import io.camunda.connector.api.error.ConnectorInputException; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -20,19 +20,19 @@ import org.slf4j.LoggerFactory; public class GoogleVertexAiChatModelProvider - implements ChatModelProvider { + implements ChatModelProvider { private static final Logger LOGGER = LoggerFactory.getLogger(GoogleVertexAiChatModelProvider.class); @Override public String type() { - return GoogleVertexAiProviderConfiguration.GOOGLE_VERTEX_AI_ID; + return GoogleGenAiProviderConfiguration.GOOGLE_GENAI_ID; } @Override - public ChatModel createChatModel(GoogleVertexAiProviderConfiguration vertexAi) { - final var connection = vertexAi.googleVertexAi(); + public ChatModel createChatModel(GoogleGenAiProviderConfiguration vertexAi) { + final var connection = vertexAi.googleGenAi(); final var builder = VertexAiGeminiChatModel.builder() .project(connection.projectId()) diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiChatModelProvider.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiChatModelProvider.java index 3d6511faaca..bb4c09e7177 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiChatModelProvider.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiChatModelProvider.java @@ -13,6 +13,7 @@ import dev.langchain4j.model.openai.OpenAiChatRequestParameters; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelHttpProxySupport; import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiApiKeyAuthentication; import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsConfigurationProperties.ChatModelProperties; import java.util.Optional; import org.slf4j.Logger; @@ -42,15 +43,16 @@ public ChatModel createChatModel(OpenAiProviderConfiguration openai) { final var builder = OpenAiChatModel.builder() - .apiKey(connection.authentication().apiKey()) .modelName(connection.model().model()) .timeout( deriveTimeoutSetting("OpenAI model call", config, connection.timeouts(), LOGGER)) .httpClientBuilder(proxySupport.createJdkHttpClientBuilder()); - Optional.ofNullable(connection.authentication().organizationId()) - .ifPresent(builder::organizationId); - Optional.ofNullable(connection.authentication().projectId()).ifPresent(builder::projectId); + if (connection.authentication() instanceof OpenAiApiKeyAuthentication apiKeyAuth) { + builder.apiKey(apiKeyAuth.apiKey()); + Optional.ofNullable(apiKeyAuth.organizationId()).ifPresent(builder::organizationId); + Optional.ofNullable(apiKeyAuth.projectId()).ifPresent(builder::projectId); + } final var modelParameters = connection.model().parameters(); if (modelParameters != null) { diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiCompatibleChatModelProvider.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiCompatibleChatModelProvider.java index a3227e0e0c1..78a9811729f 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiCompatibleChatModelProvider.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiCompatibleChatModelProvider.java @@ -12,16 +12,23 @@ import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.openai.OpenAiChatRequestParameters; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelHttpProxySupport; -import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration.OpenAiCompatibleAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiBackend; import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsConfigurationProperties.ChatModelProperties; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * LangChain4j bridge provider for the CUSTOM backend of the unified {@link + * OpenAiProviderConfiguration}. Handles OpenAiProviderConfiguration instances where {@code backend + * == CUSTOM} by building an OpenAI-compatible chat model with a custom base URL, optional API key, + * and custom headers / query params. + */ public class OpenAiCompatibleChatModelProvider - implements ChatModelProvider { + implements ChatModelProvider { private static final Logger LOGGER = LoggerFactory.getLogger(OpenAiCompatibleChatModelProvider.class); @@ -37,12 +44,17 @@ public OpenAiCompatibleChatModelProvider( @Override public String type() { - return OpenAiCompatibleProviderConfiguration.OPENAI_COMPATIBLE_ID; + return OpenAiProviderConfiguration.OPENAI_ID; } @Override - public ChatModel createChatModel(OpenAiCompatibleProviderConfiguration openaiCompatible) { - final var connection = openaiCompatible.openaiCompatible(); + public ChatModel createChatModel(OpenAiProviderConfiguration openAi) { + if (openAi.openai().backend() != OpenAiBackend.CUSTOM) { + throw new IllegalArgumentException( + "OpenAiCompatibleChatModelProvider only supports OpenAiBackend.CUSTOM, got: " + + openAi.openai().backend()); + } + final var connection = openAi.openai(); final var builder = OpenAiChatModel.builder() @@ -54,7 +66,8 @@ public ChatModel createChatModel(OpenAiCompatibleProviderConfiguration openaiCom .httpClientBuilder(proxySupport.createJdkHttpClientBuilder()); Optional.ofNullable(connection.authentication()) - .map(OpenAiCompatibleAuthentication::apiKey) + .filter(auth -> auth instanceof OpenAiApiKeyAuthentication) + .map(auth -> ((OpenAiApiKeyAuthentication) auth).apiKey()) .filter(StringUtils::isNotBlank) .ifPresent( apiKey -> { diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiDispatchingChatModelProvider.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiDispatchingChatModelProvider.java new file mode 100644 index 00000000000..46b15a1e1d4 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/provider/OpenAiDispatchingChatModelProvider.java @@ -0,0 +1,57 @@ +/* + * 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.agenticai.aiagent.framework.langchain4j.provider; + +import dev.langchain4j.model.chat.ChatModel; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiBackend; + +/** + * Dispatching {@link ChatModelProvider} for the unified {@link OpenAiProviderConfiguration}. Routes + * each request to the appropriate backend-specific provider: + * + *

    + *
  • {@link OpenAiBackend#OPENAI} → {@link OpenAiChatModelProvider} + *
  • {@link OpenAiBackend#FOUNDRY} → {@link AzureOpenAiChatModelProvider} + *
  • {@link OpenAiBackend#CUSTOM} → {@link OpenAiCompatibleChatModelProvider} + *
+ * + *

This bean is registered as the single {@code ChatModelProvider} + * in the Spring context and acts as the LangChain4j fallback when the native OpenAI SDK factory is + * not on the classpath (or is excluded). + */ +public class OpenAiDispatchingChatModelProvider + implements ChatModelProvider { + + private final OpenAiChatModelProvider openAiProvider; + private final AzureOpenAiChatModelProvider azureOpenAiProvider; + private final OpenAiCompatibleChatModelProvider openAiCompatibleProvider; + + public OpenAiDispatchingChatModelProvider( + OpenAiChatModelProvider openAiProvider, + AzureOpenAiChatModelProvider azureOpenAiProvider, + OpenAiCompatibleChatModelProvider openAiCompatibleProvider) { + this.openAiProvider = openAiProvider; + this.azureOpenAiProvider = azureOpenAiProvider; + this.openAiCompatibleProvider = openAiCompatibleProvider; + } + + @Override + public String type() { + return OpenAiProviderConfiguration.OPENAI_ID; + } + + @Override + public ChatModel createChatModel(OpenAiProviderConfiguration providerConfiguration) { + final var backend = providerConfiguration.openai().backend(); + return switch (backend) { + case OPENAI -> openAiProvider.createChatModel(providerConfiguration); + case FOUNDRY -> azureOpenAiProvider.createChatModel(providerConfiguration); + case CUSTOM -> openAiCompatibleProvider.createChatModel(providerConfiguration); + }; + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/tool/ToolCallConverterImpl.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/tool/ToolCallConverterImpl.java index 1761ddc0ec4..566239953d0 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/tool/ToolCallConverterImpl.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/tool/ToolCallConverterImpl.java @@ -14,7 +14,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.data.message.ToolExecutionResultMessage; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ContentConverter; import io.camunda.connector.agenticai.model.tool.ToolCall; import io.camunda.connector.agenticai.model.tool.ToolCallResult; import io.camunda.connector.api.error.ConnectorException; @@ -27,11 +26,9 @@ public class ToolCallConverterImpl implements ToolCallConverter { private final ObjectMapper objectMapper; - private final ContentConverter contentConverter; - public ToolCallConverterImpl(ObjectMapper objectMapper, ContentConverter contentConverter) { + public ToolCallConverterImpl(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - this.contentConverter = contentConverter; } @Override @@ -78,8 +75,8 @@ private ToolCall asToolCall(String id, String name, String inputJson) { /** * Converts the result of a tool call to a {@link ToolExecutionResultMessage}. * - *

If the result is not a string, it will be serialized to a JSON string, using the {@link - * ContentConverter} to serialize document contents. + *

If the result is not a string, it will be serialized to a JSON string using the connectors + * ObjectMapper. Document instances in the content tree are serialized as document references. */ @Override public ToolExecutionResultMessage asToolExecutionResultMessage(ToolCallResult toolCallResult) { @@ -96,7 +93,13 @@ public ToolExecutionResultMessage asToolExecutionResultMessage(ToolCallResult to private String contentAsString(String toolName, Object result) { try { - return contentConverter.convertToString(result); + if (result == null) { + return null; + } + if (result instanceof String s) { + return s; + } + return objectMapper.writeValueAsString(result); } catch (JsonProcessingException e) { throw new ConnectorException( "Failed to convert result of tool call '%s' to string: %s" diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/multimodal/DocumentModality.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/multimodal/DocumentModality.java new file mode 100644 index 00000000000..d82fbd1ff9c --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/multimodal/DocumentModality.java @@ -0,0 +1,98 @@ +/* + * 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.agenticai.aiagent.framework.multimodal; + +import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL; + +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentMetadata; +import io.camunda.connector.api.error.ConnectorException; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +/** + * Maps a Camunda {@link Document}'s declared MIME type to a {@link Modality}. + * + *

Modality vocabulary matches the capability matrix; the routing strategy uses this to decide + * whether a document fits the resolved model's modality slot, and the per-impl emitters use it to + * dispatch construction of provider-native content blocks. + * + *

Coverage parity with the LangChain4j path ({@code DocumentToContentConverterImpl}): {@code + * text/*}, {@code application/json}, {@code application/xml}, {@code application/yaml} → {@link + * Modality#TEXT}; the four common image MIME types → {@link Modality#IMAGE}; {@code + * application/pdf} → {@link Modality#DOCUMENT}. Audio + video MIME types map for completeness but + * no emitter consumes them yet (Phase G+). + */ +public final class DocumentModality { + + private static final Set TEXT_MIME_TYPES = + Set.of("application/json", "application/xml", "application/yaml"); + + private static final Set IMAGE_MIME_TYPES = + Set.of("image/jpeg", "image/png", "image/gif", "image/webp"); + + private DocumentModality() {} + + /** + * Resolves the modality of a document, throwing a {@link ConnectorException} when the MIME type + * is missing or not part of the supported vocabulary. Mirrors the L4J converter's fail-loud + * semantics on unsupported types. + */ + public static Modality of(Document document) { + final var mimeType = mimeTypeOf(document); + return classify(mimeType) + .orElseThrow( + () -> + new ConnectorException( + ERROR_CODE_FAILED_MODEL_CALL, + "Unsupported content type '%s' for document with reference '%s'" + .formatted(mimeType, document.reference()))); + } + + /** + * Pure MIME → modality mapping. Returns empty for unsupported types so the caller can decide + * whether to throw, fall back, or skip. + */ + public static Optional classify(String mimeType) { + if (StringUtils.isBlank(mimeType)) { + return Optional.empty(); + } + final var normalised = mimeType.toLowerCase(Locale.ROOT).trim(); + final var bareType = stripParameters(normalised); + + if (bareType.startsWith("text/") || TEXT_MIME_TYPES.contains(bareType)) { + return Optional.of(Modality.TEXT); + } + if (IMAGE_MIME_TYPES.contains(bareType)) { + return Optional.of(Modality.IMAGE); + } + if ("application/pdf".equals(bareType)) { + return Optional.of(Modality.DOCUMENT); + } + if (bareType.startsWith("audio/")) { + return Optional.of(Modality.AUDIO); + } + if (bareType.startsWith("video/")) { + return Optional.of(Modality.VIDEO); + } + return Optional.empty(); + } + + private static String mimeTypeOf(Document document) { + return Optional.ofNullable(document.metadata()) + .map(DocumentMetadata::getContentType) + .orElse(null); + } + + private static String stripParameters(String mimeType) { + final var semicolon = mimeType.indexOf(';'); + return semicolon >= 0 ? mimeType.substring(0, semicolon).trim() : mimeType; + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatCompletionsChatModelApi.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatCompletionsChatModelApi.java new file mode 100644 index 00000000000..861695e3b59 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatCompletionsChatModelApi.java @@ -0,0 +1,422 @@ +/* + * 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.agenticai.aiagent.framework.openai; + +import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openai.client.OpenAIClient; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam; +import com.openai.models.chat.completions.ChatCompletionContentPart; +import com.openai.models.chat.completions.ChatCompletionContentPartImage; +import com.openai.models.chat.completions.ChatCompletionContentPartText; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall; +import com.openai.models.chat.completions.ChatCompletionMessageToolCall; +import com.openai.models.chat.completions.ChatCompletionToolMessageParam; +import com.openai.models.completions.CompletionUsage; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.content.ContentTextSerializer; +import io.camunda.connector.agenticai.aiagent.framework.multimodal.DocumentModality; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.agenticai.model.message.AssistantMessage; +import io.camunda.connector.agenticai.model.message.StopReason; +import io.camunda.connector.agenticai.model.message.SystemMessage; +import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; +import io.camunda.connector.agenticai.model.message.UserMessage; +import io.camunda.connector.agenticai.model.message.content.Content; +import io.camunda.connector.agenticai.model.message.content.DocumentContent; +import io.camunda.connector.agenticai.model.message.content.TextContent; +import io.camunda.connector.agenticai.model.tool.ToolCall; +import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.error.ConnectorException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; + +/** + * Native {@link ChatModelApi} for the OpenAI Chat Completions endpoint, driving the {@code + * openai-java} SDK's blocking {@code chat().completions().create(...)} call. + * + *

Phase C scope (text-only): user / assistant / tool-result content is restricted to text; + * multimodal content blocks, reasoning round-tripping (encrypted reasoning items don't apply to + * Chat Completions), and prompt caching are deferred to Phase E. {@link + * ChatOptions#cacheRetention()} is accepted but ignored. Streaming is not yet wired in either — + * {@link ChatStreamListener} is accepted but no events are emitted. + * + *

Used by both the {@code openai} and {@code openaiCompatible} discriminators (the OkHttp {@link + * OpenAIClient} differs only in baseUrl / auth construction, which the factory handles). + */ +public class OpenAiChatCompletionsChatModelApi implements ChatModelApi { + + private static final TypeReference> MAP_TYPE_REF = new TypeReference<>() {}; + + private final OpenAIClient client; + private final String model; + private final ObjectMapper objectMapper; + private final ModelCapabilities capabilities; + @Nullable private final Long configuredMaxCompletionTokens; + @Nullable private final Double temperature; + @Nullable private final Double topP; + + public OpenAiChatCompletionsChatModelApi( + OpenAIClient client, + String model, + ObjectMapper objectMapper, + ModelCapabilities capabilities, + @Nullable Long configuredMaxCompletionTokens, + @Nullable Double temperature, + @Nullable Double topP) { + this.client = Objects.requireNonNull(client, "client"); + this.model = Objects.requireNonNull(model, "model"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + this.capabilities = Objects.requireNonNull(capabilities, "capabilities"); + this.configuredMaxCompletionTokens = configuredMaxCompletionTokens; + this.temperature = temperature; + this.topP = topP; + } + + @Override + public ModelCapabilities capabilities() { + return capabilities; + } + + @Override + public CompletableFuture complete( + ChatRequest request, ChatOptions options, ChatStreamListener listener) { + try { + final var params = buildParams(request, options); + final var completion = client.chat().completions().create(params); + return CompletableFuture.completedFuture(new ChatResponse(toAssistantMessage(completion))); + } catch (RuntimeException e) { + return CompletableFuture.failedFuture(wrapModelCallFailure(e)); + } + } + + private ChatCompletionCreateParams buildParams(ChatRequest request, ChatOptions options) { + final var builder = ChatCompletionCreateParams.builder().model(model); + + final var maxTokens = resolveMaxCompletionTokens(options); + if (maxTokens != null) { + builder.maxCompletionTokens(maxTokens); + } + Optional.ofNullable(temperature).ifPresent(builder::temperature); + Optional.ofNullable(topP).ifPresent(builder::topP); + + final var messages = request.messages(); + if (messages != null) { + for (var message : messages) { + switch (message) { + case SystemMessage system -> builder.addSystemMessage(systemPrompt(system)); + case UserMessage user -> addUserMessage(builder, user); + case AssistantMessage assistant -> builder.addMessage(toAssistantParam(assistant)); + case ToolCallResultMessage toolResults -> addToolResultMessages(builder, toolResults); + default -> + throw new IllegalArgumentException( + "Unsupported message type: " + message.getClass().getSimpleName()); + } + } + } + + final var toolDefinitions = request.toolDefinitions(); + if (toolDefinitions != null && !toolDefinitions.isEmpty()) { + builder.tools(OpenAiToolConverter.toTools(toolDefinitions)); + } + + return builder.build(); + } + + @Nullable + private Long resolveMaxCompletionTokens(ChatOptions options) { + if (options != null && options.maxOutputTokens() != null) { + return options.maxOutputTokens().longValue(); + } + return configuredMaxCompletionTokens; + } + + private String systemPrompt(SystemMessage system) { + return extractText(system.content()); + } + + /** + * Routes a {@link UserMessage} onto either the legacy text-only {@code addUserMessage(String)} + * path or the multimodal {@code addUserMessageOfArrayOfContentParts(...)} path. The latter is + * used when the message contains at least one {@link DocumentContent} (image / document, + * validated upstream by {@code ToolCallResultStrategy}). + */ + private void addUserMessage(ChatCompletionCreateParams.Builder builder, UserMessage user) { + final var content = user.content(); + if (!hasMultimodalContent(content)) { + builder.addUserMessage(extractText(content)); + return; + } + final var parts = new ArrayList(); + for (var c : content) { + if (c instanceof DocumentContent doc) { + parts.add(documentPart(doc.document())); + } else { + parts.add( + ChatCompletionContentPart.ofText( + ChatCompletionContentPartText.builder() + .text(ContentTextSerializer.toText(c, objectMapper)) + .build())); + } + } + builder.addUserMessageOfArrayOfContentParts(parts); + } + + private static boolean hasMultimodalContent(List content) { + if (content == null) { + return false; + } + for (var c : content) { + if (c instanceof DocumentContent) { + return true; + } + } + return false; + } + + private static ChatCompletionContentPart documentPart(Document document) { + final var modality = DocumentModality.of(document); + return switch (modality) { + case IMAGE -> + ChatCompletionContentPart.ofImageUrl( + ChatCompletionContentPartImage.builder() + .imageUrl( + ChatCompletionContentPartImage.ImageUrl.builder() + .url(toDataUrl(document)) + .detail(ChatCompletionContentPartImage.ImageUrl.Detail.AUTO) + .build()) + .build()); + case DOCUMENT -> + ChatCompletionContentPart.ofFile( + ChatCompletionContentPart.File.builder() + .file( + ChatCompletionContentPart.File.FileObject.builder() + .fileData(toDataUrl(document)) + .filename(safeFilename(document)) + .build()) + .build()); + default -> + throw new IllegalArgumentException( + "Document modality " + + modality + + " is not supported in OpenAI Chat Completions user-message content (only " + + "image + document emit natively); the strategy should have rejected this " + + "user-message document."); + }; + } + + private static String toDataUrl(Document document) { + final var mimeType = document.metadata().getContentType(); + return "data:" + mimeType + ";base64," + document.asBase64(); + } + + private static String safeFilename(Document document) { + final var name = document.metadata() != null ? document.metadata().getFileName() : null; + return StringUtils.isNotBlank(name) ? name : "document"; + } + + private String extractText(List content) { + if (content == null || content.isEmpty()) { + return ""; + } + final var sb = new StringBuilder(); + for (var c : content) { + sb.append(ContentTextSerializer.toText(c, objectMapper)); + } + return sb.toString(); + } + + private ChatCompletionAssistantMessageParam toAssistantParam(AssistantMessage message) { + final var builder = ChatCompletionAssistantMessageParam.builder(); + final var text = message.content() != null ? extractText(message.content()) : ""; + if (StringUtils.isNotEmpty(text)) { + builder.content(text); + } + if (message.toolCalls() != null) { + for (var call : message.toolCalls()) { + builder.addToolCall(toFunctionToolCall(call)); + } + } + return builder.build(); + } + + private ChatCompletionMessageToolCall toFunctionToolCall(ToolCall call) { + final var args = call.arguments() != null ? toJsonString(call.arguments()) : "{}"; + return ChatCompletionMessageToolCall.ofFunction( + ChatCompletionMessageFunctionToolCall.builder() + .id(call.id()) + .function( + ChatCompletionMessageFunctionToolCall.Function.builder() + .name(call.name()) + .arguments(args) + .build()) + .build()); + } + + private void addToolResultMessages( + ChatCompletionCreateParams.Builder builder, ToolCallResultMessage message) { + for (var result : message.results()) { + builder.addMessage(toToolResultParam(result)); + } + } + + private ChatCompletionToolMessageParam toToolResultParam(ToolCallResult result) { + final var b = ChatCompletionToolMessageParam.builder(); + if (result.id() != null) { + b.toolCallId(result.id()); + } + final var content = result.content(); + if (content == null) { + b.content(ToolCallResult.CONTENT_NO_RESULT); + } else if (content instanceof String s) { + b.content(s); + } else { + b.content(toJsonString(content)); + } + return b.build(); + } + + private String toJsonString(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize tool argument value", e); + } + } + + private AssistantMessage toAssistantMessage(ChatCompletion completion) { + if (completion.choices().isEmpty()) { + throw new IllegalStateException("OpenAI Chat Completions returned no choices"); + } + + final var choice = completion.choices().getFirst(); + final var message = choice.message(); + final var builder = AssistantMessage.builder(); + builder.metadata(Map.of("timestamp", ZonedDateTime.now())); + + if (StringUtils.isNotBlank(completion.id())) { + builder.messageId(completion.id()); + } + if (StringUtils.isNotBlank(completion.model())) { + builder.modelId(completion.model()); + } + + final var content = new ArrayList(); + message + .content() + .filter(StringUtils::isNotBlank) + .map(TextContent::textContent) + .ifPresent(content::add); + if (!content.isEmpty()) { + builder.content(content); + } + + final var toolCalls = new ArrayList(); + message + .toolCalls() + .ifPresent( + calls -> { + for (var call : calls) { + if (call.isFunction()) { + toolCalls.add(toToolCall(call.asFunction())); + } + } + }); + builder.toolCalls(toolCalls); + + builder.stopReason(toStopReason(choice.finishReason())); + + completion + .usage() + .map(OpenAiChatCompletionsChatModelApi::toTokenUsage) + .ifPresent(builder::usage); + + return builder.build(); + } + + private ToolCall toToolCall(ChatCompletionMessageFunctionToolCall functionCall) { + final var function = functionCall.function(); + final var arguments = parseArguments(function.arguments()); + return ToolCall.builder() + .id(functionCall.id()) + .name(function.name()) + .arguments(arguments) + .build(); + } + + private Map parseArguments(String json) { + if (json == null || json.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return objectMapper.readValue(json, MAP_TYPE_REF); + } catch (Exception e) { + throw new IllegalStateException("Failed to parse tool call arguments JSON", e); + } + } + + private static StopReason toStopReason(ChatCompletion.Choice.FinishReason finishReason) { + if (finishReason == null) { + return null; + } + final var known = finishReason.known(); + if (known == null) { + return null; + } + return switch (known) { + case STOP -> StopReason.STOP; + case LENGTH -> StopReason.LENGTH; + case TOOL_CALLS, FUNCTION_CALL -> StopReason.TOOL_USE; + case CONTENT_FILTER -> StopReason.CONTENT_FILTERED; + }; + } + + private static AgentMetrics.TokenUsage toTokenUsage(CompletionUsage usage) { + final var builder = + AgentMetrics.TokenUsage.builder() + .inputTokenCount((int) usage.promptTokens()) + .outputTokenCount((int) usage.completionTokens()); + usage + .promptTokensDetails() + .flatMap(CompletionUsage.PromptTokensDetails::cachedTokens) + .ifPresent(v -> builder.cacheReadInputTokenCount(v.intValue())); + usage + .completionTokensDetails() + .flatMap(CompletionUsage.CompletionTokensDetails::reasoningTokens) + .ifPresent(v -> builder.reasoningTokenCount(v.intValue())); + return builder.build(); + } + + private static ConnectorException wrapModelCallFailure(RuntimeException e) { + final var message = + Optional.ofNullable(e.getMessage()) + .filter(StringUtils::isNotBlank) + .orElseGet(() -> e.getClass().getSimpleName()); + return new ConnectorException( + ERROR_CODE_FAILED_MODEL_CALL, + "OpenAI Chat Completions call failed: %s".formatted(message), + e); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatModelApiConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatModelApiConfiguration.java new file mode 100644 index 00000000000..a29fad867f5 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatModelApiConfiguration.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.agenticai.aiagent.framework.openai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openai.client.OpenAIClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.ModelCapabilitiesResolver; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; +import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsConfigurationProperties; +import io.camunda.connector.runtime.annotation.ConnectorsObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registers the native OpenAI factory under the same Spring bean name as the LangChain4j bridge + * factory ({@code langchain4JOpenAiChatModelApiFactory}). The bridge configuration uses + * {@code @ConditionalOnMissingBean(name = ...)}, so this native bean takes over whenever the OpenAI + * SDK is on the classpath. + */ +@Configuration +@ConditionalOnClass(OpenAIClient.class) +public class OpenAiChatModelApiConfiguration { + + @Bean(name = "langchain4JOpenAiChatModelApiFactory") + @ConditionalOnMissingBean(name = "langchain4JOpenAiChatModelApiFactory") + public ChatModelApiFactory openAiChatModelApiFactory( + @ConnectorsObjectMapper ObjectMapper objectMapper, + ModelCapabilitiesResolver capabilitiesResolver, + AgenticAiConnectorsConfigurationProperties properties) { + return new OpenAiChatModelApiFactory( + objectMapper, + capabilitiesResolver, + properties.aiagent().chatModel().api().defaultTimeout()); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatModelApiFactory.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatModelApiFactory.java new file mode 100644 index 00000000000..25ec0db941f --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiChatModelApiFactory.java @@ -0,0 +1,191 @@ +/* + * 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.agenticai.aiagent.framework.openai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.ModelCapabilitiesResolver; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.ApiFamily; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiClientCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiConnection; +import java.time.Duration; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; + +/** + * Native OpenAI factory for the {@code openai} discriminator. Builds an {@link OpenAIClient} + * (OkHttp transport) from {@link OpenAiProviderConfiguration} and dispatches to the appropriate + * backend-specific client builder ({@code OPENAI}, {@code FOUNDRY}, or {@code CUSTOM}). + * + *

The {@code create()} method branches on {@code apiFamily} to pick {@link + * OpenAiResponsesChatModelApi} vs {@link OpenAiChatCompletionsChatModelApi}. + */ +public class OpenAiChatModelApiFactory implements ChatModelApiFactory { + + public static final String API_FAMILY_COMPLETIONS = "openai-completions"; + public static final String API_FAMILY_RESPONSES = "openai-responses"; + + private final ObjectMapper objectMapper; + private final ModelCapabilitiesResolver capabilitiesResolver; + @Nullable private final Duration defaultTimeout; + + public OpenAiChatModelApiFactory( + ObjectMapper objectMapper, + ModelCapabilitiesResolver capabilitiesResolver, + @Nullable Duration defaultTimeout) { + this.objectMapper = objectMapper; + this.capabilitiesResolver = capabilitiesResolver; + this.defaultTimeout = defaultTimeout; + } + + @Override + public String providerType() { + return OpenAiProviderConfiguration.OPENAI_ID; + } + + @Override + public String apiFamily() { + // Default — actual family depends on the per-call config, so this is informational only. + return API_FAMILY_COMPLETIONS; + } + + @Override + public Class configurationType() { + return OpenAiProviderConfiguration.class; + } + + @Override + public ChatModelApi create(OpenAiProviderConfiguration configuration) { + final var connection = configuration.openai(); + final var client = buildClient(connection); + final var parameters = connection.model().parameters(); + final var maxTokens = + parameters != null && parameters.maxCompletionTokens() != null + ? parameters.maxCompletionTokens().longValue() + : null; + final var temperature = parameters != null ? parameters.temperature() : null; + final var topP = parameters != null ? parameters.topP() : null; + + final var apiFamily = + connection.apiFamily() == ApiFamily.RESPONSES + ? API_FAMILY_RESPONSES + : API_FAMILY_COMPLETIONS; + final var capabilities = + capabilitiesResolver.resolve(apiFamily, connection.model().model(), Optional.empty()); + + return connection.apiFamily() == ApiFamily.RESPONSES + ? new OpenAiResponsesChatModelApi( + client, + connection.model().model(), + objectMapper, + capabilities, + maxTokens, + temperature, + topP) + : new OpenAiChatCompletionsChatModelApi( + client, + connection.model().model(), + objectMapper, + capabilities, + maxTokens, + temperature, + topP); + } + + private OpenAIClient buildClient(OpenAiConnection connection) { + return switch (connection.backend()) { + case OPENAI -> buildOpenAiClient(connection); + case FOUNDRY -> buildFoundryClient(connection); + case CUSTOM -> buildCustomClient(connection); + }; + } + + private OpenAIClient buildOpenAiClient(OpenAiConnection connection) { + final var builder = OpenAIOkHttpClient.builder(); + + if (connection.authentication() instanceof OpenAiApiKeyAuthentication apiKeyAuth) { + builder.apiKey(apiKeyAuth.apiKey()); + if (StringUtils.isNotBlank(apiKeyAuth.organizationId())) { + builder.organization(apiKeyAuth.organizationId()); + } + if (StringUtils.isNotBlank(apiKeyAuth.projectId())) { + builder.project(apiKeyAuth.projectId()); + } + } else { + builder.apiKey("no-key"); + } + + if (StringUtils.isNotBlank(connection.endpoint())) { + builder.baseUrl(connection.endpoint()); + } + + final var timeout = resolveTimeout(connection); + if (timeout != null) { + builder.timeout(timeout); + } + + return builder.build(); + } + + private OpenAIClient buildFoundryClient(OpenAiConnection connection) { + if (connection.authentication() instanceof OpenAiClientCredentialsAuthentication) { + throw new UnsupportedOperationException( + "Client credentials for FOUNDRY backend requires Phase G Azure SDK integration"); + } + + final var builder = OpenAIOkHttpClient.builder().baseUrl(connection.endpoint()); + + if (connection.authentication() instanceof OpenAiApiKeyAuthentication apiKeyAuth) { + builder.apiKey(apiKeyAuth.apiKey()); + } else { + builder.apiKey("no-key"); + } + + final var timeout = resolveTimeout(connection); + if (timeout != null) { + builder.timeout(timeout); + } + + return builder.build(); + } + + private OpenAIClient buildCustomClient(OpenAiConnection connection) { + final var builder = OpenAIOkHttpClient.builder().baseUrl(connection.endpoint()); + + final var apiKey = + connection.authentication() instanceof OpenAiApiKeyAuthentication apiKeyAuth + && StringUtils.isNotBlank(apiKeyAuth.apiKey()) + ? apiKeyAuth.apiKey() + : "no-key"; + builder.apiKey(apiKey); + + if (connection.headers() != null && !connection.headers().isEmpty()) { + connection.headers().forEach(builder::putHeader); + } + if (connection.queryParameters() != null && !connection.queryParameters().isEmpty()) { + connection.queryParameters().forEach(builder::putQueryParam); + } + + final var timeout = resolveTimeout(connection); + if (timeout != null) { + builder.timeout(timeout); + } + + return builder.build(); + } + + @Nullable + private Duration resolveTimeout(OpenAiConnection connection) { + return Optional.ofNullable(connection.timeouts()).map(t -> t.timeout()).orElse(defaultTimeout); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiResponsesChatModelApi.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiResponsesChatModelApi.java new file mode 100644 index 00000000000..aa44689c044 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiResponsesChatModelApi.java @@ -0,0 +1,509 @@ +/* + * 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.agenticai.aiagent.framework.openai; + +import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.EasyInputMessage; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseFunctionCallOutputItem; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseInputContent; +import com.openai.models.responses.ResponseInputFile; +import com.openai.models.responses.ResponseInputFileContent; +import com.openai.models.responses.ResponseInputImage; +import com.openai.models.responses.ResponseInputImageContent; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseInputText; +import com.openai.models.responses.ResponseInputTextContent; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseUsage; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.content.ContentTextSerializer; +import io.camunda.connector.agenticai.aiagent.framework.multimodal.DocumentModality; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.agenticai.model.message.AssistantMessage; +import io.camunda.connector.agenticai.model.message.StopReason; +import io.camunda.connector.agenticai.model.message.SystemMessage; +import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; +import io.camunda.connector.agenticai.model.message.UserMessage; +import io.camunda.connector.agenticai.model.message.content.Content; +import io.camunda.connector.agenticai.model.message.content.DocumentContent; +import io.camunda.connector.agenticai.model.message.content.TextContent; +import io.camunda.connector.agenticai.model.tool.ToolCall; +import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.error.ConnectorException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; + +/** + * Native {@link ChatModelApi} for the OpenAI Responses endpoint, driving the {@code openai-java} + * SDK's blocking {@code responses().create(...)} call. + * + *

Phase D scope (text-only): user / assistant / tool-result content is restricted to text. + * Encrypted reasoning round-tripping, prompt caching ({@code prompt_cache_key}), and multimodal + * input items are deferred to Phase E. Streaming is not yet wired in either. + * + *

Used by the {@code openai} discriminator when {@code apiFamily = RESPONSES}. The factory still + * builds the same OkHttp {@link OpenAIClient}; the choice of impl class is the only thing that + * changes between the two API families. + */ +public class OpenAiResponsesChatModelApi implements ChatModelApi { + + private static final TypeReference> MAP_TYPE_REF = new TypeReference<>() {}; + + private final OpenAIClient client; + private final String model; + private final ObjectMapper objectMapper; + private final ModelCapabilities capabilities; + @Nullable private final Long configuredMaxOutputTokens; + @Nullable private final Double temperature; + @Nullable private final Double topP; + + public OpenAiResponsesChatModelApi( + OpenAIClient client, + String model, + ObjectMapper objectMapper, + ModelCapabilities capabilities, + @Nullable Long configuredMaxOutputTokens, + @Nullable Double temperature, + @Nullable Double topP) { + this.client = Objects.requireNonNull(client, "client"); + this.model = Objects.requireNonNull(model, "model"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + this.capabilities = Objects.requireNonNull(capabilities, "capabilities"); + this.configuredMaxOutputTokens = configuredMaxOutputTokens; + this.temperature = temperature; + this.topP = topP; + } + + @Override + public ModelCapabilities capabilities() { + return capabilities; + } + + @Override + public CompletableFuture complete( + ChatRequest request, ChatOptions options, ChatStreamListener listener) { + try { + final var params = buildParams(request, options); + final var response = client.responses().create(params); + return CompletableFuture.completedFuture(new ChatResponse(toAssistantMessage(response))); + } catch (RuntimeException e) { + return CompletableFuture.failedFuture(wrapModelCallFailure(e)); + } + } + + private ResponseCreateParams buildParams(ChatRequest request, ChatOptions options) { + final var builder = ResponseCreateParams.builder().model(model); + + final var maxTokens = resolveMaxOutputTokens(options); + if (maxTokens != null) { + builder.maxOutputTokens(maxTokens); + } + Optional.ofNullable(temperature).ifPresent(builder::temperature); + Optional.ofNullable(topP).ifPresent(builder::topP); + + final var inputItems = new ArrayList(); + final var messages = request.messages(); + if (messages != null) { + for (var message : messages) { + switch (message) { + case SystemMessage system -> builder.instructions(extractText(system.content())); + case UserMessage user -> inputItems.add(toUserInputItem(user)); + case AssistantMessage assistant -> addAssistantInputItems(inputItems, assistant); + case ToolCallResultMessage toolResults -> + addToolResultInputItems(inputItems, toolResults); + default -> + throw new IllegalArgumentException( + "Unsupported message type: " + message.getClass().getSimpleName()); + } + } + } + if (!inputItems.isEmpty()) { + builder.inputOfResponse(inputItems); + } + + final var toolDefinitions = request.toolDefinitions(); + if (toolDefinitions != null && !toolDefinitions.isEmpty()) { + builder.tools(OpenAiToolConverter.toResponsesTools(toolDefinitions)); + } + + return builder.build(); + } + + @Nullable + private Long resolveMaxOutputTokens(ChatOptions options) { + if (options != null && options.maxOutputTokens() != null) { + return options.maxOutputTokens().longValue(); + } + return configuredMaxOutputTokens; + } + + private String extractText(List content) { + if (content == null || content.isEmpty()) { + return ""; + } + final var sb = new StringBuilder(); + for (var c : content) { + sb.append(ContentTextSerializer.toText(c, objectMapper)); + } + return sb.toString(); + } + + private void addAssistantInputItems( + List inputItems, AssistantMessage message) { + final var text = message.content() != null ? extractText(message.content()) : ""; + if (StringUtils.isNotEmpty(text)) { + inputItems.add( + ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.ASSISTANT) + .content(text) + .build())); + } + if (message.toolCalls() != null) { + for (var call : message.toolCalls()) { + inputItems.add(ResponseInputItem.ofFunctionCall(toFunctionToolCall(call))); + } + } + } + + private ResponseFunctionToolCall toFunctionToolCall(ToolCall call) { + final var args = call.arguments() != null ? toJsonString(call.arguments()) : "{}"; + return ResponseFunctionToolCall.builder() + .callId(call.id()) + .name(call.name()) + .arguments(args) + .build(); + } + + private void addToolResultInputItems( + List inputItems, ToolCallResultMessage message) { + for (var result : message.results()) { + inputItems.add(ResponseInputItem.ofFunctionCallOutput(toFunctionCallOutput(result))); + } + } + + private ResponseInputItem.FunctionCallOutput toFunctionCallOutput(ToolCallResult result) { + final var b = ResponseInputItem.FunctionCallOutput.builder(); + if (result.id() != null) { + b.callId(result.id()); + } + final var inlineItems = toolResultMultimodalItems(result); + if (inlineItems != null) { + b.outputOfResponseFunctionCallOutputItemList(inlineItems); + } else { + final var content = result.content(); + if (content == null) { + b.output(ToolCallResult.CONTENT_NO_RESULT); + } else if (content instanceof String s) { + b.output(s); + } else { + b.output(toJsonString(content)); + } + } + return b.build(); + } + + /** + * Builds the {@code [text, image, file]} list for a tool result whose {@code contentBlocks} were + * populated by the strategy with inline-supported documents. Returns null when no inline routing + * happened — caller falls back to the string content path. + * + *

Shape: a single text item with the serialised tool-result content (JSON / string), followed + * by one image / file item per inline document. + */ + private List toolResultMultimodalItems(ToolCallResult result) { + if (result.contentBlocks() == null || result.contentBlocks().isEmpty()) { + return null; + } + final var items = new ArrayList(); + items.add( + ResponseFunctionCallOutputItem.ofInputText( + ResponseInputTextContent.builder().text(serializedToolResultText(result)).build())); + for (var block : result.contentBlocks()) { + if (!(block instanceof DocumentContent doc)) { + throw new IllegalArgumentException( + "Unsupported inline tool-result content block type: " + + block.getClass().getSimpleName()); + } + final var modality = DocumentModality.of(doc.document()); + switch (modality) { + case IMAGE -> + items.add( + ResponseFunctionCallOutputItem.ofInputImage( + ResponseInputImageContent.builder() + .imageUrl(toDataUrl(doc.document())) + .detail(ResponseInputImageContent.Detail.AUTO) + .build())); + case DOCUMENT -> + items.add( + ResponseFunctionCallOutputItem.ofInputFile( + ResponseInputFileContent.builder() + .fileData(toDataUrl(doc.document())) + .filename(safeFilename(doc.document())) + .build())); + default -> + throw new IllegalArgumentException( + "Document modality " + + modality + + " is not supported in OpenAI Responses tool-result content (only image + " + + "document emit natively); the strategy should have routed this document to " + + "a synthetic UserMessage."); + } + } + return items; + } + + private String serializedToolResultText(ToolCallResult result) { + final var content = result.content(); + if (content == null) { + return ToolCallResult.CONTENT_NO_RESULT; + } + return content instanceof String s ? s : toJsonString(content); + } + + /** + * Builds the {@link ResponseInputItem} for a user message. Pure-text messages keep the legacy + * {@code content(String)} path; messages with multimodal content blocks (image / document, + * validated by the strategy) emit a {@code List} on the same {@code + * EasyInputMessage}. + */ + private ResponseInputItem toUserInputItem(UserMessage user) { + final var content = user.content(); + if (!hasMultimodalContent(content)) { + return ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.USER) + .content(extractText(content)) + .build()); + } + final var items = new ArrayList(); + for (var c : content) { + if (c instanceof DocumentContent doc) { + items.add(documentInputContent(doc.document())); + } else { + items.add( + ResponseInputContent.ofInputText( + ResponseInputText.builder() + .text(ContentTextSerializer.toText(c, objectMapper)) + .build())); + } + } + return ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.USER) + .contentOfResponseInputMessageContentList(items) + .build()); + } + + private static boolean hasMultimodalContent(List content) { + if (content == null) { + return false; + } + for (var c : content) { + if (c instanceof DocumentContent) { + return true; + } + } + return false; + } + + private static ResponseInputContent documentInputContent(Document document) { + final var modality = DocumentModality.of(document); + return switch (modality) { + case IMAGE -> + ResponseInputContent.ofInputImage( + ResponseInputImage.builder() + .imageUrl(toDataUrl(document)) + .detail(ResponseInputImage.Detail.AUTO) + .build()); + case DOCUMENT -> + ResponseInputContent.ofInputFile( + ResponseInputFile.builder() + .fileData(toDataUrl(document)) + .filename(safeFilename(document)) + .build()); + default -> + throw new IllegalArgumentException( + "Document modality " + + modality + + " is not supported in OpenAI Responses user message content (only image + " + + "document emit natively); the strategy should have rejected this user-message " + + "document."); + }; + } + + private static String toDataUrl(Document document) { + final var mimeType = document.metadata().getContentType(); + return "data:" + mimeType + ";base64," + document.asBase64(); + } + + private static String safeFilename(Document document) { + final var name = document.metadata() != null ? document.metadata().getFileName() : null; + return StringUtils.isNotBlank(name) ? name : "document"; + } + + private String toJsonString(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize tool argument value", e); + } + } + + private static Optional resolveModelId(Response response) { + final var model = response.model(); + if (model.isString()) { + return Optional.of(model.asString()); + } + if (model.isChat()) { + return Optional.of(model.asChat().asString()); + } + if (model.isOnly()) { + return Optional.of(model.asOnly().asString()); + } + return Optional.empty(); + } + + private AssistantMessage toAssistantMessage(Response response) { + final var builder = AssistantMessage.builder(); + builder.metadata(Map.of("timestamp", ZonedDateTime.now())); + + if (StringUtils.isNotBlank(response.id())) { + builder.messageId(response.id()); + } + resolveModelId(response).filter(StringUtils::isNotBlank).ifPresent(builder::modelId); + + final var content = new ArrayList(); + final var toolCalls = new ArrayList(); + for (ResponseOutputItem item : response.output()) { + if (item.isMessage()) { + appendOutputMessageText(item.asMessage(), content); + } else if (item.isFunctionCall()) { + toolCalls.add(toToolCall(item.asFunctionCall())); + } + // ignore reasoning / web search / file search etc. for the text-only first cut + } + if (!content.isEmpty()) { + builder.content(content); + } + builder.toolCalls(toolCalls); + + builder.stopReason(toStopReason(response, !toolCalls.isEmpty())); + + response.usage().map(OpenAiResponsesChatModelApi::toTokenUsage).ifPresent(builder::usage); + + return builder.build(); + } + + private static void appendOutputMessageText( + ResponseOutputMessage outputMessage, List content) { + for (var part : outputMessage.content()) { + if (part.isOutputText()) { + final var text = part.asOutputText().text(); + if (StringUtils.isNotBlank(text)) { + content.add(TextContent.textContent(text)); + } + } + // refusal blocks are ignored in the text-only first cut + } + } + + private ToolCall toToolCall(ResponseFunctionToolCall functionCall) { + final var arguments = parseArguments(functionCall.arguments()); + return ToolCall.builder() + .id(functionCall.callId()) + .name(functionCall.name()) + .arguments(arguments) + .build(); + } + + private Map parseArguments(String json) { + if (json == null || json.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return objectMapper.readValue(json, MAP_TYPE_REF); + } catch (Exception e) { + throw new IllegalStateException("Failed to parse tool call arguments JSON", e); + } + } + + private static StopReason toStopReason(Response response, boolean hasToolCalls) { + if (hasToolCalls) { + return StopReason.TOOL_USE; + } + final var status = response.status().flatMap(s -> Optional.ofNullable(s.known())).orElse(null); + if (status == null) { + return null; + } + return switch (status) { + case COMPLETED -> StopReason.STOP; + case INCOMPLETE -> + response + .incompleteDetails() + .flatMap(d -> d.reason()) + .flatMap(r -> Optional.ofNullable(r.known())) + .map( + known -> + switch (known) { + case MAX_OUTPUT_TOKENS -> StopReason.LENGTH; + case CONTENT_FILTER -> StopReason.CONTENT_FILTERED; + }) + .orElse(StopReason.STOP); + case FAILED -> StopReason.ERROR; + case CANCELLED -> StopReason.ABORTED; + case IN_PROGRESS, QUEUED -> null; + }; + } + + private static AgentMetrics.TokenUsage toTokenUsage(ResponseUsage usage) { + final var builder = + AgentMetrics.TokenUsage.builder() + .inputTokenCount((int) usage.inputTokens()) + .outputTokenCount((int) usage.outputTokens()); + final var inputDetails = usage.inputTokensDetails(); + if (inputDetails != null) { + builder.cacheReadInputTokenCount((int) inputDetails.cachedTokens()); + } + final var outputDetails = usage.outputTokensDetails(); + if (outputDetails != null) { + builder.reasoningTokenCount((int) outputDetails.reasoningTokens()); + } + return builder.build(); + } + + private static ConnectorException wrapModelCallFailure(RuntimeException e) { + final var message = + Optional.ofNullable(e.getMessage()) + .filter(StringUtils::isNotBlank) + .orElseGet(() -> e.getClass().getSimpleName()); + return new ConnectorException( + ERROR_CODE_FAILED_MODEL_CALL, "OpenAI Responses call failed: %s".formatted(message), e); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiToolConverter.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiToolConverter.java new file mode 100644 index 00000000000..78b0f109651 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/openai/OpenAiToolConverter.java @@ -0,0 +1,84 @@ +/* + * 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.agenticai.aiagent.framework.openai; + +import com.openai.core.JsonValue; +import com.openai.models.FunctionDefinition; +import com.openai.models.FunctionParameters; +import com.openai.models.chat.completions.ChatCompletionFunctionTool; +import com.openai.models.chat.completions.ChatCompletionTool; +import com.openai.models.responses.FunctionTool; +import com.openai.models.responses.Tool; +import io.camunda.connector.agenticai.model.tool.ToolDefinition; +import java.util.List; +import java.util.Map; + +/** + * Converts {@link ToolDefinition}s into OpenAI {@link ChatCompletionTool}s. Shared between the Chat + * Completions and Responses native impls — both endpoints accept the same {@code tools[]} JSON + * shape. + */ +public final class OpenAiToolConverter { + + private OpenAiToolConverter() {} + + public static List toTools(List definitions) { + if (definitions == null || definitions.isEmpty()) { + return List.of(); + } + return definitions.stream().map(OpenAiToolConverter::toTool).toList(); + } + + public static ChatCompletionTool toTool(ToolDefinition definition) { + final var function = FunctionDefinition.builder().name(definition.name()); + if (definition.description() != null) { + function.description(definition.description()); + } + function.parameters(toFunctionParameters(definition.inputSchema())); + + return ChatCompletionTool.ofFunction( + ChatCompletionFunctionTool.builder().function(function.build()).build()); + } + + private static FunctionParameters toFunctionParameters(Map schemaMap) { + final var builder = FunctionParameters.builder(); + if (schemaMap == null || schemaMap.isEmpty()) { + return builder.putAdditionalProperty("type", JsonValue.from("object")).build(); + } + schemaMap.forEach((key, value) -> builder.putAdditionalProperty(key, JsonValue.from(value))); + return builder.build(); + } + + /** + * Variant for the Responses API, which uses a different (but JSON-equivalent) Tool/Parameters + * shape. + */ + public static List toResponsesTools(List definitions) { + if (definitions == null || definitions.isEmpty()) { + return List.of(); + } + return definitions.stream().map(OpenAiToolConverter::toResponsesTool).toList(); + } + + public static Tool toResponsesTool(ToolDefinition definition) { + final var builder = FunctionTool.builder().name(definition.name()).strict(false); + if (definition.description() != null) { + builder.description(definition.description()); + } + builder.parameters(toResponsesParameters(definition.inputSchema())); + return Tool.ofFunction(builder.build()); + } + + private static FunctionTool.Parameters toResponsesParameters(Map schemaMap) { + final var builder = FunctionTool.Parameters.builder(); + if (schemaMap == null || schemaMap.isEmpty()) { + return builder.putAdditionalProperty("type", JsonValue.from("object")).build(); + } + schemaMap.forEach((key, value) -> builder.putAdditionalProperty(key, JsonValue.from(value))); + return builder.build(); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/strategy/ToolCallResultStrategy.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/strategy/ToolCallResultStrategy.java new file mode 100644 index 00000000000..22ea882038e --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/strategy/ToolCallResultStrategy.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.agenticai.aiagent.framework.strategy; + +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.model.message.UserMessage; +import java.util.List; + +/** + * Routes every {@code Document} found in a {@link ChatRequest} to one of two outcomes, driven by + * the resolved {@link ModelCapabilities}: + * + *

    + *
  • Tool-result documents are routed against {@link ModelCapabilities#toolResultModalities()}. + * Inline-supported modalities are appended to {@code ToolCallResult.contentBlocks} for the + * impl to emit natively. Unsupported modalities fall back to a synthetic {@link UserMessage} + * carrying the binary, anchored to the originating tool-result message (PR #6999 shape: + * header text + per-document XML correlation tag + {@code DocumentContent} block). + *
  • User-message and event-message documents are routed against {@link + * ModelCapabilities#userMessageModalities()}. Supported modalities stay inline; unsupported + * modalities fail loud with {@code ConnectorException} (no synthesis fallback for user + * messages, mirroring L4J's {@code DocumentConversionException} semantics). + *
+ * + *

Implementations must be a pure function over {@code (request, capabilities)}. The returned + * {@link Result#request()} is the wire form to dispatch (synthetic context messages already + * interleaved at their anchor positions); {@link Result#syntheticContextMessages()} duplicates + * those synthetic messages so {@code ChatClientImpl} can persist them into {@code RuntimeMemory} at + * the matching positions. + * + *

Part of the ADR-005 Phase E SPI scaffolding. + */ +public interface ToolCallResultStrategy { + + /** + * Single-pass walk over {@code request.messages()}. Routes every document; rebuilds tool-result + * messages with populated {@code contentBlocks} where inline; emits synthetic context messages + * for fallback documents; throws {@code ConnectorException} on user-message capability mismatch. + */ + Result apply(ChatRequest request, ModelCapabilities capabilities); + + /** + * @param request rewritten request — tool-result {@code contentBlocks} populated for inline + * documents; synthetic context messages interleaved at their anchor positions. + * @param syntheticContextMessages synthetic {@link UserMessage}s carrying fallback documents + * (with {@code METADATA_TOOL_CALL_DOCUMENTS=true}). Duplicates the entries already present in + * {@code request.messages()}; surfaced separately so the caller can insert them into {@code + * RuntimeMemory} at the matching positions. + */ + record Result(ChatRequest request, List syntheticContextMessages) {} +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/strategy/ToolCallResultStrategyImpl.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/strategy/ToolCallResultStrategyImpl.java new file mode 100644 index 00000000000..5967e0256a4 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/framework/strategy/ToolCallResultStrategyImpl.java @@ -0,0 +1,206 @@ +/* + * 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.agenticai.aiagent.framework.strategy; + +import static io.camunda.connector.agenticai.aiagent.agent.AgentErrorCodes.ERROR_CODE_FAILED_MODEL_CALL; +import static io.camunda.connector.agenticai.model.message.content.DocumentContent.documentContent; +import static io.camunda.connector.agenticai.model.message.content.TextContent.textContent; + +import io.camunda.connector.agenticai.aiagent.agent.ToolCallResultDocumentExtractor; +import io.camunda.connector.agenticai.aiagent.agent.ToolCallResultDocumentExtractor.ToolCallDocuments; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.multimodal.DocumentModality; +import io.camunda.connector.agenticai.model.message.DocumentXmlTag; +import io.camunda.connector.agenticai.model.message.Message; +import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; +import io.camunda.connector.agenticai.model.message.UserMessage; +import io.camunda.connector.agenticai.model.message.content.Content; +import io.camunda.connector.agenticai.model.message.content.DocumentContent; +import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.error.ConnectorException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ToolCallResultStrategyImpl implements ToolCallResultStrategy { + + private static final Logger LOGGER = LoggerFactory.getLogger(ToolCallResultStrategyImpl.class); + + private static final String FALLBACK_HEADER = "Documents extracted from tool call results:"; + + private final ToolCallResultDocumentExtractor documentExtractor; + + public ToolCallResultStrategyImpl(ToolCallResultDocumentExtractor documentExtractor) { + this.documentExtractor = documentExtractor; + } + + @Override + public Result apply(ChatRequest request, ModelCapabilities capabilities) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Applying tool-call-result strategy with resolved capabilities — " + + "userMessageModalities={}, toolResultModalities={}", + capabilities.userMessageModalities(), + capabilities.toolResultModalities()); + } + final var rewrittenMessages = new ArrayList(request.messages().size()); + final var syntheticContextMessages = new ArrayList(); + + for (var message : request.messages()) { + switch (message) { + case ToolCallResultMessage toolCallResultMessage -> { + final var routed = routeToolCallResultMessage(toolCallResultMessage, capabilities); + rewrittenMessages.add(routed.message()); + if (routed.synthetic() != null) { + rewrittenMessages.add(routed.synthetic()); + syntheticContextMessages.add(routed.synthetic()); + } + } + case UserMessage userMessage -> { + validateUserMessageModalities(userMessage, capabilities); + rewrittenMessages.add(userMessage); + } + default -> rewrittenMessages.add(message); + } + } + + return new Result( + new ChatRequest(rewrittenMessages, request.toolDefinitions(), request.responseFormat()), + List.copyOf(syntheticContextMessages)); + } + + private record RoutedToolCallResultMessage( + ToolCallResultMessage message, UserMessage synthetic) {} + + private RoutedToolCallResultMessage routeToolCallResultMessage( + ToolCallResultMessage toolCallResultMessage, ModelCapabilities capabilities) { + + final var extractedByToolCall = + documentExtractor.extractDocuments(toolCallResultMessage.results()); + if (extractedByToolCall.isEmpty()) { + return new RoutedToolCallResultMessage(toolCallResultMessage, null); + } + + // Index extracted documents by tool call id for O(1) lookup while iterating results in order. + final var docsByToolCallId = new HashMap(); + for (var entry : extractedByToolCall) { + docsByToolCallId.put(entry.toolCallId(), entry); + } + + final var fallbackEntries = new ArrayList(); + final var rewrittenResults = + new ArrayList(toolCallResultMessage.results().size()); + + for (var result : toolCallResultMessage.results()) { + final var entry = docsByToolCallId.get(result.id()); + if (entry == null) { + rewrittenResults.add(result); + continue; + } + + final var inline = new ArrayList(); + final var fallback = new ArrayList(); + for (var doc : entry.documents()) { + final var modality = DocumentModality.of(doc); + if (capabilities.toolResultModalities().contains(modality)) { + inline.add(doc); + } else { + fallback.add(doc); + } + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Routing tool result id={} name={}: documents={}, inlined={}, fallback={}, " + + "toolResultModalities={}", + result.id(), + result.name(), + entry.documents().size(), + inline.size(), + fallback.size(), + capabilities.toolResultModalities()); + } + + rewrittenResults.add(inline.isEmpty() ? result : appendInlineContentBlocks(result, inline)); + if (!fallback.isEmpty()) { + fallbackEntries.add( + new ToolCallDocuments(entry.toolCallId(), entry.toolCallName(), fallback)); + } + } + + final var rewrittenMessage = + ToolCallResultMessage.builder() + .results(rewrittenResults) + .metadata(toolCallResultMessage.metadata()) + .build(); + final var synthetic = + fallbackEntries.isEmpty() ? null : buildSyntheticUserMessage(fallbackEntries); + + return new RoutedToolCallResultMessage(rewrittenMessage, synthetic); + } + + private static ToolCallResult appendInlineContentBlocks( + ToolCallResult result, List inlineDocs) { + final var blocks = new ArrayList(); + if (result.contentBlocks() != null) { + blocks.addAll(result.contentBlocks()); + } + for (var doc : inlineDocs) { + blocks.add(documentContent(doc)); + } + return result.withContentBlocks(blocks); + } + + private static UserMessage buildSyntheticUserMessage(List fallbackEntries) { + final var content = new ArrayList(); + content.add(textContent(FALLBACK_HEADER)); + for (var entry : fallbackEntries) { + for (var doc : entry.documents()) { + content.add( + textContent( + DocumentXmlTag.from(doc, entry.toolCallId(), entry.toolCallName()).toXml())); + content.add(documentContent(doc)); + } + } + + final Map metadata = new HashMap<>(); + metadata.put("timestamp", ZonedDateTime.now()); + metadata.put(UserMessage.METADATA_TOOL_CALL_DOCUMENTS, true); + + return UserMessage.builder().content(content).metadata(metadata).build(); + } + + private void validateUserMessageModalities( + UserMessage userMessage, ModelCapabilities capabilities) { + if (userMessage.content() == null) { + return; + } + for (var contentBlock : userMessage.content()) { + if (!(contentBlock instanceof DocumentContent documentContent)) { + continue; + } + final var doc = documentContent.document(); + final var modality = DocumentModality.of(doc); + if (!capabilities.userMessageModalities().contains(modality)) { + throw new ConnectorException( + ERROR_CODE_FAILED_MODEL_CALL, + "Document with reference '%s' has modality %s but the resolved model does not " + .formatted(doc.reference(), modality) + + "support that modality in user messages (supported: %s)." + .formatted(capabilities.userMessageModalities())); + } + } + LOGGER.trace( + "Validated user message modalities against capabilities {}", + capabilities.userMessageModalities()); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/memory/runtime/MessageWindowRuntimeMemory.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/memory/runtime/MessageWindowRuntimeMemory.java index d1264b92588..66d795bfab7 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/memory/runtime/MessageWindowRuntimeMemory.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/memory/runtime/MessageWindowRuntimeMemory.java @@ -10,6 +10,7 @@ import io.camunda.connector.agenticai.model.message.Message; import io.camunda.connector.agenticai.model.message.SystemMessage; import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; +import io.camunda.connector.agenticai.model.message.UserMessage; import java.util.ArrayList; import java.util.List; @@ -72,7 +73,9 @@ public void clear() { // original implementation see Langchain4j private static List filteredMessages(List messages, int maxMessages) { final var filtered = new ArrayList<>(messages); - while (filtered.size() > maxMessages) { + int effectiveCount = (int) filtered.stream().filter(m -> !isToolCallDocumentMessage(m)).count(); + + while (effectiveCount > maxMessages) { int messageToEvictIndex = 0; // don't remove the system message @@ -82,6 +85,9 @@ private static List filteredMessages(List messages, int maxMes // remove the message at the current index Message evictedMessage = filtered.remove(messageToEvictIndex); + if (!isToolCallDocumentMessage(evictedMessage)) { + effectiveCount--; + } // remove follow-up tool call results if existing as some LLM providers return an error when // receiving tool call results without the original tool call request @@ -90,10 +96,25 @@ private static List filteredMessages(List messages, int maxMes while (filtered.size() > messageToEvictIndex && filtered.get(messageToEvictIndex) instanceof ToolCallResultMessage) { filtered.remove(messageToEvictIndex); + effectiveCount--; } } + + // remove follow-up document user messages attached to evicted tool call results + while (filtered.size() > messageToEvictIndex + && isToolCallDocumentMessage(filtered.get(messageToEvictIndex))) { + filtered.remove(messageToEvictIndex); + // document messages are not counted, no need to decrement + } } return List.copyOf(filtered); } + + private static boolean isToolCallDocumentMessage(Message message) { + return message instanceof UserMessage userMessage + && userMessage.metadata() != null + && Boolean.TRUE.equals( + userMessage.metadata().get(UserMessage.METADATA_TOOL_CALL_DOCUMENTS)); + } } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentMetrics.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentMetrics.java index 59ff1fcd5ec..d73741b7d1a 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentMetrics.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentMetrics.java @@ -6,6 +6,7 @@ */ package io.camunda.connector.agenticai.aiagent.model; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import io.camunda.connector.agenticai.model.AgenticAiRecord; @@ -47,9 +48,18 @@ public static class AgentMetricsJacksonProxyBuilder extends AgentMetricsBuilder @AgenticAiRecord @JsonDeserialize(builder = TokenUsage.AgentMetricsTokenUsageJacksonProxyBuilder.class) - public record TokenUsage(int inputTokenCount, int outputTokenCount) + public record TokenUsage( + int inputTokenCount, + int outputTokenCount, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) int cacheReadInputTokenCount, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) int cacheCreationInputTokenCount, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) int reasoningTokenCount) implements AgentMetricsTokenUsageBuilder.With { + public TokenUsage(int inputTokenCount, int outputTokenCount) { + this(inputTokenCount, outputTokenCount, 0, 0, 0); + } + public int totalTokenCount() { return inputTokenCount + outputTokenCount; } @@ -59,7 +69,14 @@ public TokenUsage add(TokenUsage tokenUsage) { builder -> builder .inputTokenCount(builder.inputTokenCount() + tokenUsage.inputTokenCount()) - .outputTokenCount(builder.outputTokenCount() + tokenUsage.outputTokenCount())); + .outputTokenCount(builder.outputTokenCount() + tokenUsage.outputTokenCount()) + .cacheReadInputTokenCount( + builder.cacheReadInputTokenCount() + tokenUsage.cacheReadInputTokenCount()) + .cacheCreationInputTokenCount( + builder.cacheCreationInputTokenCount() + + tokenUsage.cacheCreationInputTokenCount()) + .reasoningTokenCount( + builder.reasoningTokenCount() + tokenUsage.reasoningTokenCount())); } public static TokenUsage empty() { diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentResponse.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentResponse.java index a85b6abe5b1..d2405e6bd26 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentResponse.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/AgentResponse.java @@ -12,10 +12,12 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import io.camunda.connector.agenticai.model.AgenticAiRecord; import io.camunda.connector.agenticai.model.message.AssistantMessage; +import io.camunda.connector.agenticai.model.message.StopReason; import io.camunda.connector.agenticai.model.tool.ToolCall; import io.camunda.connector.agenticai.model.tool.ToolCallProcessVariable; import io.camunda.connector.agenticai.model.tool.ToolDefinition; import io.camunda.connector.generator.java.annotation.DataExample; +import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import org.springframework.lang.Nullable; @@ -87,18 +89,11 @@ public static AgentResponse exampleResultWithAssistantMessageResponse() { final var assistantMessage = AssistantMessage.builder() .content(singleTextContent("This is a sample response text from the AI agent.")) - .metadata( - Map.of( - "framework", - Map.ofEntries( - Map.entry("id", "chatcmpl-123"), - Map.entry("finishReason", "STOP"), - Map.entry( - "tokenUsage", - Map.of( - "inputTokenCount", 5, - "outputTokenCount", 6, - "totalTokenCount", 11))))) + .modelId("gpt-5") + .messageId("chatcmpl-123") + .stopReason(StopReason.STOP) + .usage(AgentMetrics.TokenUsage.builder().inputTokenCount(5).outputTokenCount(6).build()) + .metadata(Map.of("timestamp", ZonedDateTime.parse("2025-01-15T10:30:00Z"))) .build(); return AgentResponse.builder() diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/AnthropicProviderConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/AnthropicProviderConfiguration.java index addfce9d304..59ed0e0a8db 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/AnthropicProviderConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/AnthropicProviderConfiguration.java @@ -8,12 +8,17 @@ import static io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.ANTHROPIC_ID; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.HttpUrl; import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.TimeoutConfiguration; import io.camunda.connector.generator.java.annotation.FeelMode; +import io.camunda.connector.generator.java.annotation.TemplateDiscriminatorProperty; import io.camunda.connector.generator.java.annotation.TemplateProperty; import io.camunda.connector.generator.java.annotation.TemplateSubType; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertFalse; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -30,6 +35,20 @@ public String providerType() { return ANTHROPIC_ID; } + public enum AnthropicBackend { + @JsonProperty("direct") + DIRECT, + + @JsonProperty("bedrock") + BEDROCK, + + @JsonProperty("vertex") + VERTEX, + + @JsonProperty("foundry") + FOUNDRY + } + public record AnthropicConnection( @HttpUrl @TemplateProperty( @@ -39,23 +58,124 @@ public record AnthropicConnection( feel = FeelMode.optional, optional = true) String endpoint, + @TemplateProperty( + group = "provider", + label = "Backend", + description = "Specify the Anthropic backend to use.", + type = TemplateProperty.PropertyType.Dropdown, + defaultValue = "direct", + defaultValueType = TemplateProperty.DefaultValueType.String, + choices = { + @TemplateProperty.DropdownPropertyChoice( + label = "Direct (Anthropic API)", + value = "direct"), + @TemplateProperty.DropdownPropertyChoice(label = "AWS Bedrock", value = "bedrock"), + @TemplateProperty.DropdownPropertyChoice( + label = "Google Vertex AI", + value = "vertex"), + @TemplateProperty.DropdownPropertyChoice( + label = "Azure AI Foundry", + value = "foundry") + }) + AnthropicBackend backend, @Valid @NotNull AnthropicAuthentication authentication, @Valid TimeoutConfiguration timeouts, - @Valid @NotNull AnthropicModel model) {} + @Valid @NotNull AnthropicModel model) { - public record AnthropicAuthentication( - @NotBlank - @TemplateProperty( - group = "provider", - label = "Anthropic API key", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String apiKey) { + public AnthropicConnection { + if (backend == null) { + backend = AnthropicBackend.DIRECT; + } + } + + @AssertFalse( + message = "Client credentials authentication is only supported for the FOUNDRY backend") + public boolean isClientCredentialsUsedWithNonFoundryBackend() { + return authentication + instanceof AnthropicAuthentication.AnthropicClientCredentialsAuthentication + && backend != AnthropicBackend.FOUNDRY; + } + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + @JsonSubTypes({ + @JsonSubTypes.Type( + value = AnthropicAuthentication.AnthropicApiKeyAuthentication.class, + name = "apiKey"), + @JsonSubTypes.Type( + value = AnthropicAuthentication.AnthropicClientCredentialsAuthentication.class, + name = "clientCredentials") + }) + @TemplateDiscriminatorProperty( + label = "Authentication", + group = "provider", + name = "type", + defaultValue = "apiKey", + description = "Specify the Anthropic authentication strategy.") + public sealed interface AnthropicAuthentication { + @TemplateSubType(id = "apiKey", label = "API key") + record AnthropicApiKeyAuthentication( + @NotBlank + @TemplateProperty( + group = "provider", + label = "Anthropic API key", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) + String apiKey) + implements AnthropicAuthentication { + + @Override + public @NotNull String toString() { + return "AnthropicApiKeyAuthentication{apiKey=[REDACTED]}"; + } + } + + @TemplateSubType(id = "clientCredentials", label = "Client credentials") + record AnthropicClientCredentialsAuthentication( + @NotBlank + @TemplateProperty( + group = "provider", + label = "Client ID", + description = "ID of a Microsoft Entra application", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) + String clientId, + @NotBlank + @TemplateProperty( + group = "provider", + label = "Client secret", + description = "Secret of a Microsoft Entra application", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) + String clientSecret, + @NotBlank + @TemplateProperty( + group = "provider", + label = "Tenant ID", + description = + "ID of a Microsoft Entra tenant. Details in the documentation.", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional) + String tenantId, + @TemplateProperty( + group = "provider", + label = "Authority host", + description = + "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + optional = true) + String authorityHost) + implements AnthropicAuthentication { - @Override - public String toString() { - return "AnthropicAuthentication{apiKey=[REDACTED]}"; + @Override + public String toString() { + return "AnthropicClientCredentialsAuthentication{clientId=%s, clientSecret=[REDACTED], tenantId=%s, authorityHost=%s}" + .formatted(clientId, tenantId, authorityHost); + } } } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/AzureOpenAiProviderConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/AzureOpenAiProviderConfiguration.java deleted file mode 100644 index 908192082be..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/AzureOpenAiProviderConfiguration.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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.agenticai.aiagent.model.request.provider; - -import static io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration.AZURE_OPENAI_ID; - -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.HttpUrl; -import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.TimeoutConfiguration; -import io.camunda.connector.api.annotation.FEEL; -import io.camunda.connector.generator.java.annotation.FeelMode; -import io.camunda.connector.generator.java.annotation.TemplateDiscriminatorProperty; -import io.camunda.connector.generator.java.annotation.TemplateProperty; -import io.camunda.connector.generator.java.annotation.TemplateSubType; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -@TemplateSubType(id = AZURE_OPENAI_ID, label = "Azure OpenAI") -public record AzureOpenAiProviderConfiguration(@Valid @NotNull AzureOpenAiConnection azureOpenAi) - implements ProviderConfiguration { - - @TemplateProperty(ignore = true) - public static final String AZURE_OPENAI_ID = "azureOpenAi"; - - @Override - public String providerType() { - return AZURE_OPENAI_ID; - } - - public record AzureOpenAiConnection( - @NotBlank - @HttpUrl - @FEEL - @TemplateProperty( - group = "provider", - description = - "Specify Azure OpenAI endpoint. Details in the documentation.", - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String endpoint, - @Valid @NotNull AzureAuthentication authentication, - @Valid TimeoutConfiguration timeouts, - @Valid @NotNull AzureOpenAiModel model) {} - - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") - @JsonSubTypes({ - @JsonSubTypes.Type( - value = AzureAuthentication.AzureApiKeyAuthentication.class, - name = "apiKey"), - @JsonSubTypes.Type( - value = AzureAuthentication.AzureClientCredentialsAuthentication.class, - name = "clientCredentials") - }) - @TemplateDiscriminatorProperty( - label = "Authentication", - group = "provider", - name = "type", - defaultValue = "apiKey", - description = "Specify the Azure OpenAI authentication strategy.") - public sealed interface AzureAuthentication { - @TemplateSubType(id = "apiKey", label = "API key") - record AzureApiKeyAuthentication( - @NotBlank - @TemplateProperty( - group = "provider", - label = "API key", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String apiKey) - implements AzureAuthentication { - - @Override - public @NotNull String toString() { - return "AzureApiKeyAuthentication{apiKey=[REDACTED]}"; - } - } - - @TemplateSubType(id = "clientCredentials", label = "Client credentials") - record AzureClientCredentialsAuthentication( - @NotBlank - @TemplateProperty( - group = "provider", - label = "Client ID", - description = "ID of a Microsoft Entra application", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String clientId, - @NotBlank - @TemplateProperty( - group = "provider", - label = "Client secret", - description = "Secret of a Microsoft Entra application", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String clientSecret, - @NotBlank - @TemplateProperty( - group = "provider", - label = "Tenant ID", - description = - "ID of a Microsoft Entra tenant. Details in the documentation.", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional) - String tenantId, - @TemplateProperty( - group = "provider", - label = "Authority host", - description = - "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - optional = true) - String authorityHost) - implements AzureAuthentication { - - @Override - public String toString() { - return "AzureClientCredentialsAuthentication{clientId=%s, clientSecret=[REDACTED], tenantId=%s, authorityHost=%s}" - .formatted(clientId, tenantId, authorityHost); - } - } - } - - public record AzureOpenAiModel( - @NotBlank - @TemplateProperty( - group = "model", - label = "Model deployment name", - description = - "Specify the model deployment name. Details in the documentation.", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String deploymentName, - @Valid AzureOpenAiModel.AzureOpenAiModelParameters parameters) { - - public record AzureOpenAiModelParameters( - @Min(0) - @TemplateProperty( - group = "model", - label = "Maximum tokens", - tooltip = - "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - type = TemplateProperty.PropertyType.Number, - feel = FeelMode.required, - optional = true) - Integer maxTokens, - @Min(0) - @TemplateProperty( - group = "model", - label = "Temperature", - tooltip = - "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - type = TemplateProperty.PropertyType.Number, - feel = FeelMode.required, - optional = true) - Double temperature, - @Min(0) - @TemplateProperty( - group = "model", - label = "top P", - tooltip = - "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - type = TemplateProperty.PropertyType.Number, - feel = FeelMode.required, - optional = true) - Double topP) {} - } -} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/BedrockProviderConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/BedrockProviderConfiguration.java index 7ad415ae2d8..2e72f428e92 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/BedrockProviderConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/BedrockProviderConfiguration.java @@ -63,6 +63,14 @@ public boolean isDefaultCredentialsChainUsedInSaaS() { return ConnectorUtils.isSaaS() && authentication instanceof AwsAuthentication.AwsDefaultCredentialsChainAuthentication; } + + @AssertFalse( + message = + "Anthropic models must be configured via the Anthropic provider with backend = BEDROCK") + @SuppressWarnings("unused") + public boolean isAnthropicModelUsed() { + return model != null && model.model() != null && model.model().startsWith("anthropic."); + } } @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/GoogleVertexAiProviderConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/GoogleGenAiProviderConfiguration.java similarity index 74% rename from connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/GoogleVertexAiProviderConfiguration.java rename to connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/GoogleGenAiProviderConfiguration.java index e4fbcc9203e..2eb77e2e221 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/GoogleVertexAiProviderConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/GoogleGenAiProviderConfiguration.java @@ -6,8 +6,9 @@ */ package io.camunda.connector.agenticai.aiagent.model.request.provider; -import static io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GOOGLE_VERTEX_AI_ID; +import static io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GOOGLE_GENAI_ID; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import io.camunda.connector.agenticai.util.ConnectorUtils; @@ -21,19 +22,27 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -@TemplateSubType(id = GOOGLE_VERTEX_AI_ID, label = "Google Vertex AI") -public record GoogleVertexAiProviderConfiguration( - @Valid @NotNull GoogleVertexAiConnection googleVertexAi) implements ProviderConfiguration { +@TemplateSubType(id = GOOGLE_GENAI_ID, label = "Google GenAI") +public record GoogleGenAiProviderConfiguration(@Valid @NotNull GoogleGenAiConnection googleGenAi) + implements ProviderConfiguration { @TemplateProperty(ignore = true) - public static final String GOOGLE_VERTEX_AI_ID = "google-vertex-ai"; + public static final String GOOGLE_GENAI_ID = "googleGenAi"; @Override public String providerType() { - return GOOGLE_VERTEX_AI_ID; + return GOOGLE_GENAI_ID; } - public record GoogleVertexAiConnection( + public enum GoogleBackend { + @JsonProperty("developer-api") + DEVELOPER_API, + + @JsonProperty("vertex") + VERTEX + } + + public record GoogleGenAiConnection( @NotBlank @TemplateProperty( group = "provider", @@ -52,24 +61,44 @@ public record GoogleVertexAiConnection( feel = FeelMode.optional, constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) String region, - @Valid @NotNull GoogleVertexAiAuthentication authentication, - @Valid @NotNull GoogleVertexAiProviderConfiguration.GoogleVertexAiModel model) { + @Valid @NotNull GoogleGenAiAuthentication authentication, + @Valid @NotNull GoogleGenAiModel model, + @TemplateProperty( + group = "provider", + label = "Backend", + description = "Specify the Google GenAI backend to use.", + type = TemplateProperty.PropertyType.Dropdown, + defaultValue = "vertex", + defaultValueType = TemplateProperty.DefaultValueType.String, + choices = { + @TemplateProperty.DropdownPropertyChoice(label = "Vertex AI", value = "vertex"), + @TemplateProperty.DropdownPropertyChoice( + label = "Developer API (Google AI Studio)", + value = "developer-api") + }) + GoogleBackend backend) { + + public GoogleGenAiConnection { + if (backend == null) { + backend = GoogleBackend.VERTEX; + } + } - @AssertFalse(message = "Google Vertex AI is not supported on SaaS") + @AssertFalse(message = "Google GenAI is not supported on SaaS") public boolean isUsedInSaaS() { return ConnectorUtils.isSaaS() && authentication - instanceof GoogleVertexAiAuthentication.ApplicationDefaultCredentialsAuthentication; + instanceof GoogleGenAiAuthentication.ApplicationDefaultCredentialsAuthentication; } } @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @JsonSubTypes.Type( - value = GoogleVertexAiAuthentication.ServiceAccountCredentialsAuthentication.class, + value = GoogleGenAiAuthentication.ServiceAccountCredentialsAuthentication.class, name = "serviceAccountCredentials"), @JsonSubTypes.Type( - value = GoogleVertexAiAuthentication.ApplicationDefaultCredentialsAuthentication.class, + value = GoogleGenAiAuthentication.ApplicationDefaultCredentialsAuthentication.class, name = "applicationDefaultCredentials"), }) @TemplateDiscriminatorProperty( @@ -78,7 +107,7 @@ public boolean isUsedInSaaS() { name = "type", defaultValue = "serviceAccountCredentials", description = "Specify the Google Vertex AI authentication strategy.") - public sealed interface GoogleVertexAiAuthentication { + public sealed interface GoogleGenAiAuthentication { @TemplateSubType(id = "serviceAccountCredentials", label = "Service account credentials") record ServiceAccountCredentialsAuthentication( @NotBlank @@ -89,7 +118,7 @@ record ServiceAccountCredentialsAuthentication( feel = FeelMode.optional, constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) String jsonKey) - implements GoogleVertexAiAuthentication { + implements GoogleGenAiAuthentication { @Override public String toString() { return "ServiceAccountCredentialsAuthentication{jsonKey=[REDACTED]}"; @@ -99,10 +128,10 @@ public String toString() { @TemplateSubType( id = "applicationDefaultCredentials", label = "Application default credentials (Hybrid/Self-Managed only)") - record ApplicationDefaultCredentialsAuthentication() implements GoogleVertexAiAuthentication {} + record ApplicationDefaultCredentialsAuthentication() implements GoogleGenAiAuthentication {} } - public record GoogleVertexAiModel( + public record GoogleGenAiModel( @NotBlank @TemplateProperty( group = "model", @@ -113,9 +142,9 @@ public record GoogleVertexAiModel( feel = FeelMode.optional, constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) String model, - @Valid GoogleVertexAiModelParameters parameters) { + @Valid GoogleGenAiModelParameters parameters) { - public record GoogleVertexAiModelParameters( + public record GoogleGenAiModelParameters( @Min(0) @TemplateProperty( group = "model", diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/OpenAiCompatibleProviderConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/OpenAiCompatibleProviderConfiguration.java deleted file mode 100644 index 32465e91931..00000000000 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/OpenAiCompatibleProviderConfiguration.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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.agenticai.aiagent.model.request.provider; - -import static io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration.OPENAI_COMPATIBLE_ID; - -import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.HttpUrl; -import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.TimeoutConfiguration; -import io.camunda.connector.api.annotation.FEEL; -import io.camunda.connector.generator.java.annotation.FeelMode; -import io.camunda.connector.generator.java.annotation.TemplateProperty; -import io.camunda.connector.generator.java.annotation.TemplateSubType; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.util.Map; - -@TemplateSubType(id = OPENAI_COMPATIBLE_ID, label = "OpenAI Compatible") -public record OpenAiCompatibleProviderConfiguration( - @Valid @NotNull OpenAiCompatibleConnection openaiCompatible) implements ProviderConfiguration { - - @TemplateProperty(ignore = true) - public static final String OPENAI_COMPATIBLE_ID = "openaiCompatible"; - - @Override - public String providerType() { - return OPENAI_COMPATIBLE_ID; - } - - public record OpenAiCompatibleConnection( - @NotBlank - @HttpUrl - @TemplateProperty( - group = "provider", - label = "API endpoint", - tooltip = "Specify an endpoint to use the connector with an OpenAI compatible API. ", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String endpoint, - @Valid OpenAiCompatibleAuthentication authentication, - @FEEL - @TemplateProperty( - group = "provider", - label = "Headers", - description = "Map of HTTP headers to add to the request.", - feel = FeelMode.required, - optional = true) - Map headers, - @FEEL - @TemplateProperty( - group = "provider", - label = "Query Parameters", - description = "Map of query parameters to add to the request URL.", - feel = FeelMode.required, - optional = true) - @Valid - Map<@NotBlank String, String> queryParameters, - @Valid TimeoutConfiguration timeouts, - @Valid @NotNull OpenAiCompatibleModel model) {} - - public record OpenAiCompatibleAuthentication( - @TemplateProperty( - group = "provider", - label = "API key", - tooltip = - "Leave blank if using HTTP headers for authentication.
If an Authorization header is specified in the headers, then the API key is ignored.", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - optional = true) - String apiKey) { - - @Override - public String toString() { - return "OpenAiCompatibleAuthentication{apiKey=[REDACTED]}"; - } - } - - public record OpenAiCompatibleModel( - @NotBlank - @TemplateProperty( - group = "model", - label = "Model", - description = - "Specify the model ID. Details in the documentation.", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - defaultValue = "gpt-4o", - defaultValueType = TemplateProperty.DefaultValueType.String, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String model, - @Valid OpenAiCompatibleModel.OpenAiCompatibleModelParameters parameters) { - - public record OpenAiCompatibleModelParameters( - @Min(0) - @TemplateProperty( - group = "model", - label = "Maximum completion tokens", - tooltip = - "The maximum number of tokens per request to generate before stopping.

Details in the documentation.", - type = TemplateProperty.PropertyType.Number, - feel = FeelMode.required, - optional = true) - Integer maxCompletionTokens, - @Min(0) - @TemplateProperty( - group = "model", - label = "Temperature", - tooltip = - "Floating point number between 0 and 2. The higher the number, the more randomness will be injected into the response.

Details in the documentation.", - type = TemplateProperty.PropertyType.Number, - feel = FeelMode.required, - optional = true) - Double temperature, - @Min(0) - @TemplateProperty( - group = "model", - label = "top P", - tooltip = - "Recommended for advanced use cases only (you usually only need to use temperature).

Details in the documentation.", - type = TemplateProperty.PropertyType.Number, - feel = FeelMode.required, - optional = true) - Double topP, - @FEEL - @TemplateProperty( - group = "model", - label = "Custom parameters", - description = "Map of additional request parameters to include.", - feel = FeelMode.required, - optional = true) - Map customParameters) {} - } -} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/OpenAiProviderConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/OpenAiProviderConfiguration.java index 33ec82c4a29..92c6fb68a76 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/OpenAiProviderConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/OpenAiProviderConfiguration.java @@ -8,14 +8,22 @@ import static io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OPENAI_ID; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.HttpUrl; import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.TimeoutConfiguration; +import io.camunda.connector.api.annotation.FEEL; import io.camunda.connector.generator.java.annotation.FeelMode; +import io.camunda.connector.generator.java.annotation.TemplateDiscriminatorProperty; import io.camunda.connector.generator.java.annotation.TemplateProperty; import io.camunda.connector.generator.java.annotation.TemplateSubType; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertFalse; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import java.util.Map; @TemplateSubType(id = OPENAI_ID, label = "OpenAI") public record OpenAiProviderConfiguration(@Valid @NotNull OpenAiConnection openai) @@ -29,43 +37,215 @@ public String providerType() { return OPENAI_ID; } + public enum OpenAiBackend { + @JsonProperty("openai") + OPENAI, + + @JsonProperty("foundry") + FOUNDRY, + + @JsonProperty("custom") + CUSTOM + } + public record OpenAiConnection( + @TemplateProperty( + group = "provider", + label = "Backend", + description = "Specify the OpenAI backend to use.", + type = TemplateProperty.PropertyType.Dropdown, + defaultValue = "openai", + defaultValueType = TemplateProperty.DefaultValueType.String, + choices = { + @TemplateProperty.DropdownPropertyChoice(label = "OpenAI", value = "openai"), + @TemplateProperty.DropdownPropertyChoice( + label = "Azure AI Foundry", + value = "foundry"), + @TemplateProperty.DropdownPropertyChoice(label = "Custom", value = "custom") + }) + OpenAiBackend backend, @Valid @NotNull OpenAiAuthentication authentication, @Valid TimeoutConfiguration timeouts, - @Valid @NotNull OpenAiModel model) {} - - public record OpenAiAuthentication( - @NotBlank - @TemplateProperty( - group = "provider", - label = "OpenAI API key", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) - String apiKey, + @Valid @NotNull OpenAiModel model, @TemplateProperty( group = "provider", - label = "Organization ID", + label = "API", description = - "For members of multiple organizations. Details in the documentation.", - type = TemplateProperty.PropertyType.String, - feel = FeelMode.optional, - optional = true) - String organizationId, - @TemplateProperty( + "Which OpenAI API family to use. Chat Completions is the default for backward compatibility; Responses is the newer endpoint with structured input/output items.", + type = TemplateProperty.PropertyType.Dropdown, + choices = { + @TemplateProperty.DropdownPropertyChoice( + label = "Chat Completions (default)", + value = "completions"), + @TemplateProperty.DropdownPropertyChoice(label = "Responses", value = "responses") + }, + feel = FeelMode.disabled, + optional = true, + defaultValue = "completions", + defaultValueType = TemplateProperty.DefaultValueType.String) + ApiFamily apiFamily, + @HttpUrl + @TemplateProperty( group = "provider", - label = "Project ID", + label = "Endpoint", description = - "For accounts with multiple projects. Details in the documentation.", + "Optional. Override the default OpenAI base URL (e.g. for an OpenAI proxy or gateway). Leave blank to use the SDK default.", type = TemplateProperty.PropertyType.String, feel = FeelMode.optional, optional = true) - String projectId) { + String endpoint, + @FEEL + @TemplateProperty( + group = "provider", + label = "Headers", + description = "Map of HTTP headers to add to the request.", + feel = FeelMode.required, + optional = true) + Map headers, + @FEEL + @TemplateProperty( + group = "provider", + label = "Query Parameters", + description = "Map of query parameters to add to the request URL.", + feel = FeelMode.required, + optional = true) + @Valid + Map<@NotBlank String, String> queryParameters) { + + public OpenAiConnection { + if (backend == null) { + backend = OpenAiBackend.OPENAI; + } + if (apiFamily == null) { + apiFamily = ApiFamily.COMPLETIONS; + } + } + + /** Convenience constructor used by existing call sites that pre-date the backend field. */ + public OpenAiConnection( + OpenAiAuthentication authentication, TimeoutConfiguration timeouts, OpenAiModel model) { + this(null, authentication, timeouts, model, ApiFamily.COMPLETIONS, null, null, null); + } + + @AssertFalse( + message = "Client credentials authentication is only supported for the FOUNDRY backend") + public boolean isClientCredentialsUsedWithNonFoundryBackend() { + return authentication instanceof OpenAiAuthentication.OpenAiClientCredentialsAuthentication + && backend != OpenAiBackend.FOUNDRY; + } + + @AssertFalse(message = "Endpoint is required for FOUNDRY and CUSTOM backends") + public boolean isEndpointMissingForBackendThatRequiresIt() { + return (backend == OpenAiBackend.FOUNDRY || backend == OpenAiBackend.CUSTOM) + && (endpoint == null || endpoint.isBlank()); + } + } + + public enum ApiFamily { + @com.fasterxml.jackson.annotation.JsonProperty("completions") + COMPLETIONS, + @com.fasterxml.jackson.annotation.JsonProperty("responses") + RESPONSES + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + @JsonSubTypes({ + @JsonSubTypes.Type( + value = OpenAiAuthentication.OpenAiApiKeyAuthentication.class, + name = "apiKey"), + @JsonSubTypes.Type( + value = OpenAiAuthentication.OpenAiClientCredentialsAuthentication.class, + name = "clientCredentials") + }) + @TemplateDiscriminatorProperty( + label = "Authentication", + group = "provider", + name = "type", + defaultValue = "apiKey", + description = "Specify the OpenAI authentication strategy.") + public sealed interface OpenAiAuthentication { + + @TemplateSubType(id = "apiKey", label = "API key") + record OpenAiApiKeyAuthentication( + @TemplateProperty( + group = "provider", + label = "API key", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + optional = true) + String apiKey, + @TemplateProperty( + group = "provider", + label = "Organization ID", + description = + "For members of multiple organizations. Details in the documentation.", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + optional = true) + String organizationId, + @TemplateProperty( + group = "provider", + label = "Project ID", + description = + "For accounts with multiple projects. Details in the documentation.", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + optional = true) + String projectId) + implements OpenAiAuthentication { - @Override - public String toString() { - return "OpenAiAuthentication{apiKey=[REDACTED], organizationId=%s, projectId=%s}" - .formatted(organizationId, projectId); + @Override + public String toString() { + return "OpenAiApiKeyAuthentication{apiKey=[REDACTED], organizationId=%s, projectId=%s}" + .formatted(organizationId, projectId); + } + } + + @TemplateSubType(id = "clientCredentials", label = "Client credentials") + record OpenAiClientCredentialsAuthentication( + @NotBlank + @TemplateProperty( + group = "provider", + label = "Client ID", + description = "ID of a Microsoft Entra application", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) + String clientId, + @NotBlank + @TemplateProperty( + group = "provider", + label = "Client secret", + description = "Secret of a Microsoft Entra application", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + constraints = @TemplateProperty.PropertyConstraints(notEmpty = true)) + String clientSecret, + @NotBlank + @TemplateProperty( + group = "provider", + label = "Tenant ID", + description = + "ID of a Microsoft Entra tenant. Details in the documentation.", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional) + String tenantId, + @TemplateProperty( + group = "provider", + label = "Authority host", + description = + "Authority host URL for the Microsoft Entra application. Defaults to https://login.microsoftonline.com. This can also contain an OAuth 2.0 token endpoint.", + type = TemplateProperty.PropertyType.String, + feel = FeelMode.optional, + optional = true) + String authorityHost) + implements OpenAiAuthentication { + + @Override + public String toString() { + return "OpenAiClientCredentialsAuthentication{clientId=%s, clientSecret=[REDACTED], tenantId=%s, authorityHost=%s}" + .formatted(clientId, tenantId, authorityHost); + } } } @@ -114,6 +294,14 @@ public record OpenAiModelParameters( type = TemplateProperty.PropertyType.Number, feel = FeelMode.required, optional = true) - Double topP) {} + Double topP, + @FEEL + @TemplateProperty( + group = "model", + label = "Custom parameters", + description = "Map of additional request parameters to include.", + feel = FeelMode.required, + optional = true) + Map customParameters) {} } } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfiguration.java index e95076773a4..1f7ce47840a 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfiguration.java @@ -7,26 +7,26 @@ package io.camunda.connector.agenticai.aiagent.model.request.provider; import static io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.ANTHROPIC_ID; -import static io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration.AZURE_OPENAI_ID; import static io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration.BEDROCK_ID; -import static io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GOOGLE_VERTEX_AI_ID; -import static io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration.OPENAI_COMPATIBLE_ID; +import static io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GOOGLE_GENAI_ID; import static io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OPENAI_ID; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.camunda.connector.generator.java.annotation.TemplateDiscriminatorProperty; +@JsonDeserialize(using = ProviderConfigurationDeserializer.class) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = AnthropicProviderConfiguration.class, name = ANTHROPIC_ID), @JsonSubTypes.Type(value = BedrockProviderConfiguration.class, name = BEDROCK_ID), - @JsonSubTypes.Type(value = AzureOpenAiProviderConfiguration.class, name = AZURE_OPENAI_ID), - @JsonSubTypes.Type(value = GoogleVertexAiProviderConfiguration.class, name = GOOGLE_VERTEX_AI_ID), + @JsonSubTypes.Type(value = GoogleGenAiProviderConfiguration.class, name = GOOGLE_GENAI_ID), @JsonSubTypes.Type(value = OpenAiProviderConfiguration.class, name = OPENAI_ID), - @JsonSubTypes.Type( - value = OpenAiCompatibleProviderConfiguration.class, - name = OPENAI_COMPATIBLE_ID) + // Legacy type IDs — routed through ProviderConfigurationDeserializer for migration + @JsonSubTypes.Type(value = GoogleGenAiProviderConfiguration.class, name = "googleVertexAi"), + @JsonSubTypes.Type(value = OpenAiProviderConfiguration.class, name = "azureOpenAi"), + @JsonSubTypes.Type(value = OpenAiProviderConfiguration.class, name = "openaiCompatible"), }) @TemplateDiscriminatorProperty( label = "Provider", @@ -37,10 +37,8 @@ public sealed interface ProviderConfiguration permits AnthropicProviderConfiguration, BedrockProviderConfiguration, - AzureOpenAiProviderConfiguration, - GoogleVertexAiProviderConfiguration, - OpenAiProviderConfiguration, - OpenAiCompatibleProviderConfiguration { + GoogleGenAiProviderConfiguration, + OpenAiProviderConfiguration { /** Type of the provider implementation used to resolve the backing chat model. */ String providerType(); diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfigurationDeserializer.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfigurationDeserializer.java new file mode 100644 index 00000000000..6ae34627238 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/model/request/provider/ProviderConfigurationDeserializer.java @@ -0,0 +1,457 @@ +/* + * 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.agenticai.aiagent.model.request.provider; + +import static io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.ANTHROPIC_ID; +import static io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration.BEDROCK_ID; +import static io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GOOGLE_GENAI_ID; +import static io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OPENAI_ID; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; + +/** + * Migration deserializer for {@link ProviderConfiguration}. + * + *

Rewrites legacy serialized provider configuration shapes to the current canonical form before + * standard Jackson polymorphic dispatch takes over. This handles backward-compatibility for process + * instances that were saved with older configuration shapes, including: + * + *

    + *
  • Bedrock configurations with Anthropic model IDs → AnthropicProviderConfiguration with + * backend=bedrock + *
  • googleVertexAi discriminator → googleGenAi with backend=vertex + *
  • anthropic without backend field → inject backend=direct + *
  • anthropic without auth type discriminator → inject type=apiKey + *
  • openai without backend/apiFamily → inject backend=openai, apiFamily=completions + *
  • azureOpenAi → openai/foundry with field mapping + *
  • openaiCompatible → openai/custom with field mapping + *
+ * + *

Dispatch to concrete subtypes via {@code mapper.treeToValue(migrated, SubType.class)} does NOT + * re-enter this deserializer because Jackson's BeanDeserializer for the resolved concrete subtype + * takes over directly. + * + *

This deserializer is permanent infrastructure — kept indefinitely so that stale process + * variables remain readable. + */ +public class ProviderConfigurationDeserializer extends StdDeserializer { + + private static final String GOOGLE_VERTEX_AI_LEGACY_ID = "googleVertexAi"; + private static final String AZURE_OPENAI_LEGACY_ID = "azureOpenAi"; + private static final String OPENAI_COMPATIBLE_LEGACY_ID = "openaiCompatible"; + + private static final String FIELD_TYPE = "type"; + private static final String FIELD_BACKEND = "backend"; + private static final String FIELD_API_FAMILY = "apiFamily"; + private static final String FIELD_ENDPOINT = "endpoint"; + private static final String FIELD_AUTHENTICATION = "authentication"; + private static final String FIELD_MODEL = "model"; + private static final String FIELD_TIMEOUTS = "timeouts"; + private static final String FIELD_HEADERS = "headers"; + private static final String FIELD_QUERY_PARAMETERS = "queryParameters"; + private static final String FIELD_API_KEY = "apiKey"; + private static final String FIELD_DEPLOYMENT_NAME = "deploymentName"; + + public ProviderConfigurationDeserializer() { + super(ProviderConfiguration.class); + } + + @Override + public ProviderConfiguration deserialize(JsonParser jp, DeserializationContext ctxt) + throws IOException { + ObjectMapper mapper = (ObjectMapper) jp.getCodec(); + ObjectNode node = (ObjectNode) mapper.readTree(jp); + + // The `type` property may be absent from the node when Jackson's AsPropertyTypeDeserializer + // has already consumed it before calling this deserializer. In that case, infer the original + // type discriminator from the node's top-level keys (e.g. "azureOpenAi", "anthropic"). + String type = node.has(FIELD_TYPE) ? node.path(FIELD_TYPE).asText(null) : inferType(node); + + ObjectNode migrated = migrate(node, type, mapper); + String newType = + migrated.has(FIELD_TYPE) ? migrated.path(FIELD_TYPE).asText(null) : inferType(migrated); + + // Dispatch by extracting the inner connection sub-object and deserializing it directly as the + // concrete connection type (e.g. AnthropicConnection, not AnthropicProviderConfiguration). + // This avoids routing through ObjectMapper.treeToValue(ProviderConfiguration subtype), which + // would re-trigger the interface-level @JsonTypeInfo + @JsonDeserialize and cause recursion. + JsonNode rawInner = newType != null ? migrated.path(newType) : mapper.createObjectNode(); + if (!rawInner.isObject()) { + throw JsonMappingException.from( + jp, "Provider configuration is missing connection object for type: " + newType); + } + ObjectNode innerNode = (ObjectNode) rawInner; + + if (ANTHROPIC_ID.equals(newType)) { + return new AnthropicProviderConfiguration( + mapper.treeToValue(innerNode, AnthropicProviderConfiguration.AnthropicConnection.class)); + } else if (BEDROCK_ID.equals(newType)) { + return new BedrockProviderConfiguration( + mapper.treeToValue(innerNode, BedrockProviderConfiguration.BedrockConnection.class)); + } else if (GOOGLE_GENAI_ID.equals(newType)) { + return new GoogleGenAiProviderConfiguration( + mapper.treeToValue( + innerNode, GoogleGenAiProviderConfiguration.GoogleGenAiConnection.class)); + } else if (OPENAI_ID.equals(newType)) { + return new OpenAiProviderConfiguration( + mapper.treeToValue(innerNode, OpenAiProviderConfiguration.OpenAiConnection.class)); + } else { + throw new JsonMappingException(jp, "Unknown provider type: " + newType); + } + } + + /** + * Infers the provider type discriminator from the node's top-level keys when the {@code type} + * property has been consumed by Jackson's polymorphic type resolver before reaching this + * deserializer. + */ + private String inferType(ObjectNode node) { + if (node.has(ANTHROPIC_ID)) return ANTHROPIC_ID; + if (node.has(BEDROCK_ID)) return BEDROCK_ID; + if (node.has(GOOGLE_GENAI_ID)) return GOOGLE_GENAI_ID; + if (node.has(OPENAI_ID)) return OPENAI_ID; + if (node.has(GOOGLE_VERTEX_AI_LEGACY_ID)) return GOOGLE_VERTEX_AI_LEGACY_ID; + if (node.has(AZURE_OPENAI_LEGACY_ID)) return AZURE_OPENAI_LEGACY_ID; + if (node.has(OPENAI_COMPATIBLE_LEGACY_ID)) return OPENAI_COMPATIBLE_LEGACY_ID; + return null; + } + + /** + * Applies all migration rules in order and returns the (possibly rewritten) node. + * + *

Rules are applied in order; some rules may change the type discriminator, which causes + * subsequent rules to operate on the updated type. + */ + private ObjectNode migrate(ObjectNode node, String type, ObjectMapper mapper) { + // Rule 1 & 2: bedrock with Anthropic model → rewrite to anthropic/bedrock; non-Anthropic + // model → passthrough + if (BEDROCK_ID.equals(type)) { + return migrateBedrockNode(node, mapper); + } + + // Rule 3: googleVertexAi → googleGenAi/vertex + if (GOOGLE_VERTEX_AI_LEGACY_ID.equals(type)) { + return migrateGoogleVertexAiNode(node, mapper); + } + + // Rule 7: azureOpenAi → openai/foundry + if (AZURE_OPENAI_LEGACY_ID.equals(type)) { + return migrateAzureOpenAiNode(node, mapper); + } + + // Rule 8: openaiCompatible → openai/custom + if (OPENAI_COMPATIBLE_LEGACY_ID.equals(type)) { + return migrateOpenAiCompatibleNode(node, mapper); + } + + // Rules 4 & 5: anthropic without backend or without auth type discriminator + if (ANTHROPIC_ID.equals(type)) { + return migrateAnthropicNode(node, mapper); + } + + // Rule 6: openai without backend/apiFamily + if (OPENAI_ID.equals(type)) { + return migrateOpenAiNode(node, mapper); + } + + return node; + } + + /** + * Rule 1: bedrock with an {@code anthropic.*} model ID → rewrite to anthropic/bedrock. Rule 2: + * bedrock with non-Anthropic model → passthrough. + */ + private ObjectNode migrateBedrockNode(ObjectNode node, ObjectMapper mapper) { + ObjectNode bedrockObj = (ObjectNode) node.path(BEDROCK_ID); + if (bedrockObj == null || bedrockObj.isMissingNode()) { + return node; + } + + ObjectNode modelObj = (ObjectNode) bedrockObj.path(FIELD_MODEL); + String modelId = + (modelObj != null && !modelObj.isMissingNode()) + ? modelObj.path(FIELD_MODEL).asText(null) + : null; + + if (modelId == null || !modelId.startsWith("anthropic.")) { + // Rule 2: non-Anthropic model — passthrough + return node; + } + + // Rule 1: rewrite to anthropic/bedrock + ObjectNode anthropicObj = mapper.createObjectNode(); + anthropicObj.put(FIELD_BACKEND, "bedrock"); + + // Authentication from Bedrock (AWS credentials types) is incompatible with + // AnthropicAuthentication (apiKey / clientCredentials). Do NOT copy it; the + // migrated configuration will need to supply Anthropic auth separately. + + // Copy timeouts if present + if (bedrockObj.has(FIELD_TIMEOUTS)) { + anthropicObj.set(FIELD_TIMEOUTS, bedrockObj.get(FIELD_TIMEOUTS)); + } + + // Copy endpoint if present + if (bedrockObj.has(FIELD_ENDPOINT)) { + anthropicObj.set(FIELD_ENDPOINT, bedrockObj.get(FIELD_ENDPOINT)); + } + + // Copy model (BedrockModel has same field name as AnthropicModel) + if (bedrockObj.has(FIELD_MODEL)) { + anthropicObj.set(FIELD_MODEL, bedrockObj.get(FIELD_MODEL)); + } + + ObjectNode result = mapper.createObjectNode(); + result.put(FIELD_TYPE, ANTHROPIC_ID); + result.set(ANTHROPIC_ID, anthropicObj); + return result; + } + + /** + * Rule 3: googleVertexAi → googleGenAi with backend=vertex. + * + *

The old shape uses {@code googleVertexAi} as both the discriminator and the nested object + * key. The new shape uses {@code googleGenAi} as both. + */ + private ObjectNode migrateGoogleVertexAiNode(ObjectNode node, ObjectMapper mapper) { + ObjectNode googleObj = mapper.createObjectNode(); + googleObj.put(FIELD_BACKEND, "vertex"); + + ObjectNode legacyObj = (ObjectNode) node.path(GOOGLE_VERTEX_AI_LEGACY_ID); + if (legacyObj != null && !legacyObj.isMissingNode()) { + legacyObj.fields().forEachRemaining(entry -> googleObj.set(entry.getKey(), entry.getValue())); + } + + ObjectNode result = mapper.createObjectNode(); + result.put(FIELD_TYPE, GOOGLE_GENAI_ID); + result.set(GOOGLE_GENAI_ID, googleObj); + return result; + } + + /** + * Rules 4 & 5: inject missing backend and/or auth type discriminator for anthropic. + * + *

Rule 4: {@code anthropic} without backend → inject {@code backend: direct}. Rule 5: {@code + * anthropic.authentication} without {@code type} → inject {@code type: apiKey}. + */ + private ObjectNode migrateAnthropicNode(ObjectNode node, ObjectMapper mapper) { + ObjectNode anthropicObj = (ObjectNode) node.path(ANTHROPIC_ID); + if (anthropicObj == null || anthropicObj.isMissingNode()) { + return node; + } + + // Rule 4: inject backend=direct if missing + if (!anthropicObj.has(FIELD_BACKEND)) { + anthropicObj.put(FIELD_BACKEND, "direct"); + } + + // Rule 5: inject authentication.type=apiKey if auth present but missing type discriminator + if (anthropicObj.has(FIELD_AUTHENTICATION)) { + var authNode = anthropicObj.get(FIELD_AUTHENTICATION); + if (authNode != null && authNode.isObject()) { + ObjectNode authObj = (ObjectNode) authNode; + if (!authObj.has(FIELD_TYPE)) { + authObj.put(FIELD_TYPE, "apiKey"); + } + } + } + + return node; + } + + /** Rule 6: openai without backend → inject backend=openai, apiFamily=completions. */ + private ObjectNode migrateOpenAiNode(ObjectNode node, ObjectMapper mapper) { + ObjectNode openaiObj = (ObjectNode) node.path(OPENAI_ID); + if (openaiObj == null || openaiObj.isMissingNode()) { + return node; + } + + if (!openaiObj.has(FIELD_BACKEND)) { + openaiObj.put(FIELD_BACKEND, "openai"); + } + + if (!openaiObj.has(FIELD_API_FAMILY)) { + openaiObj.put(FIELD_API_FAMILY, "completions"); + } + + return node; + } + + /** + * Rule 7: azureOpenAi → openai/foundry with field mapping. + * + *

Old structure: + * + *

+   * {
+   *   "type": "azureOpenAi",
+   *   "azureOpenAi": {
+   *     "endpoint": "...",
+   *     "authentication": { "type": "apiKey", "apiKey": "..." },
+   *     "model": { "deploymentName": "...", "parameters": {...} },
+   *     "timeouts": {...}
+   *   }
+   * }
+   * 
+ * + *

New structure: + * + *

+   * {
+   *   "type": "openai",
+   *   "openai": {
+   *     "backend": "foundry",
+   *     "apiFamily": "completions",
+   *     "endpoint": "...",
+   *     "authentication": { "type": "apiKey", "apiKey": "..." },
+   *     "model": { "model": "...", "parameters": {...} },
+   *     "timeouts": {...}
+   *   }
+   * }
+   * 
+ * + *

Note: {@code deploymentName} in the old model maps to {@code model} in the new model. + */ + private ObjectNode migrateAzureOpenAiNode(ObjectNode node, ObjectMapper mapper) { + ObjectNode azureObj = (ObjectNode) node.path(AZURE_OPENAI_LEGACY_ID); + + ObjectNode openaiObj = mapper.createObjectNode(); + openaiObj.put(FIELD_BACKEND, "foundry"); + openaiObj.put(FIELD_API_FAMILY, "completions"); + + if (azureObj != null && !azureObj.isMissingNode()) { + // Copy endpoint + if (azureObj.has(FIELD_ENDPOINT)) { + openaiObj.set(FIELD_ENDPOINT, azureObj.get(FIELD_ENDPOINT)); + } + + // Copy authentication sub-tree directly (same structure) + if (azureObj.has(FIELD_AUTHENTICATION)) { + openaiObj.set(FIELD_AUTHENTICATION, azureObj.get(FIELD_AUTHENTICATION)); + } + + // Map model: deploymentName → model + if (azureObj.has(FIELD_MODEL)) { + ObjectNode oldModel = (ObjectNode) azureObj.get(FIELD_MODEL); + ObjectNode newModel = mapper.createObjectNode(); + if (oldModel.has(FIELD_DEPLOYMENT_NAME)) { + newModel.set(FIELD_MODEL, oldModel.get(FIELD_DEPLOYMENT_NAME)); + } + if (oldModel.has("parameters")) { + newModel.set("parameters", oldModel.get("parameters")); + } + openaiObj.set(FIELD_MODEL, newModel); + } + + // Copy timeouts + if (azureObj.has(FIELD_TIMEOUTS)) { + openaiObj.set(FIELD_TIMEOUTS, azureObj.get(FIELD_TIMEOUTS)); + } + } + + ObjectNode result = mapper.createObjectNode(); + result.put(FIELD_TYPE, OPENAI_ID); + result.set(OPENAI_ID, openaiObj); + return result; + } + + /** + * Rule 8: openaiCompatible → openai/custom with field mapping and auth discriminator injection. + * + *

Old structure: + * + *

+   * {
+   *   "type": "openaiCompatible",
+   *   "openaiCompatible": {
+   *     "endpoint": "...",
+   *     "authentication": { "apiKey": "..." },
+   *     "headers": {...},
+   *     "queryParameters": {...},
+   *     "timeouts": {...},
+   *     "model": { "model": "...", "parameters": {...} }
+   *   }
+   * }
+   * 
+ * + *

New structure: + * + *

+   * {
+   *   "type": "openai",
+   *   "openai": {
+   *     "backend": "custom",
+   *     "apiFamily": "completions",
+   *     "endpoint": "...",
+   *     "authentication": { "type": "apiKey", "apiKey": "..." },
+   *     "headers": {...},
+   *     "queryParameters": {...},
+   *     "timeouts": {...},
+   *     "model": { "model": "...", "parameters": {...} }
+   *   }
+   * }
+   * 
+ * + *

Note: old authentication was a flat record with just {@code apiKey}; the new shape requires + * a {@code type: apiKey} discriminator. + */ + private ObjectNode migrateOpenAiCompatibleNode(ObjectNode node, ObjectMapper mapper) { + ObjectNode compatObj = (ObjectNode) node.path(OPENAI_COMPATIBLE_LEGACY_ID); + + ObjectNode openaiObj = mapper.createObjectNode(); + openaiObj.put(FIELD_BACKEND, "custom"); + openaiObj.put(FIELD_API_FAMILY, "completions"); + + if (compatObj != null && !compatObj.isMissingNode()) { + // Copy endpoint + if (compatObj.has(FIELD_ENDPOINT)) { + openaiObj.set(FIELD_ENDPOINT, compatObj.get(FIELD_ENDPOINT)); + } + + // Map authentication: inject type=apiKey discriminator if missing + if (compatObj.has(FIELD_AUTHENTICATION)) { + ObjectNode oldAuth = (ObjectNode) compatObj.get(FIELD_AUTHENTICATION); + if (!oldAuth.has(FIELD_TYPE)) { + oldAuth.put(FIELD_TYPE, "apiKey"); + } + openaiObj.set(FIELD_AUTHENTICATION, oldAuth); + } + + // Copy headers + if (compatObj.has(FIELD_HEADERS)) { + openaiObj.set(FIELD_HEADERS, compatObj.get(FIELD_HEADERS)); + } + + // Copy queryParameters + if (compatObj.has(FIELD_QUERY_PARAMETERS)) { + openaiObj.set(FIELD_QUERY_PARAMETERS, compatObj.get(FIELD_QUERY_PARAMETERS)); + } + + // Copy timeouts + if (compatObj.has(FIELD_TIMEOUTS)) { + openaiObj.set(FIELD_TIMEOUTS, compatObj.get(FIELD_TIMEOUTS)); + } + + // Copy model (same structure) + if (compatObj.has(FIELD_MODEL)) { + openaiObj.set(FIELD_MODEL, compatObj.get(FIELD_MODEL)); + } + } + + ObjectNode result = mapper.createObjectNode(); + result.put(FIELD_TYPE, OPENAI_ID); + result.set(OPENAI_ID, openaiObj); + return result; + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/tool/GatewayToolHandler.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/tool/GatewayToolHandler.java index 27a75df9820..69c81bf3c31 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/tool/GatewayToolHandler.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/aiagent/tool/GatewayToolHandler.java @@ -7,10 +7,12 @@ package io.camunda.connector.agenticai.aiagent.tool; import io.camunda.connector.agenticai.adhoctoolsschema.schema.GatewayToolDefinitionResolver; +import io.camunda.connector.agenticai.aiagent.agent.ContentTreeDocumentWalker; import io.camunda.connector.agenticai.aiagent.model.AgentContext; import io.camunda.connector.agenticai.model.tool.GatewayToolDefinition; import io.camunda.connector.agenticai.model.tool.ToolCallResult; import io.camunda.connector.agenticai.model.tool.ToolDefinition; +import io.camunda.connector.api.document.Document; import java.util.List; /** @@ -54,4 +56,22 @@ boolean allToolDiscoveryResultsPresent( /** Handles tool discovery results matching the handlesToolDiscoveryResult() predicate. */ List handleToolDiscoveryResults( AgentContext agentContext, List toolCallResults); + + /** + * Extracts {@link Document} instances from a tool call result managed by this handler. Called + * after {@link #transformToolCallResults} so the {@code toolCallResult.content()} carries this + * handler's transformed shape (typically a typed domain object). + * + *

The default implementation delegates to {@link ContentTreeDocumentWalker}, which walks + * {@link java.util.Map}, {@link java.util.Collection}, {@code Object[]} and {@link Document} + * nodes. This is sufficient for handlers whose transformed content remains a raw tree. + * + *

Handlers that return typed records or POJOs as content must override this method and walk + * their own structure (typically via a sealed-type switch). For mixed shapes — typed wrappers + * around nested raw subtrees — call {@link + * ContentTreeDocumentWalker#extractDocumentsFromContent(Object)} on the raw parts. + */ + default List extractDocuments(ToolCallResult toolCallResult) { + return ContentTreeDocumentWalker.extractDocumentsFromContent(toolCallResult.content()); + } } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfiguration.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfiguration.java index 06f5781dd30..45b58f6b470 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfiguration.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfiguration.java @@ -38,9 +38,19 @@ import io.camunda.connector.agenticai.aiagent.agent.AgentToolsResolverImpl; import io.camunda.connector.agenticai.aiagent.agent.JobWorkerAgentRequestHandler; import io.camunda.connector.agenticai.aiagent.agent.OutboundConnectorAgentRequestHandler; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkAdapter; +import io.camunda.connector.agenticai.aiagent.agent.ToolCallResultDocumentExtractor; +import io.camunda.connector.agenticai.aiagent.framework.ChatClientImpl; +import io.camunda.connector.agenticai.aiagent.framework.ChatModelApiRegistryImpl; +import io.camunda.connector.agenticai.aiagent.framework.anthropic.AnthropicMessagesApiConfiguration; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiRegistry; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.AgenticAiCapabilitiesConfiguration; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelHttpProxySupport; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.configuration.AgenticAiLangchain4JFrameworkConfiguration; +import io.camunda.connector.agenticai.aiagent.framework.openai.OpenAiChatModelApiConfiguration; +import io.camunda.connector.agenticai.aiagent.framework.strategy.ToolCallResultStrategy; +import io.camunda.connector.agenticai.aiagent.framework.strategy.ToolCallResultStrategyImpl; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStore; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistry; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistryImpl; @@ -77,6 +87,9 @@ @ConditionalOnBooleanProperty(value = "camunda.connector.agenticai.enabled", matchIfMissing = true) @EnableConfigurationProperties(AgenticAiConnectorsConfigurationProperties.class) @Import({ + AgenticAiCapabilitiesConfiguration.class, + AnthropicMessagesApiConfiguration.class, + OpenAiChatModelApiConfiguration.class, AgenticAiLangchain4JFrameworkConfiguration.class, McpDiscoveryConfiguration.class, McpClientConfiguration.class, @@ -230,6 +243,13 @@ public SystemPromptComposer aiAgentSystemPromptComposer( return new SystemPromptComposerImpl(contributors); } + @Bean + @ConditionalOnMissingBean + public ToolCallResultDocumentExtractor toolCallResultDocumentExtractor( + GatewayToolHandlerRegistry gatewayToolHandlers) { + return new ToolCallResultDocumentExtractor(gatewayToolHandlers); + } + @Bean @ConditionalOnMissingBean public AgentMessagesHandler aiAgentMessagesHandler( @@ -244,6 +264,27 @@ public AgentResponseHandler aiAgentResponseHandler( return new AgentResponseHandlerImpl(objectMapper); } + @Bean + @ConditionalOnMissingBean + public ChatModelApiRegistry aiAgentChatModelApiRegistry( + List> chatModelApiFactories) { + return new ChatModelApiRegistryImpl(chatModelApiFactories); + } + + @Bean + @ConditionalOnMissingBean + public ToolCallResultStrategy aiAgentToolCallResultStrategy( + ToolCallResultDocumentExtractor documentExtractor) { + return new ToolCallResultStrategyImpl(documentExtractor); + } + + @Bean + @ConditionalOnMissingBean + public ChatClient aiAgentChatClient( + ChatModelApiRegistry chatModelApiRegistry, ToolCallResultStrategy toolCallResultStrategy) { + return new ChatClientImpl(chatModelApiRegistry, toolCallResultStrategy); + } + @Bean @ConditionalOnMissingBean @ConditionalOnBooleanProperty( @@ -255,7 +296,7 @@ public OutboundConnectorAgentRequestHandler aiAgentOutboundConnectorAgentRequest AgentLimitsValidator limitsValidator, AgentMessagesHandler messagesHandler, GatewayToolHandlerRegistry gatewayToolHandlers, - AiFrameworkAdapter aiFrameworkAdapter, + ChatClient chatClient, AgentResponseHandler responseHandler) { return new OutboundConnectorAgentRequestHandler( agentInitializer, @@ -263,7 +304,7 @@ public OutboundConnectorAgentRequestHandler aiAgentOutboundConnectorAgentRequest limitsValidator, messagesHandler, gatewayToolHandlers, - aiFrameworkAdapter, + chatClient, responseHandler); } @@ -289,7 +330,7 @@ public JobWorkerAgentRequestHandler aiAgentJobWorkerAgentRequestHandler( AgentLimitsValidator limitsValidator, AgentMessagesHandler messagesHandler, GatewayToolHandlerRegistry gatewayToolHandlers, - AiFrameworkAdapter aiFrameworkAdapter, + ChatClient chatClient, AgentResponseHandler responseHandler) { return new JobWorkerAgentRequestHandler( agentInitializer, @@ -297,7 +338,7 @@ public JobWorkerAgentRequestHandler aiAgentJobWorkerAgentRequestHandler( limitsValidator, messagesHandler, gatewayToolHandlers, - aiFrameworkAdapter, + chatClient, responseHandler); } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandler.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandler.java index a0e0164975a..3ef02758912 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandler.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandler.java @@ -17,6 +17,10 @@ import io.camunda.connector.agenticai.mcp.client.model.McpClientOperation; import io.camunda.connector.agenticai.mcp.client.model.McpClientOperationDefinitions; import io.camunda.connector.agenticai.mcp.client.model.McpToolDefinition; +import io.camunda.connector.agenticai.mcp.client.model.content.McpContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpDocumentContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpEmbeddedResourceContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpEmbeddedResourceContent.BlobDocumentResource; import io.camunda.connector.agenticai.mcp.client.model.content.McpTextContent; import io.camunda.connector.agenticai.mcp.client.model.result.McpClientCallToolResult; import io.camunda.connector.agenticai.mcp.client.model.result.McpClientListToolsResult; @@ -26,7 +30,9 @@ import io.camunda.connector.agenticai.model.tool.ToolDefinition; import io.camunda.connector.agenticai.util.CollectionUtils; import io.camunda.connector.agenticai.util.ObjectMapperConstants; +import io.camunda.connector.api.document.Document; import io.camunda.connector.api.error.ConnectorException; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -265,6 +271,41 @@ private ToolCallResult toolCallResultFromMcpToolCall(ToolCallResult toolCallResu return toolCallResultBuilder.build(); } + @Override + public List extractDocuments(ToolCallResult toolCallResult) { + if (!(toolCallResult.content() instanceof List contents)) { + // string-content optimization or unmanaged shape — nothing to walk + LOGGER.debug( + "MCP tool call result content is not a List ({}), skipping document extraction. toolCallId={}, toolName={}", + toolCallResult.content() != null + ? toolCallResult.content().getClass().getSimpleName() + : null, + toolCallResult.id(), + toolCallResult.name()); + return List.of(); + } + + final var documents = new ArrayList(); + for (var entry : contents) { + if (!(entry instanceof McpContent content)) { + continue; + } + switch (content) { + case McpDocumentContent documentContent -> documents.add(documentContent.document()); + case McpEmbeddedResourceContent embeddedResourceContent -> { + if (embeddedResourceContent.resource() + instanceof BlobDocumentResource blobDocumentResource) { + documents.add(blobDocumentResource.document()); + } + } + default -> { + // text/object/blob/resourceLink — no documents + } + } + } + return documents; + } + private Map mcpClientOperationAsMap(McpClientOperation mcpClientOperation) { return objectMapper.convertValue( mcpClientOperation, ObjectMapperConstants.STRING_OBJECT_MAP_TYPE_REFERENCE); diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/AssistantMessage.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/AssistantMessage.java index 202dad6b53b..679d40c050a 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/AssistantMessage.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/AssistantMessage.java @@ -9,15 +9,21 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; import io.camunda.connector.agenticai.model.AgenticAiRecord; import io.camunda.connector.agenticai.model.message.content.Content; import io.camunda.connector.agenticai.model.tool.ToolCall; import java.util.List; import java.util.Map; +import org.springframework.lang.Nullable; @AgenticAiRecord @JsonDeserialize(builder = AssistantMessage.AssistantMessageJacksonProxyBuilder.class) public record AssistantMessage( + @Nullable String modelId, + @Nullable String messageId, + @Nullable StopReason stopReason, + @Nullable AgentMetrics.TokenUsage usage, @JsonInclude(JsonInclude.Include.NON_EMPTY) List content, @JsonInclude(JsonInclude.Include.NON_EMPTY) List toolCalls, @JsonInclude(JsonInclude.Include.NON_EMPTY) Map metadata) diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/DocumentXmlTag.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/DocumentXmlTag.java new file mode 100644 index 00000000000..4579ccffe66 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/DocumentXmlTag.java @@ -0,0 +1,88 @@ +/* + * 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.agenticai.model.message; + +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentReference.CamundaDocumentReference; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; + +/** + * Represents a self-closing XML tag used to label a document in the synthetic user message, e.g.: + * + *

{@code
+ * 
+ * }
+ * + *

The tag provides correlation attributes so the model can match the document content block with + * the document reference in the tool result JSON. + */ +public record DocumentXmlTag( + @Nullable String toolCallId, + @Nullable String toolName, + @Nullable String documentShortId, + @Nullable String filename) { + + /** + * Creates a tag from a document and its tool call context. The document short ID is extracted as + * the first segment of the document's UUID identifier (e.g. "25ece9fa" from + * "25ece9fa-aeea-423d-98ed-67c1f08b137b"). + */ + public static DocumentXmlTag from(Document document, String toolCallId, String toolName) { + return new DocumentXmlTag( + toolCallId, toolName, extractDocumentShortId(document), extractFileName(document)); + } + + /** Creates a tag from a document without tool call context (e.g. for event documents). */ + public static DocumentXmlTag from(Document document) { + return from(document, null, null); + } + + /** Serializes this tag to an XML self-closing element string. */ + public String toXml() { + var sb = new StringBuilder(""); + return sb.toString(); + } + + private static void appendAttribute(StringBuilder sb, String name, String value) { + if (StringUtils.isNotBlank(value)) { + sb.append(" %s=\"%s\"".formatted(name, escapeXmlAttribute(value))); + } + } + + private static String escapeXmlAttribute(String value) { + if (value == null) { + return null; + } + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private static String extractDocumentShortId(Document document) { + if (document.reference() instanceof CamundaDocumentReference camundaRef) { + var documentId = camundaRef.getDocumentId(); + if (documentId != null) { + int dashIndex = documentId.indexOf('-'); + return dashIndex > 0 ? documentId.substring(0, dashIndex) : documentId; + } + } + return null; + } + + private static String extractFileName(Document document) { + return document.metadata() != null ? document.metadata().getFileName() : null; + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/StopReason.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/StopReason.java new file mode 100644 index 00000000000..b540e3b976a --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/StopReason.java @@ -0,0 +1,17 @@ +/* + * 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.agenticai.model.message; + +public enum StopReason { + STOP, + LENGTH, + TOOL_USE, + ERROR, + ABORTED, + CONTENT_FILTERED, + GUARDRAIL +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/UserMessage.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/UserMessage.java index 4cd1deafed6..053d11dd9b6 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/UserMessage.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/UserMessage.java @@ -23,6 +23,12 @@ public record UserMessage( @JsonInclude(JsonInclude.Include.NON_EMPTY) Map metadata) implements UserMessageBuilder.With, Message, ContentMessage { + /** + * Metadata key identifying a user message as containing documents extracted from tool call + * results. + */ + public static final String METADATA_TOOL_CALL_DOCUMENTS = "toolCallDocuments"; + public static UserMessageBuilder builder() { return UserMessageBuilder.builder(); } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/content/Content.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/content/Content.java index aabdba82e0b..8d6e7c5e261 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/content/Content.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/content/Content.java @@ -14,8 +14,10 @@ @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), @JsonSubTypes.Type(value = DocumentContent.class, name = "document"), - @JsonSubTypes.Type(value = ObjectContent.class, name = "object") + @JsonSubTypes.Type(value = ObjectContent.class, name = "object"), + @JsonSubTypes.Type(value = ReasoningContent.class, name = "reasoning") }) -public sealed interface Content permits TextContent, DocumentContent, ObjectContent { +public sealed interface Content + permits TextContent, DocumentContent, ObjectContent, ReasoningContent { Map metadata(); } diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/content/ReasoningContent.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/content/ReasoningContent.java new file mode 100644 index 00000000000..6a2c19a84e7 --- /dev/null +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/message/content/ReasoningContent.java @@ -0,0 +1,37 @@ +/* + * 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.agenticai.model.message.content; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.Map; +import org.springframework.lang.Nullable; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ReasoningContent( + String text, + @Nullable String signature, + boolean redacted, + @JsonInclude(JsonInclude.Include.NON_EMPTY) Map metadata) + implements Content { + + public ReasoningContent { + if (text == null) { + throw new IllegalArgumentException("Text cannot be null"); + } + // empty/blank text is allowed — providers emit empty thinking blocks + // with non-null signatures for redaction or roundtrip-only purposes + } + + public static ReasoningContent reasoningContent(String text) { + return new ReasoningContent(text, null, false, null); + } + + public static ReasoningContent reasoningContent(String text, String signature) { + return new ReasoningContent(text, signature, false, null); + } +} diff --git a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/tool/ToolCallResult.java b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/tool/ToolCallResult.java index 3e08d83e161..0a0194ea717 100644 --- a/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/tool/ToolCallResult.java +++ b/connectors/agentic-ai/src/main/java/io/camunda/connector/agenticai/model/tool/ToolCallResult.java @@ -12,7 +12,9 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import io.camunda.connector.agenticai.model.AgenticAiRecord; +import io.camunda.connector.agenticai.model.message.content.Content; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.lang.Nullable; @@ -22,6 +24,7 @@ public record ToolCallResult( @Nullable String id, @Nullable String name, @Nullable Object content, + @Nullable @JsonInclude(JsonInclude.Include.NON_EMPTY) List contentBlocks, @JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonAnySetter @JsonAnyGetter Map properties) implements ToolCallResultBuilder.With { diff --git a/connectors/agentic-ai/src/main/resources/capabilities/model-capabilities.yaml b/connectors/agentic-ai/src/main/resources/capabilities/model-capabilities.yaml new file mode 100644 index 00000000000..72150a419ba --- /dev/null +++ b/connectors/agentic-ai/src/main/resources/capabilities/model-capabilities.yaml @@ -0,0 +1,218 @@ +# Model capability matrix for the agentic-ai connector. +# +# This file is loaded as a low-precedence Spring PropertySource by +# `AgenticAiCapabilitiesConfiguration#setEnvironment` when the configuration +# bean is instantiated. Library consumers can override or extend any value via +# `application.yml` using the same property prefix, no full-file replacement +# required: +# +# camunda.connector.agenticai.aiagent.framework.capabilities: +# anthropic-messages: +# models: +# claude-opus-4-7: # map key reused -> override existing entry +# capabilities: +# max-output-tokens: 64000 # only this field overridden +# +# Structure under `capabilities`: +# : +# defaults: capability block applied to every model entry in the family +# models: map of opaque identifiers to entries. Each entry has one of: +# - `id` (defaults to the map key when neither is set) +# - `pattern` (glob using `*` only; longest match wins at +# resolve-time) +# plus an optional `aliases` list (id entries only) and a +# `capabilities` overlay. +# NB: `*` and `.` in property keys interfere with Spring Boot's +# map binding, so glob characters always live in the +# `pattern` field, never in the map key. +# +# Merge semantics (Spring Boot config + ADR-005 capability matrix): +# * Maps merge recursively (sub-keys of input-modalities / output-modalities +# are inherited individually) +# * Lists replace wholesale +# * Scalars and booleans replace +# +# Resolution chain (per ADR-005 §"Resolution order"): +# 1. Connector config override (per-call, future) +# 2. Exact id or alias match +# 3. Pattern (longest-match wins) +# 4. Conservative defaults (text-only, all flags false) +# +# Modality vocabulary: text | image | document | audio | video. Modality lists per +# location (user-message, tool-result, assistant-message) are symmetric. + +camunda: + connector: + agenticai: + aiagent: + framework: + capabilities: + + anthropic-messages: + defaults: + input-modalities: + user-message: [text, image, document] + tool-result: [text, image, document] + output-modalities: + assistant-message: [text] + supports-reasoning: false + supports-reasoning-signature-roundtrip: false + supports-prompt-caching: true + supports-parallel-tool-calls: true + context-window: 200000 + max-output-tokens: 8192 + models: + claude-opus-4: + pattern: claude-opus-4-* + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + max-output-tokens: 32000 + claude-sonnet-4: + pattern: claude-sonnet-4-* + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + max-output-tokens: 64000 + claude-haiku-4: + pattern: claude-haiku-4-* + capabilities: + input-modalities: + # tool-result inherited from defaults + user-message: [text, image] + max-output-tokens: 16000 + claude-3-7-sonnet: + pattern: claude-3-7-sonnet-* + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + max-output-tokens: 64000 + claude-3-5-sonnet: + pattern: claude-3-5-sonnet-* + capabilities: {} + claude-3-5-haiku: + pattern: claude-3-5-haiku-* + capabilities: + input-modalities: + user-message: [text] + tool-result: [text] + claude-fallback: + pattern: claude-* + capabilities: {} + + # Chat Completions tool messages are text-only (the SDK enforces + # ChatCompletionContentPartText for tool message content arrays); + # multimodal tool results require the Responses API. + openai-completions: + defaults: + input-modalities: + user-message: [text, image, document] + tool-result: [text] + output-modalities: + assistant-message: [text] + supports-reasoning: false + supports-reasoning-signature-roundtrip: false + supports-prompt-caching: true + supports-parallel-tool-calls: true + context-window: 128000 + max-output-tokens: 4096 + models: + gpt-5: + pattern: gpt-5* + capabilities: + supports-reasoning: true + context-window: 400000 + max-output-tokens: 128000 + o1: + pattern: o1* + capabilities: + supports-reasoning: true + supports-parallel-tool-calls: false + context-window: 200000 + max-output-tokens: 100000 + o3: + pattern: o3* + capabilities: + supports-reasoning: true + context-window: 200000 + max-output-tokens: 100000 + o4: + pattern: o4* + capabilities: + supports-reasoning: true + context-window: 200000 + max-output-tokens: 100000 + gpt-4o: + pattern: gpt-4o* + capabilities: + input-modalities: + user-message: [text, image, audio] + max-output-tokens: 16384 + gpt-4-1: + pattern: gpt-4.1* + capabilities: + context-window: 1000000 + max-output-tokens: 32768 + gpt-fallback: + pattern: gpt-* + capabilities: {} + + # Responses API: function call outputs accept text, image and file + # content items per the SDK's FunctionCallOutput.Output union. + openai-responses: + defaults: + input-modalities: + user-message: [text, image, document] + tool-result: [text, image, document] + output-modalities: + assistant-message: [text] + supports-reasoning: false + supports-reasoning-signature-roundtrip: false + supports-prompt-caching: true + supports-parallel-tool-calls: true + context-window: 128000 + max-output-tokens: 4096 + models: + gpt-5: + pattern: gpt-5* + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + context-window: 400000 + max-output-tokens: 128000 + o1: + pattern: o1* + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + supports-parallel-tool-calls: false + context-window: 200000 + max-output-tokens: 100000 + o3: + pattern: o3* + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + context-window: 200000 + max-output-tokens: 100000 + o4: + pattern: o4* + capabilities: + supports-reasoning: true + supports-reasoning-signature-roundtrip: true + context-window: 200000 + max-output-tokens: 100000 + gpt-4o: + pattern: gpt-4o* + capabilities: + input-modalities: + user-message: [text, image, audio] + max-output-tokens: 16384 + gpt-4-1: + pattern: gpt-4.1* + capabilities: + context-window: 1000000 + max-output-tokens: 32768 + gpt-fallback: + pattern: gpt-* + capabilities: {} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandlerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandlerTest.java index 41a0ea5b2be..003baadef41 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandlerTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/a2a/client/agentic/tool/A2aGatewayToolHandlerTest.java @@ -24,10 +24,12 @@ import io.camunda.connector.agenticai.a2a.client.common.model.result.A2aTask; import io.camunda.connector.agenticai.a2a.client.common.model.result.A2aTaskStatus; import io.camunda.connector.agenticai.aiagent.model.AgentContext; +import io.camunda.connector.agenticai.model.message.content.DocumentContent; import io.camunda.connector.agenticai.model.message.content.TextContent; import io.camunda.connector.agenticai.model.tool.GatewayToolDefinition; import io.camunda.connector.agenticai.model.tool.ToolCall; import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.api.document.Document; import java.io.IOException; import java.io.InputStream; import java.util.List; @@ -457,6 +459,136 @@ void handlesEmptyA2aClientsList() { assertThat(result).isEqualTo(toolCallResults); } + + @Test + void convertsRawMapContentIntoTypedA2aSendMessageResult() { + var agentContext = AgentContext.empty().withProperty(PROPERTY_A2A_CLIENTS, List.of("a2a1")); + + // simulate raw content as it arrives from the engine: a Map tree + var rawContent = + Map.of( + "kind", + "message", + "role", + "agent", + "messageId", + "msg-1", + "contextId", + "ctx-1", + "contents", + List.of(Map.of("type", "text", "text", "Agent reply"))); + var toolCallResults = List.of(createToolCallResultWithContent("call1", "a2a1", rawContent)); + + var result = handler.transformToolCallResults(agentContext, toolCallResults); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("A2A_a2a1"); + assertThat(result.getFirst().content()) + .isInstanceOfSatisfying( + A2aMessage.class, + message -> { + assertThat(message.role()).isEqualTo(A2aMessage.Role.AGENT); + assertThat(message.contents()) + .containsExactly(TextContent.textContent("Agent reply")); + }); + } + } + + @Nested + class ExtractDocuments { + + @Test + void extractsDocumentFromA2aMessageContents() { + var document = mock(Document.class); + var message = + A2aMessage.builder() + .role(A2aMessage.Role.AGENT) + .messageId("msg-1") + .contextId("ctx-1") + .contents( + List.of( + TextContent.textContent("description"), + DocumentContent.documentContent(document))) + .build(); + var toolCallResult = createToolCallResultWithContent("call1", "A2A_a2a1", message); + + var documents = handler.extractDocuments(toolCallResult); + + assertThat(documents).containsExactly(document); + } + + @Test + void extractsDocumentsFromA2aTaskArtifactsAndHistory() { + var artifactDoc = mock(Document.class); + var historyDoc = mock(Document.class); + + var artifact = + A2aArtifact.builder() + .artifactId("art-1") + .contents(List.of(DocumentContent.documentContent(artifactDoc))) + .build(); + var historyMessage = + A2aMessage.builder() + .role(A2aMessage.Role.AGENT) + .messageId("msg-1") + .contextId("ctx-1") + .contents(List.of(DocumentContent.documentContent(historyDoc))) + .build(); + var task = + A2aTask.builder() + .id("task-1") + .contextId("ctx-1") + .status(A2aTaskStatus.builder().state(A2aTaskStatus.TaskState.COMPLETED).build()) + .artifacts(List.of(artifact)) + .history(List.of(historyMessage)) + .build(); + var toolCallResult = createToolCallResultWithContent("call1", "A2A_a2a1", task); + + var documents = handler.extractDocuments(toolCallResult); + + // artifacts before history + assertThat(documents).containsExactly(artifactDoc, historyDoc); + } + + @Test + void returnsEmptyListWhenContentIsNotA2aSendMessageResult() { + var toolCallResult = createToolCallResultWithContent("call1", "A2A_a2a1", "plain text"); + + var documents = handler.extractDocuments(toolCallResult); + + assertThat(documents).isEmpty(); + } + + @Test + void returnsEmptyListWhenA2aMessageHasNoDocuments() { + var message = + A2aMessage.builder() + .role(A2aMessage.Role.AGENT) + .messageId("msg-1") + .contextId("ctx-1") + .contents(List.of(TextContent.textContent("only text"))) + .build(); + var toolCallResult = createToolCallResultWithContent("call1", "A2A_a2a1", message); + + var documents = handler.extractDocuments(toolCallResult); + + assertThat(documents).isEmpty(); + } + + @Test + void returnsEmptyListWhenA2aTaskHasNoArtifactsOrHistory() { + var task = + A2aTask.builder() + .id("task-1") + .contextId("ctx-1") + .status(A2aTaskStatus.builder().state(A2aTaskStatus.TaskState.COMPLETED).build()) + .build(); + var toolCallResult = createToolCallResultWithContent("call1", "A2A_a2a1", task); + + var documents = handler.extractDocuments(toolCallResult); + + assertThat(documents).isEmpty(); + } } @SuppressWarnings("unchecked") diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerTest.java index 8869379b933..f2369061219 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentMessagesHandlerTest.java @@ -44,7 +44,12 @@ import io.camunda.connector.agenticai.model.message.content.DocumentContent; import io.camunda.connector.agenticai.model.tool.ToolCallResult; import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentCreationRequest; +import io.camunda.connector.api.document.DocumentReference.CamundaDocumentReference; import io.camunda.connector.api.error.ConnectorException; +import io.camunda.connector.runtime.core.document.DocumentFactoryImpl; +import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; +import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -74,6 +79,9 @@ class AgentMessagesHandlerTest { private static final String EVENT_WAIT_FOR_TOOL_CALL_RESULTS_EMPTY_MESSAGE = "An event was triggered but no content was returned. Execution waited for all in-flight tool executions to complete before proceeding."; + private static final InMemoryDocumentStore documentStore = InMemoryDocumentStore.INSTANCE; + private static final DocumentFactoryImpl documentFactory = new DocumentFactoryImpl(documentStore); + @Mock private GatewayToolHandlerRegistry gatewayToolHandlers; @Mock(answer = Answers.RETURNS_DEEP_STUBS) @@ -85,11 +93,32 @@ class AgentMessagesHandlerTest { @BeforeEach void setUp() { + documentStore.clear(); systemPromptComposer = new SystemPromptComposerImpl(List.of()); + // No stub for handlerForToolDefinition: the mocked registry returns Optional.empty() by + // default, so the extractor falls back to the generic content-tree walker for every result. + // Individual tests can override this when a gateway-handler-specific extraction is needed. messagesHandler = new AgentMessagesHandlerImpl(gatewayToolHandlers, systemPromptComposer); runtimeMemory = spy(new DefaultRuntimeMemory()); } + private static Document createDocument(String content, String contentType, String filename) { + return documentFactory.create( + DocumentCreationRequest.from(content.getBytes(StandardCharsets.UTF_8)) + .contentType(contentType) + .fileName(filename) + .build()); + } + + private static String documentShortId(Document document) { + if (document.reference() instanceof CamundaDocumentReference ref) { + var id = ref.getDocumentId(); + int dash = id.indexOf('-'); + return dash > 0 ? id.substring(0, dash) : id; + } + return null; + } + @Nested class SystemMessagesTest { @@ -752,6 +781,159 @@ void interruptsToolCallsOnEventResultsWhenEventContentIsEmpty(Object eventConten EVENT_INTERRUPT_TOOL_CALLS_EMPTY_MESSAGE))))); } + @Test + void doesNotEmitSyntheticUserMessageEvenWhenToolResultsContainDocuments() { + // Document extraction is the responsibility of ToolCallResultStrategy at the ChatClient + // boundary (ADR-005 Phase E3). AgentMessagesHandlerImpl no longer creates synthetic + // UserMessages — it adds only the ToolCallResultMessage. Strategy-side assertions live in + // ToolCallResultStrategyImplTest. + final var doc1 = createDocument("weather data", "text/plain", "weather.txt"); + final var doc2 = createDocument("time report", "application/pdf", "report.pdf"); + + final var toolCallResultsWithDocs = + List.of( + ToolCallResult.builder() + .id("abcdef") + .name("getWeather") + .content(Map.of("result", "Sunny", "attachment", doc1)) + .build(), + ToolCallResult.builder() + .id("fedcba") + .name("getDateTime") + .content(Map.of("report", doc2)) + .build()); + + final var assistantMessage = + assistantMessage("Assistant message with tool calls", TOOL_CALLS); + runtimeMemory.addMessage(assistantMessage); + + when(gatewayToolHandlers.transformToolCallResults(AGENT_CONTEXT, toolCallResultsWithDocs)) + .thenReturn(toolCallResultsWithDocs); + + final var addedMessages = + messagesHandler.addUserMessages( + executionContext, + AGENT_CONTEXT, + runtimeMemory, + userPromptWithDocuments, + toolCallResultsWithDocs); + + assertThat(addedMessages).hasSize(1).first().isInstanceOf(ToolCallResultMessage.class); + } + + @Test + void doesNotCreateDocumentUserMessageWhenNoDocumentsInToolResults() { + final var assistantMessage = + assistantMessage("Assistant message with tool calls", TOOL_CALLS); + runtimeMemory.addMessage(assistantMessage); + + when(gatewayToolHandlers.transformToolCallResults(AGENT_CONTEXT, TOOL_CALL_RESULTS)) + .thenReturn(TOOL_CALL_RESULTS.stream().toList()); + + final var addedMessages = + messagesHandler.addUserMessages( + executionContext, + AGENT_CONTEXT, + runtimeMemory, + userPromptWithDocuments, + TOOL_CALL_RESULTS); + + // no document user message - only the tool call result message + assertThat(addedMessages).hasSize(1).first().isInstanceOf(ToolCallResultMessage.class); + } + + @Test + void preservesToolResultThenEventOrderWithoutSyntheticUserMessage() { + // After ADR-005 E3, AgentMessagesHandler emits [ToolCallResultMessage, EventMessages] + // only. ToolCallResultStrategy (ChatClient boundary) inserts the synthetic + // toolCallDocuments=true UserMessage between them at chat time; that path is covered + // in ChatClientImplTest + ToolCallResultStrategyImplTest. + when(executionContext.events()) + .thenReturn(new EventHandlingConfiguration(WAIT_FOR_TOOL_CALL_RESULTS)); + + final var doc = createDocument("weather data", "text/plain", "weather.txt"); + final var toolCallResultsWithDocsAndEvents = + List.of( + ToolCallResult.builder() + .id("abcdef") + .name("getWeather") + .content(Map.of("file", doc)) + .build(), + ToolCallResult.builder().id("fedcba").name("getDateTime").content("15:00").build(), + EVENT_TOOL_CALL_RESULTS.get(0)); + + final var assistantMessage = + assistantMessage("Assistant message with tool calls", TOOL_CALLS); + runtimeMemory.addMessage(assistantMessage); + + when(gatewayToolHandlers.transformToolCallResults(eq(AGENT_CONTEXT), anyList())) + .thenAnswer(invocationOnMock -> invocationOnMock.getArgument(1)); + + final var addedMessages = + messagesHandler.addUserMessages( + executionContext, + AGENT_CONTEXT, + runtimeMemory, + userPromptWithDocuments, + toolCallResultsWithDocsAndEvents); + + assertThat(addedMessages) + .hasSize(2) + .satisfiesExactly( + message -> assertThat(message).isInstanceOf(ToolCallResultMessage.class), + message -> + assertThat(message) + .isInstanceOfSatisfying( + UserMessage.class, + um -> + assertThat(um.content()) + .first() + .isEqualTo(textContent("Event data")))); + } + + @Test + void appendsDocumentsToEventMessage() { + final var doc = createDocument("event data", "application/pdf", "event.pdf"); + final var shortId = documentShortId(doc); + final var eventWithDoc = + ToolCallResult.builder().content(Map.of("text", "event", "file", doc)).build(); + + final var assistantMessage = + assistantMessage("Assistant message with tool calls", TOOL_CALLS); + runtimeMemory.addMessage(assistantMessage); + + when(gatewayToolHandlers.transformToolCallResults(eq(AGENT_CONTEXT), anyList())) + .thenReturn(TOOL_CALL_RESULTS.stream().toList()); + + final var addedMessages = + messagesHandler.addUserMessages( + executionContext, + AGENT_CONTEXT, + runtimeMemory, + userPromptWithDocuments, + List.of(TOOL_CALL_RESULTS.get(0), TOOL_CALL_RESULTS.get(1), eventWithDoc)); + + // find the event message (last one) + final var eventMessage = addedMessages.getLast(); + assertThat(eventMessage) + .isInstanceOfSatisfying( + UserMessage.class, + um -> + assertThat(um.content()) + .hasSize(3) + .satisfiesExactly( + c -> + assertThat(c) + .isEqualTo(objectContent(Map.of("text", "event", "file", doc))), + c -> + assertThat(c) + .isEqualTo( + textContent( + "" + .formatted(shortId))), + c -> assertThat(c).isEqualTo(DocumentContent.documentContent(doc)))); + } + static List toolCallResultsWithEventsAndEventBehavior() { final List arguments = new ArrayList<>(); toolCallResultsWithEvents() diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentResponseHandlerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentResponseHandlerTest.java index 15d40991d02..714255b8aed 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentResponseHandlerTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/AgentResponseHandlerTest.java @@ -206,7 +206,7 @@ void returnsAssistantMessageIfConfigured() { static Stream emptyAssistantMessages() { return Stream.of( - new AssistantMessage(List.of(), List.of(), Map.of()), + AssistantMessage.builder().content(List.of()).toolCalls(List.of()).build(), assistantMessage(List.of(DocumentContent.documentContent(mock(Document.class))))); } } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/ContentTreeDocumentWalkerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/ContentTreeDocumentWalkerTest.java new file mode 100644 index 00000000000..2d0228cb748 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/ContentTreeDocumentWalkerTest.java @@ -0,0 +1,135 @@ +/* + * 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.agenticai.aiagent.agent; + +import static io.camunda.connector.agenticai.aiagent.agent.ContentTreeDocumentWalker.extractDocumentsFromContent; +import static org.assertj.core.api.Assertions.assertThat; + +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentCreationRequest; +import io.camunda.connector.api.document.DocumentFactory; +import io.camunda.connector.runtime.core.document.DocumentFactoryImpl; +import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ContentTreeDocumentWalkerTest { + + private final InMemoryDocumentStore documentStore = InMemoryDocumentStore.INSTANCE; + private final DocumentFactory documentFactory = new DocumentFactoryImpl(documentStore); + + @BeforeEach + void setUp() { + documentStore.clear(); + } + + @Test + void extractsRootLevelDocument() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + assertThat(extractDocumentsFromContent(doc)).containsExactly(doc); + } + + @Test + void extractsDocumentFromMapValue() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + assertThat(extractDocumentsFromContent(Map.of("file", doc, "key", "value"))) + .containsExactly(doc); + } + + @Test + void extractsDocumentFromList() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + assertThat(extractDocumentsFromContent(List.of("text", doc, 42))).containsExactly(doc); + } + + @Test + void extractsDeeplyNestedDocuments() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var nested = Map.of("level1", Map.of("level2", List.of(Map.of("file", doc)))); + assertThat(extractDocumentsFromContent(nested)).containsExactly(doc); + } + + @Test + void extractsMultipleDocuments() { + final var doc1 = createDocument("hello", "text/plain", "test.txt"); + final var doc2 = createDocument("", "application/pdf", "report.pdf"); + final var content = new LinkedHashMap(); + content.put("text", doc1); + content.put("report", doc2); + content.put("other", "value"); + + assertThat(extractDocumentsFromContent(content)).containsExactly(doc1, doc2); + } + + @Test + void returnsEmptyForContentWithoutDocuments() { + assertThat(extractDocumentsFromContent(Map.of("key", "value", "list", List.of(1, 2, 3)))) + .isEmpty(); + } + + @ParameterizedTest + @MethodSource("nullAndScalars") + void returnsEmptyForNullOrScalarContent(Object content) { + assertThat(extractDocumentsFromContent(content)).isEmpty(); + } + + static Stream nullAndScalars() { + return Stream.of( + Arguments.of((Object) null), Arguments.of("text"), Arguments.of(42), Arguments.of(true)); + } + + @Test + void extractsDocumentFromArray() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + assertThat(extractDocumentsFromContent(new Object[] {"text", doc, 42})).containsExactly(doc); + } + + @Test + void extractsDocumentFromNestedArray() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var nested = Map.of("items", new Object[] {doc}); + assertThat(extractDocumentsFromContent(nested)).containsExactly(doc); + } + + @Test + void handlesNullValuesInMap() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var content = new LinkedHashMap(); + content.put("file", doc); + content.put("missing", null); + + assertThat(extractDocumentsFromContent(content)).containsExactly(doc); + } + + @Test + void handlesNullElementsInList() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var content = new ArrayList<>(); + content.add(doc); + content.add(null); + content.add("text"); + + assertThat(extractDocumentsFromContent(content)).containsExactly(doc); + } + + private Document createDocument(String content, String contentType, String filename) { + return documentFactory.create( + DocumentCreationRequest.from(content.getBytes(StandardCharsets.UTF_8)) + .contentType(contentType) + .fileName(filename) + .build()); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandlerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandlerTest.java index b7e5a82e897..b8321293c6d 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandlerTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/JobWorkerAgentRequestHandlerTest.java @@ -29,8 +29,8 @@ import io.camunda.connector.agenticai.aiagent.AiAgentJobWorker; import io.camunda.connector.agenticai.aiagent.agent.AgentInitializationResult.AgentContextInitializationResult; import io.camunda.connector.agenticai.aiagent.agent.AgentInitializationResult.AgentResponseInitializationResult; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkAdapter; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClientResult; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStore; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistry; import io.camunda.connector.agenticai.aiagent.memory.conversation.inprocess.InProcessConversationContext; @@ -93,7 +93,7 @@ class JobWorkerAgentRequestHandlerTest { @Mock private AgentLimitsValidator limitsValidator; @Mock private AgentMessagesHandler messagesHandler; @Mock private GatewayToolHandlerRegistry gatewayToolHandlers; - @Mock private AiFrameworkAdapter framework; + @Mock private ChatClient chatClient; @Mock private AgentResponseHandler responseHandler; private ConversationStore conversationStore; @@ -136,7 +136,7 @@ void directlyReturnsAgentResponseWhenInitializationReturnsResponse() { assertThat(response.responseValue()).isNotNull().isEqualTo(agentResponse); verifyNoInteractions( - limitsValidator, messagesHandler, gatewayToolHandlers, framework, responseHandler); + limitsValidator, messagesHandler, gatewayToolHandlers, chatClient, responseHandler); } @Test @@ -182,7 +182,9 @@ void orchestratesRequestExecutionWithoutToolCalls() { assertThat(agentResponse.context()).isEqualTo(response.variables().get("agentContext")); assertThat(agentResponse.context().state()).isEqualTo(AgentState.READY); assertThat(agentResponse.context().metrics()) - .isEqualTo(new AgentMetrics(1, new TokenUsage(10, 20))); + .isEqualTo( + new AgentMetrics( + 1, TokenUsage.builder().inputTokenCount(10).outputTokenCount(20).build())); assertThat(agentResponse.context().conversation()) .isNotNull() .isInstanceOfSatisfying( @@ -248,7 +250,9 @@ void orchestratesRequestExecutionWithToolCalls() { assertThat(agentResponse).isNotNull(); assertThat(agentResponse.context().state()).isEqualTo(AgentState.READY); assertThat(agentResponse.context().metrics()) - .isEqualTo(new AgentMetrics(1, new TokenUsage(10, 20))); + .isEqualTo( + new AgentMetrics( + 1, TokenUsage.builder().inputTokenCount(10).outputTokenCount(20).build())); assertThat(agentResponse.context().conversation()) .isNotNull() .isInstanceOfSatisfying( @@ -345,7 +349,9 @@ void orchestratesRequestExecutionWithInterruptedToolCall() { assertThat(agentResponse.context()).isEqualTo(response.variables().get("agentContext")); assertThat(agentResponse.context().state()).isEqualTo(AgentState.READY); assertThat(agentResponse.context().metrics()) - .isEqualTo(new AgentMetrics(1, new TokenUsage(10, 20))); + .isEqualTo( + new AgentMetrics( + 1, TokenUsage.builder().inputTokenCount(10).outputTokenCount(20).build())); assertThat(agentResponse.context().conversation()) .isNotNull() .isInstanceOfSatisfying( @@ -424,7 +430,7 @@ void silentlyCompletesJobWhenNoUserMessageContent() { assertThat(response.cancelRemainingInstances()).isFalse(); assertThat(response.elementActivations()).isEmpty(); - verifyNoInteractions(framework); + verifyNoInteractions(chatClient); } private RuntimeMemory setupRuntimeMemorySizeTest(MemoryConfiguration memoryConfiguration) { @@ -518,25 +524,25 @@ private void mockInterruptedToolCall(List toolCallResults) { } private void mockFrameworkExecution(AssistantMessage assistantMessage) { - when(framework.executeChatRequest( - eq(agentExecutionContext), any(AgentContext.class), runtimeMemoryCaptor.capture())) + when(chatClient.chat( + eq(agentExecutionContext), + any(AgentContext.class), + runtimeMemoryCaptor.capture(), + any())) .thenAnswer( i -> { final var agentContext = i.getArgument(1, AgentContext.class); - return new TestFrameworkChatResponse( + return new ChatClientResult( agentContext.withMetrics( agentContext .metrics() .incrementModelCalls(1) - .incrementTokenUsage(new TokenUsage(10, 20))), - assistantMessage, - Map.of("message", assistantMessage.content())); + .incrementTokenUsage( + TokenUsage.builder() + .inputTokenCount(10) + .outputTokenCount(20) + .build())), + assistantMessage); }); } - - private record TestFrameworkChatResponse( - AgentContext agentContext, - AssistantMessage assistantMessage, - Map rawChatResponse) - implements AiFrameworkChatResponse> {} } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandlerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandlerTest.java index def3e654177..9a1090dc49f 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandlerTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/OutboundConnectorAgentRequestHandlerTest.java @@ -25,8 +25,8 @@ import io.camunda.connector.agenticai.aiagent.agent.AgentInitializationResult.AgentContextInitializationResult; import io.camunda.connector.agenticai.aiagent.agent.AgentInitializationResult.AgentResponseInitializationResult; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkAdapter; -import io.camunda.connector.agenticai.aiagent.framework.AiFrameworkChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClientResult; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistry; import io.camunda.connector.agenticai.aiagent.memory.conversation.inprocess.InProcessConversationContext; import io.camunda.connector.agenticai.aiagent.memory.conversation.inprocess.InProcessConversationStore; @@ -83,7 +83,7 @@ class OutboundConnectorAgentRequestHandlerTest { @Mock private AgentLimitsValidator limitsValidator; @Mock private AgentMessagesHandler messagesHandler; @Mock private GatewayToolHandlerRegistry gatewayToolHandlers; - @Mock private AiFrameworkAdapter framework; + @Mock private ChatClient chatClient; @Mock private AgentResponseHandler responseHandler; @Mock private OutboundConnectorAgentExecutionContext agentExecutionContext; @@ -118,7 +118,7 @@ void directlyReturnsAgentResponseWhenInitializationReturnsResponse() { assertThat(response.agentResponse()).isEqualTo(agentResponse); verifyNoInteractions( - limitsValidator, messagesHandler, gatewayToolHandlers, framework, responseHandler); + limitsValidator, messagesHandler, gatewayToolHandlers, chatClient, responseHandler); } @Test @@ -163,7 +163,9 @@ void orchestratesRequestExecutionWithoutToolCalls() { assertThat(agentResponse).isNotNull(); assertThat(agentResponse.context().state()).isEqualTo(AgentState.READY); assertThat(agentResponse.context().metrics()) - .isEqualTo(new AgentMetrics(1, new TokenUsage(10, 20))); + .isEqualTo( + new AgentMetrics( + 1, TokenUsage.builder().inputTokenCount(10).outputTokenCount(20).build())); assertThat(agentResponse.context().conversation()) .isNotNull() .isInstanceOfSatisfying( @@ -224,7 +226,9 @@ void orchestratesRequestExecutionWithToolCalls() { assertThat(agentResponse).isNotNull(); assertThat(agentResponse.context().state()).isEqualTo(AgentState.READY); assertThat(agentResponse.context().metrics()) - .isEqualTo(new AgentMetrics(1, new TokenUsage(10, 20))); + .isEqualTo( + new AgentMetrics( + 1, TokenUsage.builder().inputTokenCount(10).outputTokenCount(20).build())); assertThat(agentResponse.context().conversation()) .isNotNull() .isInstanceOfSatisfying( @@ -380,25 +384,25 @@ private void mockUserPrompt( } private void mockFrameworkExecution(AssistantMessage assistantMessage) { - when(framework.executeChatRequest( - eq(agentExecutionContext), any(AgentContext.class), runtimeMemoryCaptor.capture())) + when(chatClient.chat( + eq(agentExecutionContext), + any(AgentContext.class), + runtimeMemoryCaptor.capture(), + any())) .thenAnswer( i -> { final var agentContext = i.getArgument(1, AgentContext.class); - return new TestFrameworkChatResponse( + return new ChatClientResult( agentContext.withMetrics( agentContext .metrics() .incrementModelCalls(1) - .incrementTokenUsage(new TokenUsage(10, 20))), - assistantMessage, - Map.of("message", assistantMessage.content())); + .incrementTokenUsage( + TokenUsage.builder() + .inputTokenCount(10) + .outputTokenCount(20) + .build())), + assistantMessage); }); } - - private record TestFrameworkChatResponse( - AgentContext agentContext, - AssistantMessage assistantMessage, - Map rawChatResponse) - implements AiFrameworkChatResponse> {} } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/ToolCallResultDocumentExtractorTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/ToolCallResultDocumentExtractorTest.java new file mode 100644 index 00000000000..0a8f632eb91 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/agent/ToolCallResultDocumentExtractorTest.java @@ -0,0 +1,238 @@ +/* + * 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.agenticai.aiagent.agent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandler; +import io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandlerRegistry; +import io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandlerRegistryImpl; +import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentCreationRequest; +import io.camunda.connector.api.document.DocumentFactory; +import io.camunda.connector.runtime.core.document.DocumentFactoryImpl; +import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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 ToolCallResultDocumentExtractorTest { + + private final InMemoryDocumentStore documentStore = InMemoryDocumentStore.INSTANCE; + private final DocumentFactory documentFactory = new DocumentFactoryImpl(documentStore); + + @Mock private GatewayToolHandlerRegistry registry; + + private ToolCallResultDocumentExtractor extractor; + + @BeforeEach + void setUp() { + documentStore.clear(); + extractor = new ToolCallResultDocumentExtractor(registry); + } + + @Test + void usesContentTreeWalkerWhenNoHandlerManagesTheToolCall() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var result = + ToolCallResult.builder() + .id("call_1") + .name("plain_bpmn_tool") + .content(Map.of("file", doc)) + .build(); + + when(registry.handlerForToolDefinition("plain_bpmn_tool")).thenReturn(Optional.empty()); + + final var extracted = extractor.extractDocuments(List.of(result)); + + assertThat(extracted).hasSize(1); + assertThat(extracted.getFirst().documents()).containsExactly(doc); + } + + @Test + void delegatesToHandlerWhenManaged(@Mock GatewayToolHandler handler) { + final var doc = createDocument("typed", "text/plain", "typed.txt"); + final var typedContent = new TypedHandlerContent(doc); + final var result = + ToolCallResult.builder().id("call_1").name("typed_tool").content(typedContent).build(); + + when(registry.handlerForToolDefinition("typed_tool")).thenReturn(Optional.of(handler)); + when(handler.extractDocuments(result)).thenReturn(List.of(doc)); + + final var extracted = extractor.extractDocuments(List.of(result)); + + assertThat(extracted).hasSize(1); + assertThat(extracted.getFirst().documents()).containsExactly(doc); + verify(handler).extractDocuments(result); + } + + @Test + void doesNotConsultHandlerWhenContentHasNoDocumentsAndIsUnmanaged() { + final var result = + ToolCallResult.builder().id("call_1").name("unknown").content("plain text").build(); + when(registry.handlerForToolDefinition("unknown")).thenReturn(Optional.empty()); + + assertThat(extractor.extractDocuments(List.of(result))).isEmpty(); + } + + @Test + void groupsExtractedDocumentsByToolCall() { + final var doc1 = createDocument("hello", "text/plain", "test.txt"); + final var doc2 = createDocument("", "application/pdf", "report.pdf"); + + final var result1 = + ToolCallResult.builder().id("call_1").name("tool_a").content(Map.of("file", doc1)).build(); + final var result2 = + ToolCallResult.builder() + .id("call_2") + .name("tool_b") + .content(Map.of("report", doc2)) + .build(); + + when(registry.handlerForToolDefinition(any())).thenReturn(Optional.empty()); + + final var extracted = extractor.extractDocuments(List.of(result1, result2)); + + assertThat(extracted).hasSize(2); + assertThat(extracted.get(0)) + .satisfies( + e -> { + assertThat(e.toolCallId()).isEqualTo("call_1"); + assertThat(e.toolCallName()).isEqualTo("tool_a"); + assertThat(e.documents()).containsExactly(doc1); + }); + assertThat(extracted.get(1)) + .satisfies( + e -> { + assertThat(e.toolCallId()).isEqualTo("call_2"); + assertThat(e.toolCallName()).isEqualTo("tool_b"); + assertThat(e.documents()).containsExactly(doc2); + }); + } + + @Test + void excludesToolCallsWithoutDocuments() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + + final var withDoc = + ToolCallResult.builder().id("call_1").name("tool_a").content(Map.of("file", doc)).build(); + final var withoutDoc = + ToolCallResult.builder().id("call_2").name("tool_b").content("plain text result").build(); + + when(registry.handlerForToolDefinition(any())).thenReturn(Optional.empty()); + + final var extracted = extractor.extractDocuments(List.of(withDoc, withoutDoc)); + + assertThat(extracted).hasSize(1); + assertThat(extracted.getFirst().toolCallId()).isEqualTo("call_1"); + } + + @Test + void handlesNullNameAndId() { + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var result = ToolCallResult.builder().content(Map.of("file", doc)).build(); + + when(registry.handlerForToolDefinition(null)).thenReturn(Optional.empty()); + + final var extracted = extractor.extractDocuments(List.of(result)); + + assertThat(extracted).hasSize(1); + assertThat(extracted.getFirst()) + .satisfies( + e -> { + assertThat(e.toolCallId()).isNull(); + assertThat(e.toolCallName()).isNull(); + }); + } + + @Test + void integrationWithRealRegistry_fallsBackToWalkerWhenNoHandlerMatches() { + final var realExtractor = + new ToolCallResultDocumentExtractor(new GatewayToolHandlerRegistryImpl(List.of())); + + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var result = + ToolCallResult.builder() + .id("call_1") + .name("plain_bpmn_tool") + .content(Map.of("attachment", doc)) + .build(); + + final var extracted = realExtractor.extractDocuments(List.of(result)); + + assertThat(extracted).hasSize(1); + assertThat(extracted.getFirst().documents()).containsExactly(doc); + } + + @Test + void integrationWithRealRegistry_routesToManagingHandler(@Mock GatewayToolHandler handler) { + final var doc = createDocument("typed", "text/plain", "typed.txt"); + final var typedContent = new TypedHandlerContent(doc); + + when(handler.type()).thenReturn("typed"); + when(handler.isGatewayManaged("typed_tool")).thenReturn(true); + when(handler.extractDocuments(any(ToolCallResult.class))).thenReturn(List.of(doc)); + + final var realExtractor = + new ToolCallResultDocumentExtractor(new GatewayToolHandlerRegistryImpl(List.of(handler))); + + final var result = + ToolCallResult.builder().id("call_1").name("typed_tool").content(typedContent).build(); + + final var extracted = realExtractor.extractDocuments(List.of(result)); + + assertThat(extracted).hasSize(1); + assertThat(extracted.getFirst().documents()).containsExactly(doc); + verify(handler).extractDocuments(result); + } + + @Test + void integrationWithRealRegistry_doesNotConsultHandlerForUnmanagedTool( + @Mock GatewayToolHandler handler) { + when(handler.type()).thenReturn("typed"); + when(handler.isGatewayManaged("plain_tool")).thenReturn(false); + + final var realExtractor = + new ToolCallResultDocumentExtractor(new GatewayToolHandlerRegistryImpl(List.of(handler))); + + final var doc = createDocument("hello", "text/plain", "test.txt"); + final var result = + ToolCallResult.builder() + .id("call_1") + .name("plain_tool") + .content(Map.of("attachment", doc)) + .build(); + + final var extracted = realExtractor.extractDocuments(List.of(result)); + + assertThat(extracted).hasSize(1); + assertThat(extracted.getFirst().documents()).containsExactly(doc); + verify(handler, never()).extractDocuments(any()); + } + + private Document createDocument(String content, String contentType, String filename) { + return documentFactory.create( + DocumentCreationRequest.from(content.getBytes(StandardCharsets.UTF_8)) + .contentType(contentType) + .fileName(filename) + .build()); + } + + private record TypedHandlerContent(Document document) {} +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/ChatClientImplTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/ChatClientImplTest.java new file mode 100644 index 00000000000..5d43db636af --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/ChatClientImplTest.java @@ -0,0 +1,207 @@ +/* + * 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.agenticai.aiagent.framework; + +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.assistantMessage; +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.systemMessage; +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.userMessage; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiRegistry; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.agenticai.aiagent.framework.strategy.ToolCallResultStrategy; +import io.camunda.connector.agenticai.aiagent.memory.runtime.DefaultRuntimeMemory; +import io.camunda.connector.agenticai.aiagent.memory.runtime.RuntimeMemory; +import io.camunda.connector.agenticai.aiagent.model.AgentContext; +import io.camunda.connector.agenticai.aiagent.model.AgentExecutionContext; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics.TokenUsage; +import io.camunda.connector.agenticai.aiagent.model.AgentState; +import io.camunda.connector.agenticai.aiagent.model.request.OutboundConnectorResponseConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.JsonResponseFormatConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.TextResponseFormatConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication.AnthropicApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicConnection; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicModel; +import io.camunda.connector.agenticai.model.message.AssistantMessage; +import io.camunda.connector.agenticai.model.tool.ToolDefinition; +import io.camunda.connector.api.error.ConnectorException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ChatClientImplTest { + + private static final List TOOL_DEFINITIONS = + List.of(ToolDefinition.builder().name("Tool").description("desc").build()); + + private static final AnthropicProviderConfiguration PROVIDER_CONFIG = + new AnthropicProviderConfiguration( + new AnthropicConnection( + null, + null, + new AnthropicApiKeyAuthentication("api-key"), + null, + new AnthropicModel("claude", null))); + + private static final AssistantMessage ASSISTANT_MESSAGE = + assistantMessage("hello world") + .withUsage(TokenUsage.builder().inputTokenCount(10).outputTokenCount(20).build()); + + private static final ModelCapabilities TEXT_ONLY_CAPABILITIES = + new ModelCapabilities( + List.of(Modality.TEXT), + List.of(Modality.TEXT), + List.of(Modality.TEXT), + false, + false, + false, + false, + null, + null); + + @Mock private ChatModelApiRegistry registry; + @Mock private ChatModelApi chatModelApi; + @Mock private AgentExecutionContext executionContext; + @Mock private ToolCallResultStrategy strategy; + + @Captor private ArgumentCaptor requestCaptor; + @Captor private ArgumentCaptor optionsCaptor; + + private RuntimeMemory runtimeMemory; + private ChatClientImpl chatClient; + + @BeforeEach + void setUp() { + runtimeMemory = new DefaultRuntimeMemory(); + runtimeMemory.addMessages(List.of(systemMessage("system"), userMessage("hi"))); + + when(executionContext.provider()).thenReturn(PROVIDER_CONFIG); + when(registry.resolve(PROVIDER_CONFIG)).thenReturn(chatModelApi); + when(chatModelApi.capabilities()).thenReturn(TEXT_ONLY_CAPABILITIES); + when(chatModelApi.complete(requestCaptor.capture(), optionsCaptor.capture(), any())) + .thenReturn(CompletableFuture.completedFuture(new ChatResponse(ASSISTANT_MESSAGE))); + when(strategy.apply(any(), any())) + .thenAnswer(inv -> new ToolCallResultStrategy.Result(inv.getArgument(0), List.of())); + + chatClient = new ChatClientImpl(registry, strategy); + } + + @Test + void buildsRequestFromRuntimeMemoryAndAgentContext() { + final var agentContext = + AgentContext.empty().withState(AgentState.READY).withToolDefinitions(TOOL_DEFINITIONS); + + final var result = chatClient.chat(executionContext, agentContext, runtimeMemory, null); + + assertThat(result.assistantMessage()).isEqualTo(ASSISTANT_MESSAGE); + assertThat(requestCaptor.getValue().messages()) + .containsExactlyElementsOf(runtimeMemory.filteredMessages()); + assertThat(requestCaptor.getValue().toolDefinitions()) + .containsExactlyElementsOf(TOOL_DEFINITIONS); + assertThat(requestCaptor.getValue().responseFormat()).isNull(); + } + + @Test + void incrementsAgentContextMetrics() { + final var agentContext = + AgentContext.empty() + .withState(AgentState.READY) + .withToolDefinitions(TOOL_DEFINITIONS) + .withMetrics( + AgentMetrics.empty() + .withModelCalls(2) + .withTokenUsage( + TokenUsage.builder().inputTokenCount(5).outputTokenCount(7).build())); + + final var result = chatClient.chat(executionContext, agentContext, runtimeMemory, null); + + assertThat(result.agentContext().metrics().modelCalls()).isEqualTo(3); + assertThat(result.agentContext().metrics().tokenUsage()) + .isEqualTo(TokenUsage.builder().inputTokenCount(15).outputTokenCount(27).build()); + } + + @Test + void translatesJsonResponseFormatConfiguration() { + when(executionContext.response()) + .thenReturn( + new OutboundConnectorResponseConfiguration( + new JsonResponseFormatConfiguration(Map.of("type", "object"), "MySchema"), false)); + + chatClient.chat( + executionContext, agentContextWithTools(), runtimeMemory, ChatStreamListener.NOOP); + + final var responseFormat = requestCaptor.getValue().responseFormat(); + assertThat(responseFormat).isInstanceOf(JsonResponseFormatConfiguration.class); + final var json = (JsonResponseFormatConfiguration) responseFormat; + assertThat(json.schemaName()).isEqualTo("MySchema"); + assertThat(json.schema()).containsEntry("type", "object"); + } + + @Test + void passesThroughTextResponseFormatConfiguration() { + when(executionContext.response()) + .thenReturn( + new OutboundConnectorResponseConfiguration( + new TextResponseFormatConfiguration(false), false)); + + chatClient.chat( + executionContext, agentContextWithTools(), runtimeMemory, ChatStreamListener.NOOP); + + assertThat(requestCaptor.getValue().responseFormat()) + .isInstanceOf(TextResponseFormatConfiguration.class); + } + + @Test + void leavesResponseFormatNullWhenResponseConfigurationMissing() { + when(executionContext.response()).thenReturn((ResponseConfiguration) null); + + chatClient.chat( + executionContext, agentContextWithTools(), runtimeMemory, ChatStreamListener.NOOP); + + assertThat(requestCaptor.getValue().responseFormat()).isNull(); + } + + @Test + void unwrapsCompletionExceptionFromUnderlyingApi() { + final var cause = new ConnectorException("MODEL_ERROR", "boom"); + when(chatModelApi.complete(any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(cause)); + + assertThatThrownBy( + () -> + chatClient.chat( + executionContext, + agentContextWithTools(), + runtimeMemory, + ChatStreamListener.NOOP)) + .isSameAs(cause); + } + + private static AgentContext agentContextWithTools() { + return AgentContext.empty().withState(AgentState.READY).withToolDefinitions(TOOL_DEFINITIONS); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/ChatModelApiRegistryImplTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/ChatModelApiRegistryImplTest.java new file mode 100644 index 00000000000..01df1e570ad --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/ChatModelApiRegistryImplTest.java @@ -0,0 +1,79 @@ +/* + * 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.agenticai.aiagent.framework; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApi; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiFactory; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication.AnthropicApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicConnection; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicModel; +import io.camunda.connector.agenticai.aiagent.model.request.provider.ProviderConfiguration; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ChatModelApiRegistryImplTest { + + @Test + void resolvesFactoryByProviderType() { + final var anthropicFactory = factoryFor(AnthropicProviderConfiguration.ANTHROPIC_ID); + final var resolvedApi = mock(ChatModelApi.class); + when(anthropicFactory.create(any())).thenReturn(resolvedApi); + + final var otherFactory = factoryFor("other"); + + final var registry = new ChatModelApiRegistryImpl(List.of(anthropicFactory, otherFactory)); + + assertThat(registry.resolve(validAnthropicConfig())).isSameAs(resolvedApi); + } + + @Test + void throwsWhenNoFactoryRegisteredForType() { + final var registry = new ChatModelApiRegistryImpl(List.of()); + + assertThatThrownBy(() -> registry.resolve(validAnthropicConfig())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "No chat model API factory registered for provider type '%s'" + .formatted(AnthropicProviderConfiguration.ANTHROPIC_ID)); + } + + @Test + void throwsWhenTwoFactoriesClaimSameProviderType() { + final var first = factoryFor("duplicate"); + final var second = factoryFor("duplicate"); + + assertThatThrownBy(() -> new ChatModelApiRegistryImpl(List.of(first, second))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Two chat model API factories claim provider type 'duplicate'"); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static ChatModelApiFactory factoryFor(String providerType) { + final ChatModelApiFactory factory = mock(ChatModelApiFactory.class); + when(factory.providerType()).thenReturn(providerType); + when(factory.apiFamily()).thenReturn("test"); + when(factory.configurationType()).thenReturn((Class) ProviderConfiguration.class); + return factory; + } + + private static AnthropicProviderConfiguration validAnthropicConfig() { + return new AnthropicProviderConfiguration( + new AnthropicConnection( + null, + null, + new AnthropicApiKeyAuthentication("api-key"), + null, + new AnthropicModel("claude", null))); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApiTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApiTest.java new file mode 100644 index 00000000000..2bf191ea188 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/anthropic/AnthropicMessagesChatModelApiTest.java @@ -0,0 +1,332 @@ +/* + * 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.agenticai.aiagent.framework.anthropic; + +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.assistantMessage; +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.systemMessage; +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.toolCallResultMessage; +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.userMessage; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.core.JsonValue; +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.MessageParam; +import com.anthropic.models.messages.Model; +import com.anthropic.models.messages.StopReason; +import com.anthropic.models.messages.TextBlock; +import com.anthropic.models.messages.TextCitation; +import com.anthropic.models.messages.ToolUseBlock; +import com.anthropic.models.messages.Usage; +import com.anthropic.services.blocking.MessageService; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.agenticai.model.tool.ToolCall; +import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.agenticai.model.tool.ToolDefinition; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentCreationRequest; +import io.camunda.connector.api.error.ConnectorException; +import io.camunda.connector.document.jackson.JacksonModuleDocumentSerializer; +import io.camunda.connector.runtime.core.document.DocumentFactoryImpl; +import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletionException; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AnthropicMessagesChatModelApiTest { + + private static final String MODEL_ID = "claude-sonnet-4-6"; + + private static final ModelCapabilities CAPABILITIES = + new ModelCapabilities( + List.of(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT), + List.of(Modality.TEXT, Modality.IMAGE), + List.of(Modality.TEXT), + true, + true, + true, + true, + 200000, + 64000); + + @Mock private AnthropicClient client; + @Mock private MessageService messageService; + + @Captor private ArgumentCaptor paramsCaptor; + + private AnthropicMessagesChatModelApi api; + + @BeforeEach + void setUp() { + when(client.messages()).thenReturn(messageService); + api = + new AnthropicMessagesChatModelApi( + client, + MODEL_ID, + new com.fasterxml.jackson.databind.ObjectMapper(), + CAPABILITIES, + 1024L, + null, + null, + null); + } + + @Test + void capabilitiesReturnsConfiguredInstance() { + assertThat(api.capabilities()).isSameAs(CAPABILITIES); + } + + @Test + void buildsExpectedMessageCreateParamsForSimpleConversation() { + when(messageService.create((MessageCreateParams) paramsCaptor.capture())) + .thenReturn(textOnlyResponse("Hi there!")); + + var request = + new ChatRequest( + List.of(systemMessage("be helpful"), userMessage("hello")), List.of(), null); + + api.complete(request, defaultOptions(), ChatStreamListener.NOOP).join(); + + var params = paramsCaptor.getValue(); + assertThat(params.model().asString()).isEqualTo(MODEL_ID); + assertThat(params.maxTokens()).isEqualTo(1024L); + assertThat(params.system()).isPresent(); + assertThat(params.system().get().string()).hasValue("be helpful"); + assertThat(params.messages()).hasSize(1); + var first = params.messages().getFirst(); + assertThat(first.role()).isEqualTo(MessageParam.Role.USER); + } + + @Test + void appliesChatOptionsMaxOutputTokensOverride() { + when(messageService.create((MessageCreateParams) paramsCaptor.capture())) + .thenReturn(textOnlyResponse("ok")); + + var options = new ChatOptions(2048, null, null, Map.of()); + api.complete( + new ChatRequest(List.of(userMessage("hi")), List.of(), null), + options, + ChatStreamListener.NOOP) + .join(); + + assertThat(paramsCaptor.getValue().maxTokens()).isEqualTo(2048L); + } + + @Test + void mapsAssistantToolCallsBackToContentBlocks() { + when(messageService.create(any(MessageCreateParams.class))) + .thenReturn(toolUseResponse("getWeather", "abc", Map.of("location", "MUC"))); + + var response = + api.complete( + new ChatRequest(List.of(userMessage("weather?")), tools(), null), + defaultOptions(), + ChatStreamListener.NOOP) + .join(); + + var assistant = response.assistantMessage(); + assertThat(assistant.toolCalls()) + .extracting(ToolCall::id, ToolCall::name) + .containsExactly(Tuple.tuple("abc", "getWeather")); + assertThat(assistant.toolCalls().getFirst().arguments()).containsEntry("location", "MUC"); + assertThat(assistant.stopReason()) + .isEqualTo(io.camunda.connector.agenticai.model.message.StopReason.TOOL_USE); + } + + @Test + void priorAssistantToolCallsAndResultsRoundTripIntoParams() { + when(messageService.create((MessageCreateParams) paramsCaptor.capture())) + .thenReturn(textOnlyResponse("ack")); + + var prior = + assistantMessage( + "let me check", + List.of( + ToolCall.builder() + .id("abc") + .name("getWeather") + .arguments(Map.of("location", "MUC")) + .build())); + var results = + toolCallResultMessage( + List.of( + ToolCallResult.builder().id("abc").name("getWeather").content("Sunny").build())); + + api.complete( + new ChatRequest( + List.of(userMessage("weather?"), prior, results, userMessage("thanks")), + tools(), + null), + defaultOptions(), + ChatStreamListener.NOOP) + .join(); + + var params = paramsCaptor.getValue(); + assertThat(params.messages()).hasSize(4); + assertThat(params.messages().get(1).role()).isEqualTo(MessageParam.Role.ASSISTANT); + assertThat(params.messages().get(2).role()).isEqualTo(MessageParam.Role.USER); + } + + @Test + void toolResultContentWithCamundaDocumentSerialisesViaConnectorsObjectMapper() { + // Regression: previously the impl called ToolResultBlockParam.contentAsJson(content), which + // delegated to the Anthropic SDK's internal ObjectMapper. That mapper has no document + // serialiser registered, so a CamundaDocument inside the content tree threw + // InvalidDefinitionException. The fix routes everything through serializedToolResultText, + // which uses our @ConnectorsObjectMapper (with JacksonModuleDocumentSerializer registered). + final var documentStore = InMemoryDocumentStore.INSTANCE; + documentStore.clear(); + final var documentFactory = new DocumentFactoryImpl(documentStore); + final Document document = + documentFactory.create( + DocumentCreationRequest.from("hello".getBytes(StandardCharsets.UTF_8)) + .contentType("text/plain") + .fileName("greeting.txt") + .build()); + + final var documentAwareApi = + new AnthropicMessagesChatModelApi( + client, + MODEL_ID, + new ObjectMapper().registerModule(new JacksonModuleDocumentSerializer()), + CAPABILITIES, + 1024L, + null, + null, + null); + + when(messageService.create((MessageCreateParams) paramsCaptor.capture())) + .thenReturn(textOnlyResponse("ack")); + + final var prior = + assistantMessage( + "let me check", + List.of(ToolCall.builder().id("abc").name("getDocument").arguments(Map.of()).build())); + final var results = + toolCallResultMessage( + List.of( + ToolCallResult.builder() + .id("abc") + .name("getDocument") + .content(Map.of("attachment", document)) + .build())); + + documentAwareApi + .complete( + new ChatRequest(List.of(userMessage("show it"), prior, results), tools(), null), + defaultOptions(), + ChatStreamListener.NOOP) + .join(); + + final var params = paramsCaptor.getValue(); + final var toolResultMessage = params.messages().get(2); + final var blocks = toolResultMessage.content().asBlockParams(); + final var toolResultBlock = blocks.getFirst().asToolResult(); + final var contentString = toolResultBlock.content().get().asString(); + assertThat(contentString).contains("\"camunda.document.type\":\"camunda\""); + assertThat(contentString).contains("greeting.txt"); + } + + @Test + void wrapsSdkExceptionInConnectorException() { + when(messageService.create(any(MessageCreateParams.class))) + .thenThrow(new RuntimeException("boom")); + + var future = + api.complete( + new ChatRequest(List.of(userMessage("hi")), List.of(), null), + defaultOptions(), + ChatStreamListener.NOOP); + + assertThatThrownBy(future::join) + .isInstanceOf(CompletionException.class) + .cause() + .isInstanceOf(ConnectorException.class) + .hasMessageContaining("Anthropic Messages call failed"); + } + + private static ChatOptions defaultOptions() { + return new ChatOptions(null, null, null, Map.of()); + } + + private static List tools() { + return List.of( + ToolDefinition.builder() + .name("getWeather") + .description("Returns the current weather") + .inputSchema( + Map.of( + "type", "object", + "properties", Map.of("location", Map.of("type", "string")), + "required", List.of("location"))) + .build()); + } + + private static com.anthropic.models.messages.Message textOnlyResponse(String text) { + return baseResponseBuilder("msg_1", StopReason.END_TURN) + .addContent( + TextBlock.builder().text(text).citations((java.util.List) null).build()) + .usage(usage(10, 5)) + .build(); + } + + private static com.anthropic.models.messages.Message toolUseResponse( + String name, String id, Map input) { + var inputBuilder = ToolUseBlock.builder().id(id).name(name); + inputBuilder.input(JsonValue.from(input)); + inputBuilder.caller(com.anthropic.models.messages.DirectCaller.builder().build()); + return baseResponseBuilder("msg_2", StopReason.TOOL_USE) + .addContent(ContentBlock.ofToolUse(inputBuilder.build())) + .usage(usage(15, 8)) + .build(); + } + + private static com.anthropic.models.messages.Message.Builder baseResponseBuilder( + String id, StopReason stopReason) { + return com.anthropic.models.messages.Message.builder() + .id(id) + .model(Model.of(MODEL_ID)) + .container(java.util.Optional.empty()) + .stopReason(stopReason) + .stopSequence(java.util.Optional.empty()); + } + + private static Usage usage(long input, long output) { + return Usage.builder() + .inputTokens(input) + .outputTokens(output) + .cacheCreation(java.util.Optional.empty()) + .cacheCreationInputTokens(java.util.Optional.empty()) + .cacheReadInputTokens(java.util.Optional.empty()) + .inferenceGeo(java.util.Optional.empty()) + .serverToolUse(java.util.Optional.empty()) + .serviceTier(java.util.Optional.empty()) + .build(); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/BundledCapabilityMatrixTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/BundledCapabilityMatrixTest.java new file mode 100644 index 00000000000..c6bed6e1ccc --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/BundledCapabilityMatrixTest.java @@ -0,0 +1,179 @@ +/* + * 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.agenticai.aiagent.framework.capabilities; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.agenticai.aiagent.framework.openai.OpenAiChatModelApiFactory; +import io.camunda.connector.runtime.annotation.ConnectorsObjectMapper; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Spring-Boot-style integration test for the bundled capability matrix. {@link + * AgenticAiCapabilitiesConfiguration} loads the bundled YAML as a {@link + * org.springframework.core.env.PropertySource} during its own {@code setEnvironment(...)} callback, + * so importing the configuration class is enough — no manual environment post-processing needed. + * The test then exercises the full {@link AgenticAiFrameworkProperties} → {@link + * CapabilityMatrixFactory} → {@link ModelCapabilitiesResolver} pipeline. + */ +class BundledCapabilityMatrixTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withUserConfiguration(TestObjectMapperConfig.class) + .withUserConfiguration(AgenticAiCapabilitiesConfiguration.class); + + @Test + void coversAllShippedApiFamilies() { + contextRunner.run( + context -> { + final var matrix = context.getBean(CapabilityMatrix.class); + assertThat(matrix.families()) + .containsKeys( + "anthropic-messages", + OpenAiChatModelApiFactory.API_FAMILY_COMPLETIONS, + OpenAiChatModelApiFactory.API_FAMILY_RESPONSES); + }); + } + + @Test + void claudeSonnet4ResolvesToFullCapabilities() { + contextRunner.run( + context -> { + final var caps = resolve(context, "anthropic-messages", "claude-sonnet-4-6"); + + assertThat(caps.userMessageModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + assertThat(caps.toolResultModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + assertThat(caps.supportsReasoning()).isTrue(); + assertThat(caps.supportsReasoningSignatureRoundtrip()).isTrue(); + assertThat(caps.supportsPromptCaching()).isTrue(); + assertThat(caps.supportsParallelToolCalls()).isTrue(); + assertThat(caps.maxOutputTokens()).isEqualTo(64000); + }); + } + + @Test + void claudeHaiku4InheritsToolResultButOverridesUserMessage() { + contextRunner.run( + context -> { + final var caps = resolve(context, "anthropic-messages", "claude-haiku-4-5"); + + assertThat(caps.userMessageModalities()).containsExactly(Modality.TEXT, Modality.IMAGE); + assertThat(caps.toolResultModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + assertThat(caps.supportsReasoning()).isFalse(); + }); + } + + @Test + void unknownClaudeModelFallsThroughToFamilyCatchAll() { + contextRunner.run( + context -> { + final var caps = resolve(context, "anthropic-messages", "claude-some-future-model"); + + assertThat(caps.userMessageModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + assertThat(caps.supportsPromptCaching()).isTrue(); + }); + } + + @Test + void gpt5OnCompletionsHasReasoningButNotRoundtrip() { + contextRunner.run( + context -> { + final var caps = + resolve(context, OpenAiChatModelApiFactory.API_FAMILY_COMPLETIONS, "gpt-5"); + + assertThat(caps.supportsReasoning()).isTrue(); + assertThat(caps.supportsReasoningSignatureRoundtrip()).isFalse(); + assertThat(caps.toolResultModalities()).containsExactly(Modality.TEXT); + assertThat(caps.contextWindow()).isEqualTo(400000); + }); + } + + @Test + void gpt5OnResponsesHasReasoningRoundtripAndMultimodalToolResults() { + contextRunner.run( + context -> { + final var caps = + resolve(context, OpenAiChatModelApiFactory.API_FAMILY_RESPONSES, "gpt-5"); + + assertThat(caps.supportsReasoning()).isTrue(); + assertThat(caps.supportsReasoningSignatureRoundtrip()).isTrue(); + assertThat(caps.toolResultModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + }); + } + + @Test + void gpt4oAddsAudioToUserMessageButKeepsToolResultFromDefaults() { + contextRunner.run( + context -> { + final var caps = + resolve(context, OpenAiChatModelApiFactory.API_FAMILY_RESPONSES, "gpt-4o-mini"); + + assertThat(caps.userMessageModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.AUDIO); + assertThat(caps.toolResultModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + assertThat(caps.supportsReasoning()).isFalse(); + }); + } + + @Test + void o1OnCompletionsHasReasoningButNoParallelToolCalls() { + contextRunner.run( + context -> { + final var caps = + resolve(context, OpenAiChatModelApiFactory.API_FAMILY_COMPLETIONS, "o1-mini"); + + assertThat(caps.supportsReasoning()).isTrue(); + assertThat(caps.supportsParallelToolCalls()).isFalse(); + }); + } + + @Test + void gpt41OnResponsesHasLargeContextWindowDespiteDotInPattern() { + contextRunner.run( + context -> { + final var caps = + resolve(context, OpenAiChatModelApiFactory.API_FAMILY_RESPONSES, "gpt-4.1-mini"); + + assertThat(caps.contextWindow()).isEqualTo(1000000); + assertThat(caps.maxOutputTokens()).isEqualTo(32768); + }); + } + + private static ModelCapabilities resolve( + org.springframework.context.ApplicationContext context, String apiFamily, String modelId) { + return context + .getBean(ModelCapabilitiesResolver.class) + .resolve(apiFamily, modelId, Optional.empty()); + } + + /** + * Provides the {@link com.fasterxml.jackson.databind.ObjectMapper} bean that {@link + * AgenticAiCapabilitiesConfiguration} expects (qualified with {@link ConnectorsObjectMapper}). + */ + @Configuration + static class TestObjectMapperConfig { + @Bean + @ConnectorsObjectMapper + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrixOverrideTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrixOverrideTest.java new file mode 100644 index 00000000000..b8cf2377a51 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/CapabilityMatrixOverrideTest.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.agenticai.aiagent.framework.capabilities; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.BundledCapabilityMatrixTest.TestObjectMapperConfig; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Verifies that consumer-supplied properties (higher precedence than the bundled YAML) deep-merge + * into the capability matrix. {@link AgenticAiCapabilitiesConfiguration} loads the bundled YAML + * itself during {@code setEnvironment(...)}, so importing it plus {@code withPropertyValues(...)} + * mirrors the layering a library consumer gets when adding entries to their {@code + * application.yml}. + */ +class CapabilityMatrixOverrideTest { + + private final ApplicationContextRunner baseRunner = + new ApplicationContextRunner() + .withUserConfiguration(TestObjectMapperConfig.class) + .withUserConfiguration(AgenticAiCapabilitiesConfiguration.class); + + private static final String PREFIX = "camunda.connector.agenticai.aiagent.framework.capabilities"; + + @Test + void overrideTunesScalarFieldsOnExistingPattern() { + baseRunner + .withPropertyValues( + PREFIX + + ".anthropic-messages.models.claude-sonnet-4.capabilities.max-output-tokens=999999") + .run( + context -> { + final var caps = resolve(context, "anthropic-messages", "claude-sonnet-4-6"); + + assertThat(caps.maxOutputTokens()).isEqualTo(999999); + // Other bundled fields untouched: + assertThat(caps.supportsReasoning()).isTrue(); + assertThat(caps.userMessageModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + }); + } + + @Test + void overrideAddsBrandNewModelEntryUnderExistingFamily() { + baseRunner + .withPropertyValues( + PREFIX + + ".anthropic-messages.models.my-org-tuned-claude.capabilities.max-output-tokens=12345", + PREFIX + + ".anthropic-messages.models.my-org-tuned-claude.capabilities.supports-reasoning=true") + .run( + context -> { + final var caps = resolve(context, "anthropic-messages", "my-org-tuned-claude"); + + assertThat(caps.maxOutputTokens()).isEqualTo(12345); + assertThat(caps.supportsReasoning()).isTrue(); + // Inherited from anthropic-messages defaults: + assertThat(caps.userMessageModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + assertThat(caps.toolResultModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + }); + } + + @Test + void overrideReplacesModalityListWholesaleViaSpringBootSemantics() { + baseRunner + .withPropertyValues( + PREFIX + + ".openai-completions.models.gpt-4o.capabilities.input-modalities.user-message[0]=text") + .run( + context -> { + final var caps = resolve(context, "openai-completions", "gpt-4o-mini"); + + assertThat(caps.userMessageModalities()).containsExactly(Modality.TEXT); + // tool_result still inherited from openai-completions defaults: + assertThat(caps.toolResultModalities()).containsExactly(Modality.TEXT); + }); + } + + @Test + void overrideTunesFamilyDefaultsForUnpinnedModels() { + baseRunner + .withPropertyValues(PREFIX + ".openai-completions.defaults.max-output-tokens=7777") + .run( + context -> { + // Pinned entries (gpt-5*) keep their own override: + final var gpt5 = resolve(context, "openai-completions", "gpt-5"); + assertThat(gpt5.maxOutputTokens()).isEqualTo(128000); + + // Models that match only the gpt-* fallback inherit the new default: + final var generic = resolve(context, "openai-completions", "gpt-3.5-turbo"); + assertThat(generic.maxOutputTokens()).isEqualTo(7777); + }); + } + + private static ModelCapabilities resolve( + org.springframework.context.ApplicationContext context, String apiFamily, String modelId) { + return context + .getBean(ModelCapabilitiesResolver.class) + .resolve(apiFamily, modelId, Optional.empty()); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesResolverTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesResolverTest.java new file mode 100644 index 00000000000..d3821aeaca2 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/capabilities/ModelCapabilitiesResolverTest.java @@ -0,0 +1,380 @@ +/* + * 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.agenticai.aiagent.framework.capabilities; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities; +import io.camunda.connector.agenticai.aiagent.framework.api.ModelCapabilities.Modality; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.AgenticAiFrameworkProperties.ApiFamilyProperties; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.AgenticAiFrameworkProperties.ModelEntryProperties; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.ModelCapabilitiesYaml.InputModalities; +import io.camunda.connector.agenticai.aiagent.framework.capabilities.ModelCapabilitiesYaml.OutputModalities; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ModelCapabilitiesResolverTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // --- Builder helpers ----------------------------------------------------- + + private static AgenticAiFrameworkProperties props(Map families) { + return new AgenticAiFrameworkProperties(families); + } + + private static ApiFamilyProperties family( + ModelCapabilitiesYaml defaults, Map models) { + return new ApiFamilyProperties(defaults, models); + } + + private static ModelEntryProperties entry(ModelCapabilitiesYaml capabilities) { + return new ModelEntryProperties(null, null, List.of(), capabilities); + } + + private static ModelEntryProperties entryWithAliases( + List aliases, ModelCapabilitiesYaml capabilities) { + return new ModelEntryProperties(null, null, aliases, capabilities); + } + + /** A fully-populated capability block usable as an api-family default. */ + private static ModelCapabilitiesYaml fullDefaults( + List userMessage, List toolResult) { + return new ModelCapabilitiesYaml( + new InputModalities(userMessage, toolResult), + new OutputModalities(List.of(Modality.TEXT)), + false, + false, + true, + true, + 200000, + 8192); + } + + private ModelCapabilitiesResolver resolverFor(AgenticAiFrameworkProperties props) { + return new ModelCapabilitiesResolver( + CapabilityMatrixFactory.build(props, objectMapper), objectMapper); + } + + // --- Tests --------------------------------------------------------------- + + @Test + void overrideShortCircuitsResolution() { + final var resolver = resolverFor(props(Map.of())); + final var override = + new ModelCapabilities( + List.of(Modality.TEXT, Modality.IMAGE, Modality.AUDIO), + List.of(Modality.TEXT), + List.of(Modality.TEXT), + true, + true, + true, + true, + 12345, + 6789); + + final var result = resolver.resolve("anthropic-messages", "anything", Optional.of(override)); + + assertThat(result).isSameAs(override); + } + + @Test + void exactIdMatchInheritsDefaultsAndAppliesOverrides() { + // defaults: DOCUMENT in user_message, max_output_tokens=8192, no reasoning + // override on claude-opus-4-7: enable reasoning, max_output_tokens=32000 + final var defaults = + fullDefaults( + List.of(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT), + List.of(Modality.TEXT, Modality.IMAGE)); + final var override = new ModelCapabilitiesYaml(null, null, true, true, null, null, null, 32000); + + final var resolver = + resolverFor( + props( + Map.of( + "anthropic-messages", + family( + defaults, + Map.of( + "claude-opus-4-7", + entryWithAliases(List.of("claude-opus-latest"), override)))))); + + final var caps = resolver.resolve("anthropic-messages", "claude-opus-4-7", Optional.empty()); + + assertThat(caps.userMessageModalities()) + .containsExactly(Modality.TEXT, Modality.IMAGE, Modality.DOCUMENT); + assertThat(caps.toolResultModalities()).containsExactly(Modality.TEXT, Modality.IMAGE); + assertThat(caps.supportsReasoning()).isTrue(); + assertThat(caps.supportsReasoningSignatureRoundtrip()).isTrue(); + assertThat(caps.supportsPromptCaching()).isTrue(); + assertThat(caps.contextWindow()).isEqualTo(200000); + assertThat(caps.maxOutputTokens()).isEqualTo(32000); + } + + @Test + void aliasMatchResolvesToSameEntry() { + final var resolver = + resolverFor( + props( + Map.of( + "anthropic-messages", + family( + fullDefaults(List.of(Modality.TEXT), List.of(Modality.TEXT)), + Map.of( + "claude-opus-4-7", + entryWithAliases( + List.of("claude-opus-latest"), + new ModelCapabilitiesYaml( + null, null, null, null, null, null, null, 32000))))))); + + final var byAlias = + resolver.resolve("anthropic-messages", "claude-opus-latest", Optional.empty()); + final var byId = resolver.resolve("anthropic-messages", "claude-opus-4-7", Optional.empty()); + + assertThat(byAlias).isEqualTo(byId); + } + + @Test + void longestPatternWins() { + // claude-opus-* (12 chars) beats claude-* (8 chars). + final var models = new LinkedHashMap(); + models.put( + "claude-fallback", + new ModelEntryProperties( + null, + List.of("claude-*"), + List.of(), + new ModelCapabilitiesYaml(null, null, false, null, null, null, null, null))); + models.put( + "claude-opus", + new ModelEntryProperties( + null, + List.of("claude-opus-*"), + List.of(), + new ModelCapabilitiesYaml(null, null, true, null, null, null, null, null))); + + final var resolver = + resolverFor( + props( + Map.of( + "anthropic-messages", + family(fullDefaults(List.of(Modality.TEXT), List.of(Modality.TEXT)), models)))); + + final var caps = resolver.resolve("anthropic-messages", "claude-opus-3-5", Optional.empty()); + + assertThat(caps.supportsReasoning()).isTrue(); + } + + @Test + void deepMergeKeepsInheritedSubKeyWhenOverlayOverridesSibling() { + // Defaults provide tool_result=[text,image]; override only user_message. + final var defaults = + fullDefaults( + List.of(Modality.TEXT, Modality.IMAGE), List.of(Modality.TEXT, Modality.IMAGE)); + final var override = + new ModelCapabilitiesYaml( + new InputModalities(List.of(Modality.TEXT), null), + null, + null, + null, + null, + null, + null, + null); + + final var resolver = + resolverFor( + props( + Map.of( + "anthropic-messages", + family( + defaults, + Map.of( + "claude-haiku", + new ModelEntryProperties( + null, List.of("claude-haiku-*"), List.of(), override)))))); + + final var caps = resolver.resolve("anthropic-messages", "claude-haiku-4-5", Optional.empty()); + + assertThat(caps.userMessageModalities()).containsExactly(Modality.TEXT); + assertThat(caps.toolResultModalities()).containsExactly(Modality.TEXT, Modality.IMAGE); + } + + @Test + void unknownApiFamilyFallsThroughToConservativeDefaults() { + final var resolver = resolverFor(props(Map.of())); + + final var caps = resolver.resolve("does-not-exist", "claude-opus-4-7", Optional.empty()); + + assertThat(caps).isEqualTo(ModelCapabilitiesResolver.CONSERVATIVE_DEFAULTS); + } + + @Test + void unknownModelFallsThroughToConservativeDefaultsWhenNoFallbackPattern() { + final var resolver = + resolverFor( + props( + Map.of( + "anthropic-messages", + family( + fullDefaults( + List.of(Modality.TEXT, Modality.IMAGE), List.of(Modality.TEXT)), + Map.of("claude-opus-4-7", entry(emptyOverride())))))); + + final var caps = resolver.resolve("anthropic-messages", "claude-mystery", Optional.empty()); + + assertThat(caps).isEqualTo(ModelCapabilitiesResolver.CONSERVATIVE_DEFAULTS); + } + + @Test + void rejectsEntryWithBothExplicitIdAndPattern() { + final var props = + props( + Map.of( + "anthropic-messages", + family( + fullDefaults(List.of(Modality.TEXT), List.of(Modality.TEXT)), + Map.of( + "claude-opus-4-7", + new ModelEntryProperties( + "claude-opus-4-7", + List.of("claude-opus-*"), + List.of(), + emptyOverride()))))); + + assertThatThrownBy(() -> CapabilityMatrixFactory.build(props, objectMapper)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("at most one of"); + } + + @Test + void rejectsPatternEntryWithAliases() { + final var props = + props( + Map.of( + "anthropic-messages", + family( + fullDefaults(List.of(Modality.TEXT), List.of(Modality.TEXT)), + Map.of( + "claude-opus", + new ModelEntryProperties( + null, + List.of("claude-opus-*"), + List.of("some-alias"), + emptyOverride()))))); + + assertThatThrownBy(() -> CapabilityMatrixFactory.build(props, objectMapper)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot declare aliases"); + } + + @Test + void mapKeyServesAsImplicitIdAndExactBeatsPattern() { + final var defaults = fullDefaults(List.of(Modality.TEXT), List.of(Modality.TEXT)); + final var models = new LinkedHashMap(); + // Map key is the id (no explicit id/pattern fields). + models.put( + "claude-opus-4-7", + entry(new ModelCapabilitiesYaml(null, null, null, null, null, null, null, 64000))); + // Pattern entry — explicit pattern field. + models.put( + "claude-opus", + new ModelEntryProperties( + null, + List.of("claude-opus-*"), + List.of(), + new ModelCapabilitiesYaml(null, null, null, null, null, null, null, 8192))); + + final var resolver = resolverFor(props(Map.of("anthropic-messages", family(defaults, models)))); + + final var byId = resolver.resolve("anthropic-messages", "claude-opus-4-7", Optional.empty()); + assertThat(byId.maxOutputTokens()).isEqualTo(64000); + + final var byPattern = + resolver.resolve("anthropic-messages", "claude-opus-3-5", Optional.empty()); + assertThat(byPattern.maxOutputTokens()).isEqualTo(8192); + } + + @Test + void multiplePatternsPerEntry() { + // pattern as a list — entry matches if any glob matches; longest matching glob's length + // determines the entry's score for cross-entry longest-match selection. + final var defaults = fullDefaults(List.of(Modality.TEXT), List.of(Modality.TEXT)); + final var entry = + new ModelEntryProperties( + null, + List.of("gpt-4o*", "gpt-4-turbo*"), + List.of(), + new ModelCapabilitiesYaml(null, null, null, null, null, null, null, 16384)); + + final var resolver = + resolverFor( + props(Map.of("openai-completions", family(defaults, Map.of("gpt-4o-family", entry))))); + + assertThat( + resolver + .resolve("openai-completions", "gpt-4o-mini", Optional.empty()) + .maxOutputTokens()) + .isEqualTo(16384); + assertThat( + resolver + .resolve("openai-completions", "gpt-4-turbo-2024-04-09", Optional.empty()) + .maxOutputTokens()) + .isEqualTo(16384); + } + + @Test + void deepMergeReplacesListsWholesale() { + final var defaults = + fullDefaults(List.of(Modality.TEXT, Modality.IMAGE), List.of(Modality.TEXT)); + final var override = + new ModelCapabilitiesYaml( + new InputModalities(List.of(Modality.TEXT), null), + null, + null, + null, + null, + null, + null, + null); + + final var resolver = + resolverFor( + props( + Map.of( + "openai-completions", + family( + defaults, + Map.of( + "gpt-4o", + new ModelEntryProperties( + null, List.of("gpt-4o*"), List.of(), override)))))); + + final var caps = resolver.resolve("openai-completions", "gpt-4o-mini", Optional.empty()); + + assertThat(caps.userMessageModalities()).containsExactly(Modality.TEXT); + } + + @Test + void globMatcherTreatsStarAsWildcard() { + assertThat(ModelCapabilitiesResolver.matchesGlob("claude-opus-*", "claude-opus-4-7")).isTrue(); + assertThat(ModelCapabilitiesResolver.matchesGlob("claude-opus-*", "claude-sonnet-4-7")) + .isFalse(); + assertThat(ModelCapabilitiesResolver.matchesGlob("gpt-*", "gpt-4o-mini")).isTrue(); + assertThat(ModelCapabilitiesResolver.matchesGlob("gpt-4.1*", "gpt-4.1-nano")).isTrue(); + assertThat(ModelCapabilitiesResolver.matchesGlob("gpt-4.1*", "gpt-4o")).isFalse(); + } + + private static ModelCapabilitiesYaml emptyOverride() { + return new ModelCapabilitiesYaml(null, null, null, null, null, null, null, null); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/content/ContentTextSerializerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/content/ContentTextSerializerTest.java new file mode 100644 index 00000000000..437d3622ab2 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/content/ContentTextSerializerTest.java @@ -0,0 +1,104 @@ +/* + * 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.agenticai.aiagent.framework.content; + +import static io.camunda.connector.agenticai.model.message.content.ObjectContent.objectContent; +import static io.camunda.connector.agenticai.model.message.content.ReasoningContent.reasoningContent; +import static io.camunda.connector.agenticai.model.message.content.TextContent.textContent; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.agenticai.model.message.content.DocumentContent; +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentCreationRequest; +import io.camunda.connector.api.document.DocumentFactory; +import io.camunda.connector.document.jackson.JacksonModuleDocumentSerializer; +import io.camunda.connector.runtime.core.document.DocumentFactoryImpl; +import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ContentTextSerializerTest { + + private final ObjectMapper objectMapper = + new ObjectMapper().registerModule(new JacksonModuleDocumentSerializer()); + + @Test + void textContentReturnsRawText() { + assertThat(ContentTextSerializer.toText(textContent("hello"), objectMapper)).isEqualTo("hello"); + } + + @Test + void objectContentWithStringReturnsRawString() { + assertThat(ContentTextSerializer.toText(objectContent("plain string"), objectMapper)) + .isEqualTo("plain string"); + } + + @Test + void objectContentWithMapReturnsJson() { + final var content = new LinkedHashMap(); + content.put("key", "value"); + content.put("count", 42); + + assertThat(ContentTextSerializer.toText(objectContent(content), objectMapper)) + .isEqualTo("{\"key\":\"value\",\"count\":42}"); + } + + @Test + void objectContentWithListReturnsJson() { + assertThat(ContentTextSerializer.toText(objectContent(List.of("a", "b", "c")), objectMapper)) + .isEqualTo("[\"a\",\"b\",\"c\"]"); + } + + @Test + void objectContentWithDocumentSerialisesViaDocumentModule() { + final var documentStore = InMemoryDocumentStore.INSTANCE; + documentStore.clear(); + final DocumentFactory factory = new DocumentFactoryImpl(documentStore); + final Document document = + factory.create( + DocumentCreationRequest.from("hello".getBytes(StandardCharsets.UTF_8)) + .contentType("text/plain") + .fileName("greeting.txt") + .build()); + + final var nested = Map.of("attachment", document); + + final var json = ContentTextSerializer.toText(objectContent(nested), objectMapper); + + assertThat(json).contains("\"camunda.document.type\":\"camunda\""); + assertThat(json).doesNotContain("hello"); + } + + @Test + void unsupportedContentTypeThrows() { + final var documentStore = InMemoryDocumentStore.INSTANCE; + documentStore.clear(); + final DocumentFactory factory = new DocumentFactoryImpl(documentStore); + final Document document = + factory.create( + DocumentCreationRequest.from("x".getBytes(StandardCharsets.UTF_8)) + .contentType("text/plain") + .build()); + + assertThatThrownBy( + () -> + ContentTextSerializer.toText( + DocumentContent.documentContent(document), objectMapper)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("DocumentContent"); + + assertThatThrownBy( + () -> ContentTextSerializer.toText(reasoningContent("thinking"), objectMapper)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ReasoningContent"); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterTest.java index b6008e673ad..510b55b3e65 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatMessageConverterTest.java @@ -9,29 +9,30 @@ import static io.camunda.connector.agenticai.model.message.content.TextContent.textContent; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.entry; import static org.assertj.core.api.Assertions.within; +import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.ToolExecutionResultMessage; -import dev.langchain4j.http.client.SuccessfulHttpResponse; +import dev.langchain4j.model.anthropic.AnthropicTokenUsage; +import dev.langchain4j.model.bedrock.BedrockTokenUsage; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.ChatResponseMetadata; -import dev.langchain4j.model.openai.OpenAiChatResponseMetadata; import dev.langchain4j.model.openai.OpenAiTokenUsage; import dev.langchain4j.model.output.FinishReason; import dev.langchain4j.model.output.TokenUsage; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolCallConverter; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; import io.camunda.connector.agenticai.model.message.AssistantMessage; import io.camunda.connector.agenticai.model.message.Message; +import io.camunda.connector.agenticai.model.message.StopReason; import io.camunda.connector.agenticai.model.message.SystemMessage; import io.camunda.connector.agenticai.model.message.ToolCallResultMessage; import io.camunda.connector.agenticai.model.message.UserMessage; @@ -43,15 +44,16 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.assertj.core.api.InstanceOfAssertFactories; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -59,7 +61,6 @@ class ChatMessageConverterTest { @Mock private ToolCallConverter toolCallConverter; @Mock private ContentConverter contentConverter; - @Spy private ObjectMapper objectMapper = new ObjectMapper(); @InjectMocks private ChatMessageConverterImpl chatMessageConverter; @@ -226,64 +227,15 @@ void toAssistantMessage_convertsFromChatResponse() { .isEqualTo("AI response"); }); - assertThat(result.metadata()).containsKey("timestamp"); + assertThat(result.metadata()).containsOnlyKeys("timestamp"); assertThat((ZonedDateTime) result.metadata().get("timestamp")) .isCloseTo(ZonedDateTime.now(), within(1, ChronoUnit.SECONDS)); - assertThat(result.metadata()).containsKey("framework"); - assertThat(result.metadata().get("framework")) - .asInstanceOf(InstanceOfAssertFactories.MAP) - .containsExactly( - entry("id", "chatcmpl-123"), - entry("finishReason", "STOP"), - entry( - "tokenUsage", - Map.of("inputTokenCount", 10, "outputTokenCount", 20, "totalTokenCount", 30))); - } - - @Test - void toAssistantMessage_containsOnlyBasicMetadata() { - final var aiMessage = AiMessage.builder().text("AI response").build(); - - final var chatResponseMetadata = - OpenAiChatResponseMetadata.builder() - .id("chatcmpl-123") - .finishReason(FinishReason.TOOL_EXECUTION) - .tokenUsage( - OpenAiTokenUsage.builder() - .inputTokenCount(10) - .inputTokensDetails( - OpenAiTokenUsage.InputTokensDetails.builder().cachedTokens(1).build()) - .outputTokenCount(20) - .totalTokenCount(30) - .build()) - .serviceTier("super-premium") - .rawHttpResponse( - SuccessfulHttpResponse.builder() - .statusCode(200) - .headers(Map.of("x-my-header", List.of("dummy"))) - .body("AI response") - .build()) - .build(); - - final var chatResponse = - new ChatResponse.Builder().aiMessage(aiMessage).metadata(chatResponseMetadata).build(); - final var result = chatMessageConverter.toAssistantMessage(chatResponse); - - final var expectedTokenUsage = new LinkedHashMap(); - expectedTokenUsage.put("inputTokenCount", 10); - expectedTokenUsage.put("inputTokensDetails", Map.of("cachedTokens", 1)); - expectedTokenUsage.put("outputTokenCount", 20); - expectedTokenUsage.put("outputTokensDetails", null); - expectedTokenUsage.put("totalTokenCount", 30); - - assertThat(result.metadata().get("framework")) - .asInstanceOf(InstanceOfAssertFactories.MAP) - .containsExactly( - entry("id", "chatcmpl-123"), - entry("finishReason", "TOOL_EXECUTION"), - entry("tokenUsage", expectedTokenUsage)) - .doesNotContainKeys("serviceTier", "rawHttpResponse"); + assertThat(result.messageId()).isEqualTo("chatcmpl-123"); + assertThat(result.stopReason()).isEqualTo(StopReason.STOP); + assertThat(result.usage()) + .isEqualTo( + AgentMetrics.TokenUsage.builder().inputTokenCount(10).outputTokenCount(20).build()); } @Test @@ -304,15 +256,12 @@ void toAssistantMessage_convertsFromChatResponse_withoutContentText() { assertThat(result.content()).isEmpty(); - assertThat(result.metadata()).containsKey("framework"); - assertThat(result.metadata().get("framework")) - .asInstanceOf(InstanceOfAssertFactories.MAP) - .containsExactly( - entry("id", "chatcmpl-123"), - entry("finishReason", "CONTENT_FILTER"), - entry( - "tokenUsage", - Map.of("inputTokenCount", 10, "outputTokenCount", 0, "totalTokenCount", 10))); + assertThat(result.metadata()).containsOnlyKeys("timestamp"); + assertThat(result.messageId()).isEqualTo("chatcmpl-123"); + assertThat(result.stopReason()).isEqualTo(StopReason.CONTENT_FILTERED); + assertThat(result.usage()) + .isEqualTo( + AgentMetrics.TokenUsage.builder().inputTokenCount(10).outputTokenCount(0).build()); } @Test @@ -333,11 +282,10 @@ void toAssistantMessage_convertsFromChatResponse_withoutMetadata() { final var result = chatMessageConverter.toAssistantMessage(chatResponse); - assertThat(result.metadata()).containsKey("framework"); - assertThat(result.metadata().get("framework")) - .isNotNull() - .asInstanceOf(InstanceOfAssertFactories.MAP) - .isEmpty(); + assertThat(result.metadata()).containsOnlyKeys("timestamp"); + assertThat(result.messageId()).isNull(); + assertThat(result.stopReason()).isNull(); + assertThat(result.usage()).isNull(); } @Test @@ -426,4 +374,133 @@ public Map metadata() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Unknown message type"); } + + @Test + void toAssistantMessage_populatesTypedProvenanceFields() { + final var aiMessage = AiMessage.builder().text("Hello").build(); + final var chatResponseMetadata = + ChatResponseMetadata.builder() + .id("msg-abc") + .modelName("claude-3-7-sonnet") + .finishReason(FinishReason.STOP) + .tokenUsage(new TokenUsage(5, 10)) + .build(); + final var chatResponse = + new ChatResponse.Builder().aiMessage(aiMessage).metadata(chatResponseMetadata).build(); + + final var result = chatMessageConverter.toAssistantMessage(chatResponse); + + assertThat(result.messageId()).isEqualTo("msg-abc"); + assertThat(result.modelId()).isEqualTo("claude-3-7-sonnet"); + assertThat(result.stopReason()).isEqualTo(StopReason.STOP); + assertThat(result.usage()) + .isEqualTo( + AgentMetrics.TokenUsage.builder().inputTokenCount(5).outputTokenCount(10).build()); + } + + @Test + void toAssistantMessage_extractsAnthropicCacheTokens() { + final var aiMessage = AiMessage.builder().build(); + final var anthropicTokenUsage = + AnthropicTokenUsage.builder() + .inputTokenCount(10) + .outputTokenCount(5) + .cacheReadInputTokens(3) + .cacheCreationInputTokens(7) + .build(); + final var chatResponseMetadata = + ChatResponseMetadata.builder().tokenUsage(anthropicTokenUsage).build(); + final var chatResponse = + new ChatResponse.Builder().aiMessage(aiMessage).metadata(chatResponseMetadata).build(); + + final var result = chatMessageConverter.toAssistantMessage(chatResponse); + + assertThat(result.usage()) + .isEqualTo( + AgentMetrics.TokenUsage.builder() + .inputTokenCount(10) + .outputTokenCount(5) + .cacheReadInputTokenCount(3) + .cacheCreationInputTokenCount(7) + .build()); + } + + @Test + void toAssistantMessage_extractsBedrockCacheTokens() { + final var aiMessage = AiMessage.builder().build(); + final var bedrockTokenUsage = + BedrockTokenUsage.builder() + .inputTokenCount(8) + .outputTokenCount(4) + .cacheReadInputTokens(2) + .cacheWriteInputTokens(6) + .build(); + final var chatResponseMetadata = + ChatResponseMetadata.builder().tokenUsage(bedrockTokenUsage).build(); + final var chatResponse = + new ChatResponse.Builder().aiMessage(aiMessage).metadata(chatResponseMetadata).build(); + + final var result = chatMessageConverter.toAssistantMessage(chatResponse); + + assertThat(result.usage()) + .isEqualTo( + AgentMetrics.TokenUsage.builder() + .inputTokenCount(8) + .outputTokenCount(4) + .cacheReadInputTokenCount(2) + .cacheCreationInputTokenCount(6) + .build()); + } + + @Test + void toAssistantMessage_extractsOpenAiReasoningTokens() { + final var aiMessage = AiMessage.builder().build(); + final var openAiTokenUsage = + OpenAiTokenUsage.builder() + .inputTokenCount(12) + .inputTokensDetails( + OpenAiTokenUsage.InputTokensDetails.builder().cachedTokens(4).build()) + .outputTokenCount(8) + .outputTokensDetails( + OpenAiTokenUsage.OutputTokensDetails.builder().reasoningTokens(3).build()) + .build(); + final var chatResponseMetadata = + ChatResponseMetadata.builder().tokenUsage(openAiTokenUsage).build(); + final var chatResponse = + new ChatResponse.Builder().aiMessage(aiMessage).metadata(chatResponseMetadata).build(); + + final var result = chatMessageConverter.toAssistantMessage(chatResponse); + + assertThat(result.usage()) + .isEqualTo( + AgentMetrics.TokenUsage.builder() + .inputTokenCount(12) + .outputTokenCount(8) + .cacheReadInputTokenCount(4) + .reasoningTokenCount(3) + .build()); + } + + @ParameterizedTest + @MethodSource("finishReasonMappings") + void toAssistantMessage_finishReasonMapping(FinishReason finishReason, StopReason expected) { + final var aiMessage = AiMessage.builder().build(); + final var chatResponseMetadata = + ChatResponseMetadata.builder().finishReason(finishReason).build(); + final var chatResponse = + new ChatResponse.Builder().aiMessage(aiMessage).metadata(chatResponseMetadata).build(); + + final var result = chatMessageConverter.toAssistantMessage(chatResponse); + + assertThat(result.stopReason()).isEqualTo(expected); + } + + static Stream finishReasonMappings() { + return Stream.of( + arguments(FinishReason.STOP, StopReason.STOP), + arguments(FinishReason.LENGTH, StopReason.LENGTH), + arguments(FinishReason.TOOL_EXECUTION, StopReason.TOOL_USE), + arguments(FinishReason.CONTENT_FILTER, StopReason.CONTENT_FILTERED), + arguments(FinishReason.OTHER, null)); + } } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatModelFactoryTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatModelFactoryTest.java index 94b05b336c7..be86f456816 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatModelFactoryTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ChatModelFactoryTest.java @@ -15,7 +15,7 @@ import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.ChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.ChatModelProviderRegistry; import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication.AnthropicApiKeyAuthentication; import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicConnection; import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicModel; import io.camunda.connector.agenticai.aiagent.model.request.provider.ProviderConfiguration; @@ -36,7 +36,8 @@ void delegatesToProviderResolvedFromRegistry() { new AnthropicProviderConfiguration( new AnthropicConnection( null, - new AnthropicAuthentication("api-key"), + null, + new AnthropicApiKeyAuthentication("api-key"), null, new AnthropicModel("claude", null))); final var expectedChatModel = mock(ChatModel.class); diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterTest.java index d61889fd5d9..6b949b5ece5 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/ContentConverterTest.java @@ -7,17 +7,21 @@ package io.camunda.connector.agenticai.aiagent.framework.langchain4j; import static io.camunda.connector.agenticai.model.message.content.ObjectContent.objectContent; +import static io.camunda.connector.agenticai.model.message.content.ReasoningContent.reasoningContent; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentConverterImpl; import io.camunda.connector.agenticai.model.message.content.DocumentContent; import io.camunda.connector.agenticai.model.message.content.ObjectContent; +import io.camunda.connector.agenticai.model.message.content.ReasoningContent; import io.camunda.connector.agenticai.model.message.content.TextContent; import io.camunda.connector.api.document.Document; import io.camunda.connector.api.document.DocumentCreationRequest; import io.camunda.connector.api.document.DocumentFactory; +import io.camunda.connector.document.jackson.JacksonModuleDocumentSerializer; import io.camunda.connector.runtime.core.document.DocumentFactoryImpl; import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; import java.nio.charset.StandardCharsets; @@ -35,8 +39,10 @@ class ContentConverterTest { private final InMemoryDocumentStore documentStore = InMemoryDocumentStore.INSTANCE; private final DocumentFactory documentFactory = new DocumentFactoryImpl(documentStore); + private final ObjectMapper objectMapper = + new ObjectMapper().registerModule(new JacksonModuleDocumentSerializer()); private final ContentConverter contentConverter = - new ContentConverterImpl(new ObjectMapper(), new DocumentToContentConverterImpl()); + new ContentConverterImpl(objectMapper, new DocumentToContentConverterImpl()); @BeforeEach void setUp() { @@ -81,6 +87,15 @@ void supportsObjectContent() throws JsonProcessingException { assertThat(((dev.langchain4j.data.message.TextContent) content).text()) .isEqualTo("{\"key\":\"value\"}"); } + + @Test + void convertReasoningContent_throwsUnsupported() { + final ReasoningContent reasoningContent = reasoningContent("thinking..."); + + assertThatThrownBy(() -> contentConverter.convertToContent(reasoningContent)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("ReasoningContent is not supported by the LangChain4j"); + } } @Nested @@ -120,7 +135,7 @@ void supportsObjectContent() throws JsonProcessingException, JSONException { } @Test - void supportsObjectContentContainingCamundaDocuments() + void serializesDocumentsAsReferencesInObjectContent() throws JsonProcessingException, JSONException { final var content = new LinkedHashMap(); content.put("hello", "world"); @@ -128,24 +143,30 @@ void supportsObjectContentContainingCamundaDocuments() content.put("document2", createDocument("", "application/pdf", "test.pdf")); final var stringResult = contentConverter.convertToString(content); + + // documents are serialized as document references (lenient: ignores dynamic IDs) JSONAssert.assertEquals( """ { "hello": "world", "document1": { - "type": "text", - "media_type": "text/plain", - "data": "Hello, world!" + "camunda.document.type": "camunda", + "metadata": { + "contentType": "text/plain", + "fileName": "test.txt" + } }, "document2": { - "type": "base64", - "media_type": "application/pdf", - "data": "PFBERiBDT05URU5UPg==" + "camunda.document.type": "camunda", + "metadata": { + "contentType": "application/pdf", + "fileName": "test.pdf" + } } } """, stringResult, - true); + false); } } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkAdapterTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkAdapterTest.java deleted file mode 100644 index 3fbeff8565d..00000000000 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JAiFrameworkAdapterTest.java +++ /dev/null @@ -1,315 +0,0 @@ -/* - * 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.agenticai.aiagent.framework.langchain4j; - -import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.assistantMessage; -import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.systemMessage; -import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.userMessage; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.when; - -import dev.langchain4j.agent.tool.ToolSpecification; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.exception.ModelNotFoundException; -import dev.langchain4j.exception.UnresolvedModelServerException; -import dev.langchain4j.model.chat.ChatModel; -import dev.langchain4j.model.chat.request.ChatRequest; -import dev.langchain4j.model.chat.request.ResponseFormatType; -import dev.langchain4j.model.chat.request.json.JsonObjectSchema; -import dev.langchain4j.model.chat.response.ChatResponse; -import dev.langchain4j.model.output.TokenUsage; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.jsonschema.JsonSchemaConverter; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverter; -import io.camunda.connector.agenticai.aiagent.memory.runtime.DefaultRuntimeMemory; -import io.camunda.connector.agenticai.aiagent.memory.runtime.RuntimeMemory; -import io.camunda.connector.agenticai.aiagent.model.AgentContext; -import io.camunda.connector.agenticai.aiagent.model.AgentExecutionContext; -import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; -import io.camunda.connector.agenticai.aiagent.model.AgentState; -import io.camunda.connector.agenticai.aiagent.model.request.OutboundConnectorResponseConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.ResponseConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.JsonResponseFormatConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.TextResponseFormatConfiguration; -import io.camunda.connector.agenticai.model.message.AssistantMessage; -import io.camunda.connector.agenticai.model.message.Message; -import io.camunda.connector.agenticai.model.tool.ToolDefinition; -import io.camunda.connector.api.error.ConnectorException; -import java.util.List; -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.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class Langchain4JAiFrameworkAdapterTest { - - private static final String SYSTEM_PROMPT = "You are a helpful assistant. Be nice."; - private static final String USER_PROMPT = "Write a haiku about the sea."; - private static final String RESPONSE_TEXT = - "Endless waves whisper | moonlight dances on the tide | secrets drift below."; - - private static final List INPUT_MESSAGES = - List.of(systemMessage(SYSTEM_PROMPT), userMessage(USER_PROMPT)); - - private static final List L4J_MESSAGES = - List.of( - dev.langchain4j.data.message.SystemMessage.systemMessage(SYSTEM_PROMPT), - dev.langchain4j.data.message.UserMessage.userMessage(USER_PROMPT)); - - private static final AssistantMessage ASSISTANT_MESSAGE = assistantMessage(RESPONSE_TEXT); - - private static final List TOOL_DEFINITIONS = - List.of( - ToolDefinition.builder().name("GetTime").description("Returns the current time").build()); - - private static final List L4J_TOOL_SPECIFICATIONS = - List.of( - ToolSpecification.builder() - .name("GetTime") - .description("Returns the current time") - .build()); - - private static final AgentContext AGENT_CONTEXT = - AgentContext.empty() - .withState(AgentState.READY) - .withToolDefinitions(TOOL_DEFINITIONS) - .withMetrics( - AgentMetrics.empty() - .withModelCalls(5) - .withTokenUsage( - AgentMetrics.TokenUsage.empty() - .withInputTokenCount(10) - .withOutputTokenCount(20))); - - @Mock private ChatModelFactory chatModelFactory; - @Mock private ChatMessageConverter chatMessageConverter; - @Mock private ToolSpecificationConverter toolSpecificationConverter; - @Mock private JsonSchemaConverter jsonSchemaConverter; - - @Mock private ChatModel chatModel; - @Mock private ChatResponse chatResponse; - - @Captor private ArgumentCaptor chatRequestCaptor; - - private RuntimeMemory runtimeMemory; - private Langchain4JAiFrameworkAdapter adapter; - - @BeforeEach - void setUp() { - runtimeMemory = new DefaultRuntimeMemory(); - runtimeMemory.addMessages(INPUT_MESSAGES); - when(chatMessageConverter.map(runtimeMemory.filteredMessages())).thenReturn(L4J_MESSAGES); - - when(toolSpecificationConverter.asToolSpecifications(TOOL_DEFINITIONS)) - .thenReturn(L4J_TOOL_SPECIFICATIONS); - - when(chatModelFactory.createChatModel(any())).thenReturn(chatModel); - when(chatModel.chat(chatRequestCaptor.capture())).thenReturn(chatResponse); - when(chatResponse.tokenUsage()).thenReturn(new TokenUsage(5, 6)); - when(chatMessageConverter.toAssistantMessage(chatResponse)).thenReturn(ASSISTANT_MESSAGE); - - adapter = - new Langchain4JAiFrameworkAdapter( - chatModelFactory, - chatMessageConverter, - toolSpecificationConverter, - jsonSchemaConverter); - } - - @Test - void modelRequestContainsMessagesAndToolSpecifications() { - adapter.executeChatRequest(createExecutionContext(), AGENT_CONTEXT, runtimeMemory); - - final var chatRequest = chatRequestCaptor.getValue(); - assertThat(chatRequest.messages()).containsExactlyElementsOf(L4J_MESSAGES); - assertThat(chatRequest.toolSpecifications()).containsExactlyElementsOf(L4J_TOOL_SPECIFICATIONS); - } - - @Test - void wrapsUnderlyingExceptionsInConnectorException() { - reset(chatModel, chatResponse, chatMessageConverter); - when(chatMessageConverter.map(runtimeMemory.filteredMessages())).thenReturn(L4J_MESSAGES); - - final var cause = new ModelNotFoundException("Model 'dummy' was not found"); - doThrow(cause).when(chatModel).chat(any(ChatRequest.class)); - - assertThatThrownBy( - () -> - adapter.executeChatRequest(createExecutionContext(), AGENT_CONTEXT, runtimeMemory)) - .isInstanceOfSatisfying( - ConnectorException.class, - ex -> { - assertThat(ex.getErrorCode()).isEqualTo("FAILED_MODEL_CALL"); - assertThat(ex.getMessage()) - .isEqualTo("Model call failed: Model 'dummy' was not found"); - assertThat(ex.getCause()).isEqualTo(cause); - }); - } - - @Test - void usesExceptionClassIfNoMessageIncludedInException() { - reset(chatModel, chatResponse, chatMessageConverter); - when(chatMessageConverter.map(runtimeMemory.filteredMessages())).thenReturn(L4J_MESSAGES); - - final var cause = new UnresolvedModelServerException((String) null); - doThrow(cause).when(chatModel).chat(any(ChatRequest.class)); - - assertThatThrownBy( - () -> - adapter.executeChatRequest(createExecutionContext(), AGENT_CONTEXT, runtimeMemory)) - .isInstanceOfSatisfying( - ConnectorException.class, - ex -> { - assertThat(ex.getErrorCode()).isEqualTo("FAILED_MODEL_CALL"); - assertThat(ex.getMessage()) - .isEqualTo("Model call failed: UnresolvedModelServerException"); - assertThat(ex.getCause()).isEqualTo(cause); - }); - } - - @Test - void doesNotExplicitelyConfigureResponseFormatWhenTextFormatIsConfigured() { - adapter.executeChatRequest(createExecutionContext(), AGENT_CONTEXT, runtimeMemory); - - final var chatRequest = chatRequestCaptor.getValue(); - assertThat(chatRequest.responseFormat()).isNull(); - } - - @Test - void doesNotExplicitelyConfigureResponseFormatWhenResponseConfigurationIsMissing() { - adapter.executeChatRequest(createExecutionContext(null), AGENT_CONTEXT, runtimeMemory); - - final var chatRequest = chatRequestCaptor.getValue(); - assertThat(chatRequest.responseFormat()).isNull(); - } - - @Test - void doesNotExplicitelyConfigureResponseFormatWhenResponseFormatConfigurationIsMissing() { - adapter.executeChatRequest( - createExecutionContext(new OutboundConnectorResponseConfiguration(null, false)), - AGENT_CONTEXT, - runtimeMemory); - - final var chatRequest = chatRequestCaptor.getValue(); - assertThat(chatRequest.responseFormat()).isNull(); - } - - @Test - void requestsJsonResponseWhenConfigured() { - adapter.executeChatRequest( - createExecutionContext( - new OutboundConnectorResponseConfiguration( - new JsonResponseFormatConfiguration(null, null), false)), - AGENT_CONTEXT, - runtimeMemory); - - final var chatRequest = chatRequestCaptor.getValue(); - assertThat(chatRequest.responseFormat().type()).isEqualTo(ResponseFormatType.JSON); - assertThat(chatRequest.responseFormat().jsonSchema()).isNull(); - } - - @Test - void requestsJsonResponseWithSchemaWhenConfigured() { - final Map schema = Map.of("type", "object", "description", "My schema"); - final var schemaName = "Foo"; - - final var jsonObjectSchema = JsonObjectSchema.builder().description("My schema").build(); - when(jsonSchemaConverter.mapToSchema(schema)).thenReturn(jsonObjectSchema); - - adapter.executeChatRequest( - createExecutionContext( - new OutboundConnectorResponseConfiguration( - new JsonResponseFormatConfiguration(schema, schemaName), false)), - AGENT_CONTEXT, - runtimeMemory); - - final var chatRequest = chatRequestCaptor.getValue(); - assertThat(chatRequest.responseFormat().type()).isEqualTo(ResponseFormatType.JSON); - assertThat(chatRequest.responseFormat().jsonSchema()).isNotNull(); - assertThat(chatRequest.responseFormat().jsonSchema().name()).isEqualTo(schemaName); - assertThat(chatRequest.responseFormat().jsonSchema().rootElement()).isEqualTo(jsonObjectSchema); - } - - @ParameterizedTest - @NullAndEmptySource - @ValueSource(strings = {" "}) - void usesDefaultSchemaNameIfNullOrBlank(String schemaName) { - final Map schema = Map.of("type", "object", "description", "My schema"); - - final var jsonObjectSchema = JsonObjectSchema.builder().description("My schema").build(); - when(jsonSchemaConverter.mapToSchema(schema)).thenReturn(jsonObjectSchema); - - adapter.executeChatRequest( - createExecutionContext( - new OutboundConnectorResponseConfiguration( - new JsonResponseFormatConfiguration(schema, schemaName), false)), - AGENT_CONTEXT, - runtimeMemory); - - final var chatRequest = chatRequestCaptor.getValue(); - assertThat(chatRequest.responseFormat().type()).isEqualTo(ResponseFormatType.JSON); - assertThat(chatRequest.responseFormat().jsonSchema()).isNotNull(); - assertThat(chatRequest.responseFormat().jsonSchema().name()).isEqualTo("Response"); - assertThat(chatRequest.responseFormat().jsonSchema().rootElement()).isEqualTo(jsonObjectSchema); - } - - @Test - void incrementsMetricsFromResponse() { - final var adapterResponse = - adapter.executeChatRequest(createExecutionContext(), AGENT_CONTEXT, runtimeMemory); - final var expectedMetrics = - AGENT_CONTEXT - .metrics() - .withModelCalls(6) - .withTokenUsage( - AgentMetrics.TokenUsage.empty() - .withInputTokenCount(15) // 10 from context + 5 from response - .withOutputTokenCount(26)); // 20 from context + 6 from response - - assertThat(adapterResponse.agentContext()) - .usingRecursiveComparison() - .isEqualTo(AGENT_CONTEXT.withMetrics(expectedMetrics)); - } - - @Test - void tokenUsageIsUnchangedIfMissingInResponse() { - when(chatResponse.tokenUsage()).thenReturn(null); - - final var adapterResponse = - adapter.executeChatRequest(createExecutionContext(), AGENT_CONTEXT, runtimeMemory); - - assertThat(adapterResponse.agentContext()) - .usingRecursiveComparison() - .isEqualTo(AGENT_CONTEXT.withMetrics(AGENT_CONTEXT.metrics().withModelCalls(6))); - } - - private AgentExecutionContext createExecutionContext() { - return createExecutionContext( - new OutboundConnectorResponseConfiguration( - new TextResponseFormatConfiguration(false), false)); - } - - private AgentExecutionContext createExecutionContext( - ResponseConfiguration responseConfiguration) { - final var executionContext = mock(AgentExecutionContext.class); - when(executionContext.response()).thenReturn(responseConfiguration); - - return executionContext; - } -} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApiTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApiTest.java new file mode 100644 index 00000000000..e8d77c399e4 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/Langchain4JChatModelApiTest.java @@ -0,0 +1,242 @@ +/* + * 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.agenticai.aiagent.framework.langchain4j; + +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.assistantMessage; +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.systemMessage; +import static io.camunda.connector.agenticai.aiagent.TestMessagesFixture.userMessage; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.exception.ModelNotFoundException; +import dev.langchain4j.exception.UnresolvedModelServerException; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.response.ChatResponse; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatOptions; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatStreamListener; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.jsonschema.JsonSchemaConverter; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverter; +import io.camunda.connector.agenticai.aiagent.model.AgentMetrics; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.JsonResponseFormatConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.ResponseFormatConfiguration.TextResponseFormatConfiguration; +import io.camunda.connector.agenticai.model.message.AssistantMessage; +import io.camunda.connector.agenticai.model.message.Message; +import io.camunda.connector.agenticai.model.tool.ToolDefinition; +import io.camunda.connector.api.error.ConnectorException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class Langchain4JChatModelApiTest { + + private static final String SYSTEM_PROMPT = "You are a helpful assistant. Be nice."; + private static final String USER_PROMPT = "Write a haiku about the sea."; + private static final String RESPONSE_TEXT = + "Endless waves whisper | moonlight dances on the tide | secrets drift below."; + + private static final List INPUT_MESSAGES = + List.of(systemMessage(SYSTEM_PROMPT), userMessage(USER_PROMPT)); + + private static final List L4J_MESSAGES = + List.of( + dev.langchain4j.data.message.SystemMessage.systemMessage(SYSTEM_PROMPT), + dev.langchain4j.data.message.UserMessage.userMessage(USER_PROMPT)); + + private static final AssistantMessage ASSISTANT_MESSAGE = assistantMessage(RESPONSE_TEXT); + + private static final List TOOL_DEFINITIONS = + List.of( + ToolDefinition.builder().name("GetTime").description("Returns the current time").build()); + + private static final List L4J_TOOL_SPECIFICATIONS = + List.of( + ToolSpecification.builder() + .name("GetTime") + .description("Returns the current time") + .build()); + + @Mock private ChatModel chatModel; + @Mock private ChatMessageConverter chatMessageConverter; + @Mock private ToolSpecificationConverter toolSpecificationConverter; + @Mock private JsonSchemaConverter jsonSchemaConverter; + + @Mock private ChatResponse chatResponse; + + @Captor private ArgumentCaptor chatRequestCaptor; + + private Langchain4JChatModelApi api; + + @BeforeEach + void setUp() { + when(chatMessageConverter.map(INPUT_MESSAGES)).thenReturn(L4J_MESSAGES); + when(toolSpecificationConverter.asToolSpecifications(TOOL_DEFINITIONS)) + .thenReturn(L4J_TOOL_SPECIFICATIONS); + when(chatModel.chat(chatRequestCaptor.capture())).thenReturn(chatResponse); + when(chatMessageConverter.toAssistantMessage(chatResponse)) + .thenReturn( + ASSISTANT_MESSAGE.withUsage( + AgentMetrics.TokenUsage.builder().inputTokenCount(5).outputTokenCount(6).build())); + + api = + new Langchain4JChatModelApi( + chatModel, chatMessageConverter, toolSpecificationConverter, jsonSchemaConverter); + } + + @Test + void modelRequestContainsMessagesAndToolSpecifications() { + complete(null); + + final var chatRequest = chatRequestCaptor.getValue(); + assertThat(chatRequest.messages()).containsExactlyElementsOf(L4J_MESSAGES); + assertThat(chatRequest.toolSpecifications()).containsExactlyElementsOf(L4J_TOOL_SPECIFICATIONS); + } + + @Test + void wrapsUnderlyingExceptionsInConnectorException() { + reset(chatModel, chatResponse, chatMessageConverter); + when(chatMessageConverter.map(INPUT_MESSAGES)).thenReturn(L4J_MESSAGES); + + final var cause = new ModelNotFoundException("Model 'dummy' was not found"); + doThrow(cause).when(chatModel).chat(any(ChatRequest.class)); + + assertThatThrownBy(() -> complete(null).join()) + .isInstanceOfSatisfying( + CompletionException.class, + wrapper -> + assertThat(wrapper.getCause()) + .isInstanceOfSatisfying( + ConnectorException.class, + ex -> { + assertThat(ex.getErrorCode()).isEqualTo("FAILED_MODEL_CALL"); + assertThat(ex.getMessage()) + .isEqualTo("Model call failed: Model 'dummy' was not found"); + assertThat(ex.getCause()).isEqualTo(cause); + })); + } + + @Test + void usesExceptionClassIfNoMessageIncludedInException() { + reset(chatModel, chatResponse, chatMessageConverter); + when(chatMessageConverter.map(INPUT_MESSAGES)).thenReturn(L4J_MESSAGES); + + final var cause = new UnresolvedModelServerException((String) null); + doThrow(cause).when(chatModel).chat(any(ChatRequest.class)); + + assertThatThrownBy(() -> complete(null).join()) + .isInstanceOfSatisfying( + CompletionException.class, + wrapper -> + assertThat(wrapper.getCause()) + .isInstanceOfSatisfying( + ConnectorException.class, + ex -> { + assertThat(ex.getErrorCode()).isEqualTo("FAILED_MODEL_CALL"); + assertThat(ex.getMessage()) + .isEqualTo("Model call failed: UnresolvedModelServerException"); + assertThat(ex.getCause()).isEqualTo(cause); + })); + } + + @Test + void doesNotExplicitlyConfigureResponseFormatWhenNull() { + complete(null); + + assertThat(chatRequestCaptor.getValue().responseFormat()).isNull(); + } + + @Test + void doesNotExplicitlyConfigureResponseFormatForText() { + complete(new TextResponseFormatConfiguration(false)); + + assertThat(chatRequestCaptor.getValue().responseFormat()).isNull(); + } + + @Test + void requestsJsonResponseWhenConfigured() { + complete(new JsonResponseFormatConfiguration(null, null)); + + final var format = chatRequestCaptor.getValue().responseFormat(); + assertThat(format.type()).isEqualTo(ResponseFormatType.JSON); + assertThat(format.jsonSchema()).isNull(); + } + + @Test + void requestsJsonResponseWithSchemaWhenConfigured() { + final Map schema = Map.of("type", "object", "description", "My schema"); + final var schemaName = "Foo"; + + final var jsonObjectSchema = JsonObjectSchema.builder().description("My schema").build(); + when(jsonSchemaConverter.mapToSchema(schema)).thenReturn(jsonObjectSchema); + + complete(new JsonResponseFormatConfiguration(schema, schemaName)); + + final var format = chatRequestCaptor.getValue().responseFormat(); + assertThat(format.type()).isEqualTo(ResponseFormatType.JSON); + assertThat(format.jsonSchema()).isNotNull(); + assertThat(format.jsonSchema().name()).isEqualTo(schemaName); + assertThat(format.jsonSchema().rootElement()).isEqualTo(jsonObjectSchema); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void usesDefaultSchemaNameIfNullOrBlank(String schemaName) { + final Map schema = Map.of("type", "object", "description", "My schema"); + + final var jsonObjectSchema = JsonObjectSchema.builder().description("My schema").build(); + when(jsonSchemaConverter.mapToSchema(schema)).thenReturn(jsonObjectSchema); + + complete(new JsonResponseFormatConfiguration(schema, schemaName)); + + final var format = chatRequestCaptor.getValue().responseFormat(); + assertThat(format.type()).isEqualTo(ResponseFormatType.JSON); + assertThat(format.jsonSchema()).isNotNull(); + assertThat(format.jsonSchema().name()).isEqualTo("Response"); + assertThat(format.jsonSchema().rootElement()).isEqualTo(jsonObjectSchema); + } + + @Test + void responseCarriesAssistantMessageWithUsage() { + final var response = complete(null).join(); + + assertThat(response.assistantMessage()).isNotNull(); + assertThat(response.assistantMessage().usage()) + .isEqualTo( + AgentMetrics.TokenUsage.builder().inputTokenCount(5).outputTokenCount(6).build()); + } + + private java.util.concurrent.CompletableFuture< + io.camunda.connector.agenticai.aiagent.framework.api.ChatResponse> + complete(ResponseFormatConfiguration responseFormat) { + return api.complete( + new io.camunda.connector.agenticai.aiagent.framework.api.ChatRequest( + INPUT_MESSAGES, TOOL_DEFINITIONS, responseFormat), + new ChatOptions(null, null, null, Map.of()), + ChatStreamListener.NOOP); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentSerializerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentSerializerTest.java deleted file mode 100644 index 68e3826faa8..00000000000 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/framework/langchain4j/document/DocumentToContentSerializerTest.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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.agenticai.aiagent.framework.langchain4j.document; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.langchain4j.data.message.VideoContent; -import io.camunda.connector.api.document.Document; -import io.camunda.connector.api.document.DocumentCreationRequest; -import io.camunda.connector.api.document.DocumentFactory; -import io.camunda.connector.runtime.core.document.DocumentFactoryImpl; -import io.camunda.connector.runtime.core.document.store.InMemoryDocumentStore; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.List; -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.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.skyscreamer.jsonassert.JSONAssert; - -@ExtendWith(MockitoExtension.class) -class DocumentToContentSerializerTest { - - @Spy - private final DocumentToContentConverter contentConverter = new DocumentToContentConverterImpl(); - - private final InMemoryDocumentStore documentStore = InMemoryDocumentStore.INSTANCE; - private final DocumentFactory documentFactory = new DocumentFactoryImpl(documentStore); - - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper().registerModule(new DocumentToContentModule(contentConverter)); - - documentStore.clear(); - } - - @ParameterizedTest - @CsvSource({ - "test.txt,text/plain", - "test.csv,text/csv", - "test.json,application/json", - "test.xml,application/xml", - "test.yaml,application/yaml" - }) - void serializesTextTypesAsText(String filename, String contentType) throws Exception { - final var document = createDocument("Hello, world!", contentType, filename); - final var serializedDocument = objectMapper.writeValueAsString(document); - - JSONAssert.assertEquals( - """ - { - "type" : "text", - "media_type" : "%s", - "data" : "Hello, world!" - } - """ - .formatted(contentType), - serializedDocument, - true); - } - - @ParameterizedTest - @CsvSource({ - "test.gif,image/gif", - "test.jpg,image/jpeg", - "test.pdf,application/pdf", - "test.png,image/png", - "test.webp,image/webp" - }) - void serializesFileContentAsBase64(String filename, String contentType) throws Exception { - final var document = createDocument("Hello, world!", contentType, filename); - final var serializedDocument = objectMapper.writeValueAsString(document); - - JSONAssert.assertEquals( - """ - { - "type" : "base64", - "media_type" : "%s", - "data" : "SGVsbG8sIHdvcmxkIQ==" - } - """ - .formatted(contentType), - serializedDocument, - true); - } - - @Test - void supportsSerializingDocumentsInNestedStructures() throws Exception { - final var input = new LinkedHashMap(); - input.put("hello", "world"); - input.put("document1", createDocument("Hello, world!", "text/plain", "test.txt")); - input.put( - "documents", - List.of( - createDocument("", "application/pdf", "test.pdf"), - createDocument("", "image/png", "image.png"))); - input.put( - "map", - Map.of( - "some_json", createDocument("{\"foo\": \"bar\"}", "application/json", "test.json"), - "some_csv", createDocument("foo,bar", "text/csv", "test.csv"))); - - final var serialized = objectMapper.writeValueAsString(input); - - JSONAssert.assertEquals( - """ - { - "hello": "world", - "document1": { - "type": "text", - "media_type": "text/plain", - "data": "Hello, world!" - }, - "documents": [ - { - "type": "base64", - "media_type": "application/pdf", - "data": "PFBERiBDT05URU5UPg==" - }, - { - "type": "base64", - "media_type": "image/png", - "data": "PElNQUdFIENPTlRFTlQ+" - } - ], - "map": { - "some_csv": { - "type": "text", - "media_type": "text/csv", - "data": "foo,bar" - }, - "some_json": { - "type": "text", - "media_type": "application/json", - "data": "{\\"foo\\": \\"bar\\"}" - } - } - } - """, - serialized, - true); - } - - @Test - void throwsExceptionOnUnsupportedContentType() { - final var document = createDocument("Hello, world!", "text/plain", "test.txt"); - when(contentConverter.convert(document)) - .thenReturn(VideoContent.from("

Covers every row of the ADR 005 migration table: + * + *

    + *
  1. Old bedrock + Anthropic model → AnthropicProviderConfiguration(backend=bedrock) + *
  2. Old bedrock + Amazon model → BedrockProviderConfiguration (passthrough) + *
  3. Old googleVertexAi → GoogleGenAiProviderConfiguration(backend=vertex) + *
  4. anthropic (no backend) → backend=direct injected + *
  5. anthropic (no auth type) → auth.type=apiKey injected + *
  6. openai (no backend) → backend=openai, apiFamily=completions injected + *
  7. Old azureOpenAi → openai/foundry with auth + endpoint mapping + *
  8. Old openaiCompatible → openai/custom with auth discriminator injected + *
  9. Idempotence: deserialize old → serialize → deserialize → same result + *
  10. ConnectorsObjectMapper round-trip: no collision with document/FEEL modules + *
+ */ +class ProviderConfigurationDeserializerTest { + + // Use the ConnectorsObjectMapper configuration (ignores unknown properties, handles + // Java time types) — required so that round-trip serialization of records whose + // @AssertFalse boolean methods become JSON properties does not fail on re-read. + private static final ObjectMapper MAPPER = TestObjectMapperSupplier.getInstance(); + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 1: old bedrock + Anthropic model → anthropic/bedrock + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule1_oldBedrockWithAnthropicModel_becomesAnthropicBackendBedrock() throws Exception { + var json = + """ + { + "type": "bedrock", + "bedrock": { + "region": "us-east-1", + "authentication": { "type": "credentials", "accessKey": "ak", "secretKey": "sk" }, + "model": { "model": "anthropic.claude-sonnet-4-5" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(AnthropicProviderConfiguration.class); + var anthropic = (AnthropicProviderConfiguration) result; + assertThat(anthropic.anthropic().backend()).isEqualTo(AnthropicBackend.BEDROCK); + assertThat(anthropic.anthropic().model().model()).isEqualTo("anthropic.claude-sonnet-4-5"); + } + + @Test + void rule1_oldBedrockWithAnthropicCrossRegionModelPrefix_becomesAnthropicBackendBedrock() + throws Exception { + var json = + """ + { + "type": "bedrock", + "bedrock": { + "region": "us-east-1", + "authentication": { "type": "defaultCredentialsChain" }, + "model": { "model": "anthropic.claude-3-haiku-20240307-v1:0" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(AnthropicProviderConfiguration.class); + var anthropic = (AnthropicProviderConfiguration) result; + assertThat(anthropic.anthropic().backend()).isEqualTo(AnthropicBackend.BEDROCK); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 2: old bedrock + non-Anthropic model → passthrough + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule2_oldBedrockWithAmazonModel_passthrough() throws Exception { + var json = + """ + { + "type": "bedrock", + "bedrock": { + "region": "us-east-1", + "authentication": { "type": "credentials", "accessKey": "ak", "secretKey": "sk" }, + "model": { "model": "amazon.nova-pro-v1:0" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(BedrockProviderConfiguration.class); + var bedrock = (BedrockProviderConfiguration) result; + assertThat(bedrock.bedrock().model().model()).isEqualTo("amazon.nova-pro-v1:0"); + } + + @Test + void rule2_oldBedrockWithMetaModel_passthrough() throws Exception { + var json = + """ + { + "type": "bedrock", + "bedrock": { + "region": "eu-west-1", + "authentication": { "type": "credentials", "accessKey": "ak", "secretKey": "sk" }, + "model": { "model": "meta.llama3-8b-instruct-v1:0" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(BedrockProviderConfiguration.class); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 3: googleVertexAi → googleGenAi/vertex + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule3_oldGoogleVertexAi_becomesGoogleGenAiBackendVertex() throws Exception { + var json = + """ + { + "type": "googleVertexAi", + "googleVertexAi": { + "projectId": "my-project", + "region": "us-central1", + "authentication": { "type": "serviceAccountCredentials", "jsonKey": "{}" }, + "model": { "model": "gemini-1.5-flash" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(GoogleGenAiProviderConfiguration.class); + var google = (GoogleGenAiProviderConfiguration) result; + assertThat(google.googleGenAi().backend()).isEqualTo(GoogleBackend.VERTEX); + assertThat(google.googleGenAi().projectId()).isEqualTo("my-project"); + assertThat(google.googleGenAi().region()).isEqualTo("us-central1"); + assertThat(google.googleGenAi().model().model()).isEqualTo("gemini-1.5-flash"); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 4: anthropic without backend → inject backend=direct + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule4_anthropicWithoutBackend_injectsBackendDirect() throws Exception { + var json = + """ + { + "type": "anthropic", + "anthropic": { + "authentication": { "type": "apiKey", "apiKey": "my-key" }, + "model": { "model": "claude-sonnet-4-6" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(AnthropicProviderConfiguration.class); + var anthropic = (AnthropicProviderConfiguration) result; + assertThat(anthropic.anthropic().backend()).isEqualTo(AnthropicBackend.DIRECT); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 5: anthropic with authentication but missing type discriminator + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule5_anthropicWithoutAuthType_injectsApiKeyDiscriminator() throws Exception { + var json = + """ + { + "type": "anthropic", + "anthropic": { + "backend": "direct", + "authentication": { "apiKey": "my-secret-key" }, + "model": { "model": "claude-sonnet-4-6" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(AnthropicProviderConfiguration.class); + var anthropic = (AnthropicProviderConfiguration) result; + assertThat(anthropic.anthropic().authentication()) + .isInstanceOf(AnthropicApiKeyAuthentication.class); + assertThat(((AnthropicApiKeyAuthentication) anthropic.anthropic().authentication()).apiKey()) + .isEqualTo("my-secret-key"); + } + + @Test + void rule4and5_anthropicWithoutBackendOrAuthType_injectsBoth() throws Exception { + var json = + """ + { + "type": "anthropic", + "anthropic": { + "authentication": { "apiKey": "combined-key" }, + "model": { "model": "claude-haiku-4-5" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(AnthropicProviderConfiguration.class); + var anthropic = (AnthropicProviderConfiguration) result; + assertThat(anthropic.anthropic().backend()).isEqualTo(AnthropicBackend.DIRECT); + assertThat(anthropic.anthropic().authentication()) + .isInstanceOf(AnthropicApiKeyAuthentication.class); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 6: openai without backend/apiFamily → inject defaults + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule6_openaiWithoutBackend_injectsBackendOpenaiAndApiFamilyCompletions() throws Exception { + var json = + """ + { + "type": "openai", + "openai": { + "authentication": { "type": "apiKey", "apiKey": "sk-test" }, + "model": { "model": "gpt-4o" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(OpenAiProviderConfiguration.class); + var openai = (OpenAiProviderConfiguration) result; + assertThat(openai.openai().backend()).isEqualTo(OpenAiBackend.OPENAI); + assertThat(openai.openai().apiFamily()).isEqualTo(ApiFamily.COMPLETIONS); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 7: azureOpenAi → openai/foundry + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule7_azureOpenAiWithApiKey_becomesOpenAiFoundryWithFieldMapping() throws Exception { + var json = + """ + { + "type": "azureOpenAi", + "azureOpenAi": { + "endpoint": "https://my-azure.openai.azure.com", + "authentication": { "type": "apiKey", "apiKey": "azure-api-key" }, + "model": { "deploymentName": "gpt-4o-deployment", "parameters": { "maxCompletionTokens": 1000 } } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(OpenAiProviderConfiguration.class); + var openai = (OpenAiProviderConfiguration) result; + assertThat(openai.openai().backend()).isEqualTo(OpenAiBackend.FOUNDRY); + assertThat(openai.openai().apiFamily()).isEqualTo(ApiFamily.COMPLETIONS); + assertThat(openai.openai().endpoint()).isEqualTo("https://my-azure.openai.azure.com"); + assertThat(openai.openai().authentication()).isInstanceOf(OpenAiApiKeyAuthentication.class); + assertThat(((OpenAiApiKeyAuthentication) openai.openai().authentication()).apiKey()) + .isEqualTo("azure-api-key"); + // deploymentName maps to model.model + assertThat(openai.openai().model().model()).isEqualTo("gpt-4o-deployment"); + assertThat(openai.openai().model().parameters().maxCompletionTokens()).isEqualTo(1000); + } + + @Test + void rule7_azureOpenAiWithClientCredentials_becomesOpenAiFoundry() throws Exception { + var json = + """ + { + "type": "azureOpenAi", + "azureOpenAi": { + "endpoint": "https://my-azure.openai.azure.com", + "authentication": { + "type": "clientCredentials", + "clientId": "cid", + "clientSecret": "csecret", + "tenantId": "tid" + }, + "model": { "deploymentName": "gpt-4o-turbo" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(OpenAiProviderConfiguration.class); + var openai = (OpenAiProviderConfiguration) result; + assertThat(openai.openai().backend()).isEqualTo(OpenAiBackend.FOUNDRY); + assertThat(openai.openai().authentication()) + .isInstanceOf( + OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiClientCredentialsAuthentication + .class); + assertThat(openai.openai().model().model()).isEqualTo("gpt-4o-turbo"); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 8: openaiCompatible → openai/custom + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void rule8_openaiCompatible_becomesOpenAiCustomWithAuthDiscriminator() throws Exception { + var json = + """ + { + "type": "openaiCompatible", + "openaiCompatible": { + "endpoint": "https://my-ollama.local/v1", + "authentication": { "apiKey": "ollama-key" }, + "headers": { "X-Custom": "value" }, + "queryParameters": { "version": "v2" }, + "model": { "model": "llama3" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(OpenAiProviderConfiguration.class); + var openai = (OpenAiProviderConfiguration) result; + assertThat(openai.openai().backend()).isEqualTo(OpenAiBackend.CUSTOM); + assertThat(openai.openai().apiFamily()).isEqualTo(ApiFamily.COMPLETIONS); + assertThat(openai.openai().endpoint()).isEqualTo("https://my-ollama.local/v1"); + assertThat(openai.openai().authentication()).isInstanceOf(OpenAiApiKeyAuthentication.class); + assertThat(((OpenAiApiKeyAuthentication) openai.openai().authentication()).apiKey()) + .isEqualTo("ollama-key"); + assertThat(openai.openai().headers()).containsEntry("X-Custom", "value"); + assertThat(openai.openai().queryParameters()).containsEntry("version", "v2"); + assertThat(openai.openai().model().model()).isEqualTo("llama3"); + } + + @Test + void rule8_openaiCompatibleWithoutAuth_becomesOpenAiCustomWithNullableApiKey() throws Exception { + var json = + """ + { + "type": "openaiCompatible", + "openaiCompatible": { + "endpoint": "https://my-vllm.local/v1", + "model": { "model": "mistral-7b" } + } + } + """; + + var result = MAPPER.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(OpenAiProviderConfiguration.class); + var openai = (OpenAiProviderConfiguration) result; + assertThat(openai.openai().backend()).isEqualTo(OpenAiBackend.CUSTOM); + assertThat(openai.openai().model().model()).isEqualTo("mistral-7b"); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 9: Idempotence — deserialize old → serialize → deserialize → same result + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void idempotence_oldBedrockAnthropicModel_roundTripProducesSameResult() throws Exception { + var oldJson = + """ + { + "type": "bedrock", + "bedrock": { + "region": "us-east-1", + "authentication": { "type": "credentials", "accessKey": "ak", "secretKey": "sk" }, + "model": { "model": "anthropic.claude-sonnet-4-5" } + } + } + """; + + var first = MAPPER.readValue(oldJson, ProviderConfiguration.class); + var serialized = MAPPER.writeValueAsString(first); + var second = MAPPER.readValue(serialized, ProviderConfiguration.class); + + assertThat(second).isInstanceOf(AnthropicProviderConfiguration.class); + assertThat(second).usingRecursiveComparison().isEqualTo(first); + } + + @Test + void idempotence_azureOpenAi_roundTripProducesSameResult() throws Exception { + var oldJson = + """ + { + "type": "azureOpenAi", + "azureOpenAi": { + "endpoint": "https://my-azure.openai.azure.com", + "authentication": { "type": "apiKey", "apiKey": "azure-api-key" }, + "model": { "deploymentName": "gpt-4o-deployment" } + } + } + """; + + var first = MAPPER.readValue(oldJson, ProviderConfiguration.class); + var serialized = MAPPER.writeValueAsString(first); + var second = MAPPER.readValue(serialized, ProviderConfiguration.class); + + assertThat(second).isInstanceOf(OpenAiProviderConfiguration.class); + assertThat(second).usingRecursiveComparison().isEqualTo(first); + } + + @Test + void idempotence_openaiCompatible_roundTripProducesSameResult() throws Exception { + var oldJson = + """ + { + "type": "openaiCompatible", + "openaiCompatible": { + "endpoint": "https://my-ollama.local/v1", + "authentication": { "apiKey": "ollama-key" }, + "model": { "model": "llama3" } + } + } + """; + + var first = MAPPER.readValue(oldJson, ProviderConfiguration.class); + var serialized = MAPPER.writeValueAsString(first); + var second = MAPPER.readValue(serialized, ProviderConfiguration.class); + + assertThat(second).isInstanceOf(OpenAiProviderConfiguration.class); + assertThat(second).usingRecursiveComparison().isEqualTo(first); + } + + @Test + void idempotence_googleVertexAi_roundTripProducesSameResult() throws Exception { + var oldJson = + """ + { + "type": "googleVertexAi", + "googleVertexAi": { + "projectId": "my-project", + "region": "us-central1", + "authentication": { "type": "serviceAccountCredentials", "jsonKey": "{}" }, + "model": { "model": "gemini-1.5-flash" } + } + } + """; + + var first = MAPPER.readValue(oldJson, ProviderConfiguration.class); + var serialized = MAPPER.writeValueAsString(first); + var second = MAPPER.readValue(serialized, ProviderConfiguration.class); + + assertThat(second).isInstanceOf(GoogleGenAiProviderConfiguration.class); + assertThat(second).usingRecursiveComparison().isEqualTo(first); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Rule 10: ConnectorsObjectMapper round-trip — no collision with document/FEEL modules + // ───────────────────────────────────────────────────────────────────────────── + + @Test + void connectorsObjectMapper_roundTripAnthropicConfiguration_noCollisionWithDocumentOrFeelModules() + throws Exception { + var connectorsMapper = TestObjectMapperSupplier.getInstance(); + + var original = + new AnthropicProviderConfiguration( + new AnthropicProviderConfiguration.AnthropicConnection( + null, + AnthropicBackend.DIRECT, + new AnthropicApiKeyAuthentication("my-key"), + null, + new AnthropicProviderConfiguration.AnthropicModel("claude-sonnet-4-6", null))); + + var json = connectorsMapper.writeValueAsString(original); + var deserialized = connectorsMapper.readValue(json, ProviderConfiguration.class); + + assertThat(deserialized).isInstanceOf(AnthropicProviderConfiguration.class); + assertThat(deserialized).usingRecursiveComparison().isEqualTo(original); + } + + @Test + void connectorsObjectMapper_canDeserializeLegacyShapes_withFullModuleStack() throws Exception { + var connectorsMapper = TestObjectMapperSupplier.getInstance(); + + var json = + """ + { + "type": "azureOpenAi", + "azureOpenAi": { + "endpoint": "https://my-foundry.azure.com", + "authentication": { "type": "apiKey", "apiKey": "foundry-key" }, + "model": { "deploymentName": "gpt-4o" } + } + } + """; + + var result = connectorsMapper.readValue(json, ProviderConfiguration.class); + + assertThat(result).isInstanceOf(OpenAiProviderConfiguration.class); + var openai = (OpenAiProviderConfiguration) result; + assertThat(openai.openai().backend()).isEqualTo(OpenAiBackend.FOUNDRY); + assertThat(openai.openai().model().model()).isEqualTo("gpt-4o"); + } +} diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/model/request/ProviderConfigurationTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/model/request/ProviderConfigurationTest.java index cde2d8a156d..80d783875b0 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/model/request/ProviderConfigurationTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/aiagent/model/request/ProviderConfigurationTest.java @@ -10,23 +10,24 @@ import io.camunda.connector.agenticai.aiagent.model.request.MemoryStorageConfiguration.AwsAgentCoreAuthentication; import io.camunda.connector.agenticai.aiagent.model.request.MemoryStorageConfiguration.AwsAgentCoreMemoryStorageConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication.AnthropicApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicAuthentication.AnthropicClientCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicBackend; import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicConnection; import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration.AnthropicModel; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration.AzureAuthentication; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration.AzureOpenAiConnection; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration.AzureOpenAiModel; import io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration; import io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration.AwsAuthentication; import io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration.BedrockConnection; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GoogleVertexAiAuthentication.ApplicationDefaultCredentialsAuthentication; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GoogleVertexAiAuthentication.ServiceAccountCredentialsAuthentication; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GoogleVertexAiConnection; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GoogleVertexAiModel; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration.GoogleVertexAiModel.GoogleVertexAiModelParameters; -import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration.OpenAiCompatibleAuthentication; -import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration.OpenAiCompatibleConnection; -import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration.OpenAiCompatibleModel; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GoogleGenAiAuthentication.ApplicationDefaultCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GoogleGenAiAuthentication.ServiceAccountCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GoogleGenAiConnection; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GoogleGenAiModel; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration.GoogleGenAiModel.GoogleGenAiModelParameters; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiApiKeyAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiAuthentication.OpenAiClientCredentialsAuthentication; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiBackend; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiConnection; +import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration.OpenAiModel; import io.camunda.connector.agenticai.aiagent.model.request.provider.shared.TimeoutConfiguration; import io.camunda.connector.agenticai.util.ConnectorUtils; import jakarta.validation.ConstraintViolation; @@ -38,8 +39,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.NullSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration; @@ -126,6 +127,30 @@ void shouldRejectInvalidEndpoint(String endpoint) { .contains(HTTP_URL_VALIDATION_MESSAGE); } + @Test + void validationShouldFail_WhenAnthropicModelUsedWithBedrock() { + var connection = + createConnection( + null, + new AwsAuthentication.AwsStaticCredentialsAuthentication("key", "secret"), + "anthropic.claude-sonnet-4-5"); + assertThat(validator.validate(connection)) + .hasSize(1) + .extracting(ConstraintViolation::getMessage) + .containsExactly( + "Anthropic models must be configured via the Anthropic provider with backend = BEDROCK"); + } + + @Test + void validationShouldSucceed_WhenNonAnthropicModelUsedWithBedrock() { + var connection = + createConnection( + null, + new AwsAuthentication.AwsStaticCredentialsAuthentication("key", "secret"), + "amazon.nova-pro-v1:0"); + assertThat(validator.validate(connection)).isEmpty(); + } + private BedrockConnection createConnection(String endpoint, AwsAuthentication authentication) { return new BedrockConnection( "eu-central-1", @@ -137,6 +162,19 @@ private BedrockConnection createConnection(String endpoint, AwsAuthentication au new BedrockProviderConfiguration.BedrockModel.BedrockModelParameters( null, null, null))); } + + private BedrockConnection createConnection( + String endpoint, AwsAuthentication authentication, String modelId) { + return new BedrockConnection( + "eu-central-1", + endpoint, + authentication, + TIMEOUT, + new BedrockProviderConfiguration.BedrockModel( + modelId, + new BedrockProviderConfiguration.BedrockModel.BedrockModelParameters( + null, null, null))); + } } @Nested @@ -150,7 +188,8 @@ void shouldAcceptValidEndpoint(String endpoint) { var connection = new AnthropicConnection( endpoint, - new AnthropicAuthentication("key"), + null, + new AnthropicApiKeyAuthentication("key"), TIMEOUT, new AnthropicModel("model", null)); assertThat(validator.validate(connection)).isEmpty(); @@ -163,115 +202,267 @@ void shouldRejectInvalidEndpoint(String endpoint) { var connection = new AnthropicConnection( endpoint, - new AnthropicAuthentication("key"), + null, + new AnthropicApiKeyAuthentication("key"), TIMEOUT, new AnthropicModel("model", null)); assertThat(validator.validate(connection)) .extracting(ConstraintViolation::getMessage) .contains(HTTP_URL_VALIDATION_MESSAGE); } - } - @Nested - class AzureOpenAiConnectionTest { + @Test + void validationShouldSucceed_WhenApiKeyAuthenticationUsed() { + var connection = + new AnthropicConnection( + null, + null, + new AnthropicApiKeyAuthentication("my-api-key"), + TIMEOUT, + new AnthropicModel("model", null)); + assertThat(validator.validate(connection)).isEmpty(); + } - @ParameterizedTest - @MethodSource( - "io.camunda.connector.agenticai.aiagent.model.request.ProviderConfigurationTest#validHttpUrls") - void shouldAcceptValidEndpoint(String endpoint) { + @Test + void validationShouldSucceed_WhenClientCredentialsAuthUsedWithFoundryBackend() { var connection = - new AzureOpenAiConnection( - endpoint, - new AzureAuthentication.AzureApiKeyAuthentication("key"), + new AnthropicConnection( + null, + AnthropicBackend.FOUNDRY, + new AnthropicClientCredentialsAuthentication( + "client-id", "client-secret", "tenant-id", null), TIMEOUT, - new AzureOpenAiModel("deployment", null)); + new AnthropicModel("model", null)); assertThat(validator.validate(connection)).isEmpty(); } @ParameterizedTest - @MethodSource( - "io.camunda.connector.agenticai.aiagent.model.request.ProviderConfigurationTest#invalidHttpUrls") - void shouldRejectInvalidUrlEndpoint(String endpoint) { + @EnumSource( + value = AnthropicBackend.class, + names = {"DIRECT", "BEDROCK", "VERTEX"}) + void validationShouldFail_WhenClientCredentialsAuthUsedWithNonFoundryBackend( + AnthropicBackend backend) { var connection = - new AzureOpenAiConnection( - endpoint, - new AzureAuthentication.AzureApiKeyAuthentication("key"), + new AnthropicConnection( + null, + backend, + new AnthropicClientCredentialsAuthentication( + "client-id", "client-secret", "tenant-id", null), TIMEOUT, - new AzureOpenAiModel("deployment", null)); + new AnthropicModel("model", null)); assertThat(validator.validate(connection)) + .hasSize(1) .extracting(ConstraintViolation::getMessage) - .contains(HTTP_URL_VALIDATION_MESSAGE); + .containsExactly( + "Client credentials authentication is only supported for the FOUNDRY backend"); + } + } + + @Nested + class OpenAiConnectionTest { + + @Test + void validationShouldSucceed_WhenOpenAIBackendWithApiKeyAuth() { + var connection = + new OpenAiConnection( + OpenAiBackend.OPENAI, + new OpenAiApiKeyAuthentication("my-api-key", null, null), + TIMEOUT, + new OpenAiModel("gpt-4o", null), + null, + null, + null, + null); + assertThat(validator.validate(connection)).isEmpty(); + } + + @Test + void validationShouldSucceed_WhenFoundryBackendWithClientCredentialsAuth() { + var connection = + new OpenAiConnection( + OpenAiBackend.FOUNDRY, + new OpenAiClientCredentialsAuthentication( + "client-id", "client-secret", "tenant-id", null), + TIMEOUT, + new OpenAiModel("gpt-4o", null), + null, + "https://my-foundry-endpoint.azure.com", + null, + null); + assertThat(validator.validate(connection)).isEmpty(); + } + + @Test + void validationShouldSucceed_WhenFoundryBackendWithApiKeyAuth() { + var connection = + new OpenAiConnection( + OpenAiBackend.FOUNDRY, + new OpenAiApiKeyAuthentication("my-api-key", null, null), + TIMEOUT, + new OpenAiModel("gpt-4o", null), + null, + "https://my-foundry-endpoint.azure.com", + null, + null); + assertThat(validator.validate(connection)).isEmpty(); + } + + @Test + void validationShouldSucceed_WhenCustomBackendWithApiKeyAuth() { + var connection = + new OpenAiConnection( + OpenAiBackend.CUSTOM, + new OpenAiApiKeyAuthentication(null, null, null), + TIMEOUT, + new OpenAiModel("some-model", null), + null, + "https://custom-endpoint.local/v1", + null, + null); + // apiKey may be null for CUSTOM + assertThat(validator.validate(connection)).isEmpty(); } @ParameterizedTest - @NullAndEmptySource - void shouldRejectBlankEndpoint(String endpoint) { + @EnumSource( + value = OpenAiBackend.class, + names = {"OPENAI", "CUSTOM"}) + void validationShouldFail_WhenClientCredentialsAuthUsedWithNonFoundryBackend( + OpenAiBackend backend) { var connection = - new AzureOpenAiConnection( - endpoint, - new AzureAuthentication.AzureApiKeyAuthentication("key"), + new OpenAiConnection( + backend, + new OpenAiClientCredentialsAuthentication( + "client-id", "client-secret", "tenant-id", null), TIMEOUT, - new AzureOpenAiModel("deployment", null)); + new OpenAiModel("gpt-4o", null), + null, + "https://some-endpoint.local", + null, + null); assertThat(validator.validate(connection)) + .hasSize(1) .extracting(ConstraintViolation::getMessage) - .contains("must not be blank"); + .containsExactly( + "Client credentials authentication is only supported for the FOUNDRY backend"); } - } - - @Nested - class OpenAiCompatibleConnectionTest { @ParameterizedTest @MethodSource( "io.camunda.connector.agenticai.aiagent.model.request.ProviderConfigurationTest#validHttpUrls") + @NullSource void shouldAcceptValidEndpoint(String endpoint) { var connection = - new OpenAiCompatibleConnection( - endpoint, - new OpenAiCompatibleAuthentication("key"), + new OpenAiConnection( + OpenAiBackend.OPENAI, + new OpenAiApiKeyAuthentication("key", null, null), + TIMEOUT, + new OpenAiModel("gpt-4o", null), null, + endpoint, null, - TIMEOUT, - new OpenAiCompatibleModel("model", null)); + null); assertThat(validator.validate(connection)).isEmpty(); } @ParameterizedTest @MethodSource( "io.camunda.connector.agenticai.aiagent.model.request.ProviderConfigurationTest#invalidHttpUrls") - void shouldRejectInvalidUrlEndpoint(String endpoint) { + void shouldRejectInvalidEndpoint(String endpoint) { var connection = - new OpenAiCompatibleConnection( - endpoint, - new OpenAiCompatibleAuthentication("key"), + new OpenAiConnection( + OpenAiBackend.OPENAI, + new OpenAiApiKeyAuthentication("key", null, null), + TIMEOUT, + new OpenAiModel("gpt-4o", null), null, + endpoint, null, - TIMEOUT, - new OpenAiCompatibleModel("model", null)); + null); assertThat(validator.validate(connection)) .extracting(ConstraintViolation::getMessage) .contains(HTTP_URL_VALIDATION_MESSAGE); } + @Test + void validationShouldSucceed_WhenNullBackendDefaultsToOpenAI() { + // Compact constructor sets null backend → OPENAI + var connection = + new OpenAiConnection( + new OpenAiApiKeyAuthentication("my-api-key", null, null), + TIMEOUT, + new OpenAiModel("gpt-4o", null)); + assertThat(connection.backend()).isEqualTo(OpenAiBackend.OPENAI); + assertThat(validator.validate(connection)).isEmpty(); + } + @ParameterizedTest - @NullAndEmptySource - void shouldRejectBlankEndpoint(String endpoint) { + @EnumSource( + value = OpenAiBackend.class, + names = {"FOUNDRY", "CUSTOM"}) + void validationShouldFail_WhenEndpointMissingForBackendThatRequiresIt(OpenAiBackend backend) { var connection = - new OpenAiCompatibleConnection( - endpoint, - new OpenAiCompatibleAuthentication("key"), + new OpenAiConnection( + backend, + new OpenAiApiKeyAuthentication("my-api-key", null, null), + TIMEOUT, + new OpenAiModel("gpt-4o", null), null, null, + null, + null); + assertThat(validator.validate(connection)) + .hasSize(1) + .extracting(ConstraintViolation::getMessage) + .containsExactly("Endpoint is required for FOUNDRY and CUSTOM backends"); + } + + @ParameterizedTest + @EnumSource( + value = OpenAiBackend.class, + names = {"FOUNDRY", "CUSTOM"}) + void validationShouldFail_WhenEndpointBlankForBackendThatRequiresIt(OpenAiBackend backend) { + var connection = + new OpenAiConnection( + backend, + new OpenAiApiKeyAuthentication("my-api-key", null, null), TIMEOUT, - new OpenAiCompatibleModel("model", null)); + new OpenAiModel("gpt-4o", null), + null, + " ", + null, + null); + // Both @HttpUrl and @AssertFalse validations trigger for blank endpoint assertThat(validator.validate(connection)) + .hasSize(2) .extracting(ConstraintViolation::getMessage) - .contains("must not be blank"); + .containsExactlyInAnyOrder( + "Must be an HTTP or HTTPS URL", + "Endpoint is required for FOUNDRY and CUSTOM backends"); + } + + @ParameterizedTest + @EnumSource( + value = OpenAiBackend.class, + names = {"FOUNDRY", "CUSTOM"}) + void validationShouldSucceed_WhenEndpointProvidedForBackendThatRequiresIt( + OpenAiBackend backend) { + var connection = + new OpenAiConnection( + backend, + new OpenAiApiKeyAuthentication("my-api-key", null, null), + TIMEOUT, + new OpenAiModel("gpt-4o", null), + null, + "https://my-endpoint.local/v1", + null, + null); + assertThat(validator.validate(connection)).isEmpty(); } } @Nested - class GoogleVertexAiConnectionTest { + class GoogleGenAiConnectionTest { @Test void validationShouldSucceed_WhenNotSaaS() { @@ -286,7 +477,7 @@ void validationShouldFail_WhenSaaS() { assertThat(validator.validate(connection)) .hasSize(1) .extracting(ConstraintViolation::getMessage) - .containsExactly("Google Vertex AI is not supported on SaaS"); + .containsExactly("Google GenAI is not supported on SaaS"); } @Test @@ -302,22 +493,24 @@ void validationShouldSucceed_WhenNotSaaSAndServiceAccountCredentialsUsed() { assertThat(validator.validate(connection)).isEmpty(); } - private static GoogleVertexAiConnection createConnectionWithApplicationDefaultCredentials() { - return new GoogleVertexAiConnection( + private static GoogleGenAiConnection createConnectionWithApplicationDefaultCredentials() { + return new GoogleGenAiConnection( "my-project-id", "us-central1", new ApplicationDefaultCredentialsAuthentication(), - new GoogleVertexAiModel( - "gemini-1.5-flash", new GoogleVertexAiModelParameters(null, null, null, null))); + new GoogleGenAiModel( + "gemini-1.5-flash", new GoogleGenAiModelParameters(null, null, null, null)), + null); } - private static GoogleVertexAiConnection createConnectionWithServiceAccountCredentials() { - return new GoogleVertexAiConnection( + private static GoogleGenAiConnection createConnectionWithServiceAccountCredentials() { + return new GoogleGenAiConnection( "my-project-id", "us-central1", new ServiceAccountCredentialsAuthentication("{}"), - new GoogleVertexAiModel( - "gemini-1.5-flash", new GoogleVertexAiModelParameters(null, null, null, null))); + new GoogleGenAiModel( + "gemini-1.5-flash", new GoogleGenAiModelParameters(null, null, null, null)), + null); } } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfigurationTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfigurationTest.java index 2931f54e766..7a27a25f28f 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfigurationTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/autoconfigure/AgenticAiConnectorsAutoConfigurationTest.java @@ -28,21 +28,19 @@ import io.camunda.connector.agenticai.aiagent.agent.AgentToolsResolver; import io.camunda.connector.agenticai.aiagent.agent.JobWorkerAgentRequestHandler; import io.camunda.connector.agenticai.aiagent.agent.OutboundConnectorAgentRequestHandler; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatClient; +import io.camunda.connector.agenticai.aiagent.framework.api.ChatModelApiRegistry; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatMessageConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelFactory; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ChatModelHttpProxySupport; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.ContentConverter; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.Langchain4JAiFrameworkAdapter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.document.DocumentToContentConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.jsonschema.JsonSchemaConverter; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.AnthropicChatModelProvider; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.AzureOpenAiChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.BedrockChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.ChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.ChatModelProviderRegistry; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.GoogleVertexAiChatModelProvider; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.OpenAiChatModelProvider; -import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.OpenAiCompatibleChatModelProvider; +import io.camunda.connector.agenticai.aiagent.framework.langchain4j.provider.OpenAiDispatchingChatModelProvider; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolCallConverter; import io.camunda.connector.agenticai.aiagent.framework.langchain4j.tool.ToolSpecificationConverter; import io.camunda.connector.agenticai.aiagent.memory.conversation.ConversationStoreRegistry; @@ -50,20 +48,14 @@ import io.camunda.connector.agenticai.aiagent.memory.conversation.awsagentcore.mapping.AwsAgentCoreConversationMapper; import io.camunda.connector.agenticai.aiagent.memory.conversation.document.CamundaDocumentConversationStore; import io.camunda.connector.agenticai.aiagent.memory.conversation.inprocess.InProcessConversationStore; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AnthropicProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.AzureOpenAiProviderConfiguration; import io.camunda.connector.agenticai.aiagent.model.request.provider.BedrockProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleVertexAiProviderConfiguration; -import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiCompatibleProviderConfiguration; +import io.camunda.connector.agenticai.aiagent.model.request.provider.GoogleGenAiProviderConfiguration; import io.camunda.connector.agenticai.aiagent.model.request.provider.OpenAiProviderConfiguration; import io.camunda.connector.agenticai.aiagent.model.request.provider.ProviderConfiguration; import io.camunda.connector.agenticai.aiagent.tool.GatewayToolHandlerRegistry; -import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomAnthropicProviderConfig.CustomAnthropicChatModelProvider; -import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomAzureOpenAiProviderConfig.CustomAzureOpenAiChatModelProvider; import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomBedrockProviderConfig.CustomBedrockChatModelProvider; -import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomGoogleVertexAiProviderConfig.CustomGoogleVertexAiChatModelProvider; -import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomOpenAiCompatibleProviderConfig.CustomOpenAiCompatibleChatModelProvider; -import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomOpenAiProviderConfig.CustomOpenAiChatModelProvider; +import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomFoundryProviderConfig.CustomFoundryChatModelProvider; +import io.camunda.connector.agenticai.autoconfigure.AgenticAiConnectorsAutoConfigurationTest.CustomChatModelProviderOverrides.CustomGoogleGenAiProviderConfig.CustomGoogleGenAiChatModelProvider; import io.camunda.connector.agenticai.common.AgenticAiHttpProxySupport; import io.camunda.connector.http.client.proxy.EnvironmentProxyConfiguration; import java.util.List; @@ -101,20 +93,23 @@ class AgenticAiConnectorsAutoConfigurationTest { AgentLimitsValidator.class, AgentMessagesHandler.class, AgentResponseHandler.class, + ChatModelApiRegistry.class, + ChatClient.class, OutboundConnectorAgentRequestHandler.class, AiAgentFunction.class, JobWorkerAgentRequestHandler.class, AiAgentJobWorker.class); + // L4J factory + provider beans for `anthropic` were dropped when those providers landed native in + // ADR-005 Phase B/C — the `openai` discriminator is still served by the LangChain4j bridge via + // `OpenAiDispatchingChatModelProvider` (which internally routes OPENAI/FOUNDRY/CUSTOM backends), + // unless overridden by the native SDK factory. private static final List> LANGCHAIN4J_BEANS = List.of( ChatModelHttpProxySupport.class, - AnthropicChatModelProvider.class, - AzureOpenAiChatModelProvider.class, + OpenAiDispatchingChatModelProvider.class, BedrockChatModelProvider.class, GoogleVertexAiChatModelProvider.class, - OpenAiChatModelProvider.class, - OpenAiCompatibleChatModelProvider.class, ChatModelProviderRegistry.class, ChatModelFactory.class, DocumentToContentConverter.class, @@ -122,8 +117,7 @@ class AgenticAiConnectorsAutoConfigurationTest { ToolCallConverter.class, JsonSchemaConverter.class, ToolSpecificationConverter.class, - ChatMessageConverter.class, - Langchain4JAiFrameworkAdapter.class); + ChatMessageConverter.class); // this will need to be updated in case we support different frameworks private static final List> ALL_BEANS = @@ -307,37 +301,26 @@ void userProvidedProviderBeanOverridesDefault(ProviderOverrideCase override) { } static Stream providerOverrideCases() { + // Only providers still backed by the L4J bridge declare a `ChatModelProvider` bean and + // therefore support overriding it. `anthropic`, `openai` (OPENAI/CUSTOM backends) have native + // factories now and no L4J `ChatModelProvider` to replace. + // The FOUNDRY backend of `openai` still goes through the L4J bridge (AzureOpenAiChatModel). return Stream.of( new ProviderOverrideCase( - CustomAnthropicProviderConfig.class, - "customAnthropicChatModelProvider", - AnthropicProviderConfiguration.class, - CustomAnthropicChatModelProvider.class), - new ProviderOverrideCase( - CustomAzureOpenAiProviderConfig.class, - "customAzureOpenAiChatModelProvider", - AzureOpenAiProviderConfiguration.class, - CustomAzureOpenAiChatModelProvider.class), + CustomFoundryProviderConfig.class, + "customFoundryChatModelProvider", + OpenAiProviderConfiguration.class, + CustomFoundryChatModelProvider.class), new ProviderOverrideCase( CustomBedrockProviderConfig.class, "customBedrockChatModelProvider", BedrockProviderConfiguration.class, CustomBedrockChatModelProvider.class), new ProviderOverrideCase( - CustomGoogleVertexAiProviderConfig.class, - "customGoogleVertexAiChatModelProvider", - GoogleVertexAiProviderConfiguration.class, - CustomGoogleVertexAiChatModelProvider.class), - new ProviderOverrideCase( - CustomOpenAiProviderConfig.class, - "customOpenAiChatModelProvider", - OpenAiProviderConfiguration.class, - CustomOpenAiChatModelProvider.class), - new ProviderOverrideCase( - CustomOpenAiCompatibleProviderConfig.class, - "customOpenAiCompatibleChatModelProvider", - OpenAiCompatibleProviderConfiguration.class, - CustomOpenAiCompatibleChatModelProvider.class)); + CustomGoogleGenAiProviderConfig.class, + "customGoogleGenAiChatModelProvider", + GoogleGenAiProviderConfiguration.class, + CustomGoogleGenAiChatModelProvider.class)); } record ProviderOverrideCase( @@ -352,43 +335,22 @@ public String toString() { } } - static class CustomAnthropicProviderConfig { + static class CustomFoundryProviderConfig { @Bean - ChatModelProvider customAnthropicChatModelProvider() { - return new CustomAnthropicChatModelProvider(); + ChatModelProvider customFoundryChatModelProvider() { + return new CustomFoundryChatModelProvider(); } - static class CustomAnthropicChatModelProvider - implements ChatModelProvider { - - @Override - public String type() { - return AnthropicProviderConfiguration.ANTHROPIC_ID; - } - - @Override - public ChatModel createChatModel(AnthropicProviderConfiguration providerConfiguration) { - return mock(ChatModel.class); - } - } - } - - static class CustomAzureOpenAiProviderConfig { - @Bean - ChatModelProvider customAzureOpenAiChatModelProvider() { - return new CustomAzureOpenAiChatModelProvider(); - } - - static class CustomAzureOpenAiChatModelProvider - implements ChatModelProvider { + static class CustomFoundryChatModelProvider + implements ChatModelProvider { @Override public String type() { - return AzureOpenAiProviderConfiguration.AZURE_OPENAI_ID; + return OpenAiProviderConfiguration.OPENAI_ID; } @Override - public ChatModel createChatModel(AzureOpenAiProviderConfiguration providerConfiguration) { + public ChatModel createChatModel(OpenAiProviderConfiguration providerConfiguration) { return mock(ChatModel.class); } } @@ -415,68 +377,22 @@ public ChatModel createChatModel(BedrockProviderConfiguration providerConfigurat } } - static class CustomGoogleVertexAiProviderConfig { - @Bean - ChatModelProvider - customGoogleVertexAiChatModelProvider() { - return new CustomGoogleVertexAiChatModelProvider(); - } - - static class CustomGoogleVertexAiChatModelProvider - implements ChatModelProvider { - - @Override - public String type() { - return GoogleVertexAiProviderConfiguration.GOOGLE_VERTEX_AI_ID; - } - - @Override - public ChatModel createChatModel( - GoogleVertexAiProviderConfiguration providerConfiguration) { - return mock(ChatModel.class); - } - } - } - - static class CustomOpenAiProviderConfig { - @Bean - ChatModelProvider customOpenAiChatModelProvider() { - return new CustomOpenAiChatModelProvider(); - } - - static class CustomOpenAiChatModelProvider - implements ChatModelProvider { - - @Override - public String type() { - return OpenAiProviderConfiguration.OPENAI_ID; - } - - @Override - public ChatModel createChatModel(OpenAiProviderConfiguration providerConfiguration) { - return mock(ChatModel.class); - } - } - } - - static class CustomOpenAiCompatibleProviderConfig { + static class CustomGoogleGenAiProviderConfig { @Bean - ChatModelProvider - customOpenAiCompatibleChatModelProvider() { - return new CustomOpenAiCompatibleChatModelProvider(); + ChatModelProvider customGoogleGenAiChatModelProvider() { + return new CustomGoogleGenAiChatModelProvider(); } - static class CustomOpenAiCompatibleChatModelProvider - implements ChatModelProvider { + static class CustomGoogleGenAiChatModelProvider + implements ChatModelProvider { @Override public String type() { - return OpenAiCompatibleProviderConfiguration.OPENAI_COMPATIBLE_ID; + return GoogleGenAiProviderConfiguration.GOOGLE_GENAI_ID; } @Override - public ChatModel createChatModel( - OpenAiCompatibleProviderConfiguration providerConfiguration) { + public ChatModel createChatModel(GoogleGenAiProviderConfiguration providerConfiguration) { return mock(ChatModel.class); } } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandlerTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandlerTest.java index 9e88ee5b3b2..d746f2e4bdf 100644 --- a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandlerTest.java +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/mcp/discovery/McpClientGatewayToolHandlerTest.java @@ -10,17 +10,28 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.ObjectMapper; import io.camunda.connector.agenticai.aiagent.model.AgentContext; import io.camunda.connector.agenticai.mcp.client.model.McpToolDefinition; import io.camunda.connector.agenticai.mcp.client.model.McpToolDefinitionBuilder; +import io.camunda.connector.agenticai.mcp.client.model.content.McpBlobContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpDocumentContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpEmbeddedResourceContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpEmbeddedResourceContent.BlobDocumentResource; +import io.camunda.connector.agenticai.mcp.client.model.content.McpEmbeddedResourceContent.BlobResource; +import io.camunda.connector.agenticai.mcp.client.model.content.McpEmbeddedResourceContent.TextResource; +import io.camunda.connector.agenticai.mcp.client.model.content.McpObjectContent; +import io.camunda.connector.agenticai.mcp.client.model.content.McpResourceLinkContent; import io.camunda.connector.agenticai.mcp.client.model.content.McpTextContent; import io.camunda.connector.agenticai.mcp.client.model.result.McpClientCallToolResult; import io.camunda.connector.agenticai.mcp.client.model.result.McpClientListToolsResult; import io.camunda.connector.agenticai.model.tool.GatewayToolDefinition; import io.camunda.connector.agenticai.model.tool.ToolCall; import io.camunda.connector.agenticai.model.tool.ToolCallResult; +import io.camunda.connector.api.document.Document; import io.camunda.connector.api.error.ConnectorException; import java.util.List; import java.util.Map; @@ -391,7 +402,7 @@ void transformsMcpClientResults_toMcpToolCallResults() { } @Test - void retainsListOfContentBlocksIfResultIsNotASingleTextBlock() { + void retainsTypedContentListIfResultIsNotASingleTextBlock() { var agentContext = AgentContext.empty().withProperty(PROPERTY_MCP_CLIENTS, List.of("mcp1")); var mcpCallToolResult = new McpClientCallToolResult( @@ -400,17 +411,19 @@ void retainsListOfContentBlocksIfResultIsNotASingleTextBlock() { McpTextContent.textContent("First content"), McpTextContent.textContent("Second content")), false); - var toolCallResults = - List.of(createToolCallResultWithContent("call1", "mcp1", mcpCallToolResult)); + // simulate engine deserialization: content arrives as a raw Map, not a typed POJO + @SuppressWarnings("unchecked") + var contentAsMap = objectMapper.convertValue(mcpCallToolResult, Map.class); + var toolCallResults = List.of(createToolCallResultWithContent("call1", "mcp1", contentAsMap)); var result = handler.transformToolCallResults(agentContext, toolCallResults); assertThat(result).hasSize(1); assertThat(result.getFirst().content()) - .isEqualTo( - List.of( - McpTextContent.textContent("First content"), - McpTextContent.textContent("Second content"))); + .asInstanceOf(InstanceOfAssertFactories.list(McpContent.class)) + .containsExactly( + McpTextContent.textContent("First content"), + McpTextContent.textContent("Second content")); } @Test @@ -567,4 +580,122 @@ void handlesEmptyNewGatewayToolDefinitions() { assertThat(result.removed()).containsExactly("mcp1", "mcp2"); } } + + @Nested + class ExtractDocuments { + + @Test + void extractsDocumentFromMcpDocumentContent() { + var document = mock(Document.class); + var toolCallResult = + createToolCallResultWithContent( + "call1", "MCP_mcp1___tool1", List.of(new McpDocumentContent(document, Map.of()))); + + var documents = handler.extractDocuments(toolCallResult); + + assertThat(documents).containsExactly(document); + } + + @Test + void extractsDocumentFromBlobDocumentResourceInsideEmbeddedResource() { + var document = mock(Document.class); + var toolCallResult = + createToolCallResultWithContent( + "call1", + "MCP_mcp1___tool1", + List.of( + new McpEmbeddedResourceContent( + new BlobDocumentResource("uri://doc", "application/pdf", document), + Map.of()))); + + var documents = handler.extractDocuments(toolCallResult); + + assertThat(documents).containsExactly(document); + } + + @Test + void doesNotExtractFromTextResource() { + var toolCallResult = + createToolCallResultWithContent( + "call1", + "MCP_mcp1___tool1", + List.of( + new McpEmbeddedResourceContent( + new TextResource("uri://text", "text/plain", "hello"), Map.of()))); + + assertThat(handler.extractDocuments(toolCallResult)).isEmpty(); + } + + @Test + void doesNotExtractFromBlobResource() { + var toolCallResult = + createToolCallResultWithContent( + "call1", + "MCP_mcp1___tool1", + List.of( + new McpEmbeddedResourceContent( + new BlobResource("uri://blob", "application/octet-stream", new byte[] {1, 2}), + Map.of()))); + + assertThat(handler.extractDocuments(toolCallResult)).isEmpty(); + } + + @Test + void doesNotExtractFromTextOrObjectOrBlobOrResourceLinkVariants() { + var toolCallResult = + createToolCallResultWithContent( + "call1", + "MCP_mcp1___tool1", + List.of( + McpTextContent.textContent("just text"), + new McpObjectContent(Map.of("k", "v"), Map.of()), + new McpBlobContent(new byte[] {1}, "image/png", Map.of()), + new McpResourceLinkContent("uri://x", "link", "desc", "text/plain", Map.of()))); + + assertThat(handler.extractDocuments(toolCallResult)).isEmpty(); + } + + @Test + void preservesOrderAndCollectsMultipleDocuments() { + var doc1 = mock(Document.class); + var doc2 = mock(Document.class); + var toolCallResult = + createToolCallResultWithContent( + "call1", + "MCP_mcp1___tool1", + List.of( + new McpDocumentContent(doc1, Map.of()), + McpTextContent.textContent("between"), + new McpEmbeddedResourceContent( + new BlobDocumentResource("uri://2", "application/pdf", doc2), Map.of()))); + + assertThat(handler.extractDocuments(toolCallResult)).containsExactly(doc1, doc2); + } + + @Test + void returnsEmptyListWhenContentIsAStringOptimization() { + // single-text-content optimization yields a String content; nothing to extract + var toolCallResult = + createToolCallResultWithContent("call1", "MCP_mcp1___tool1", "plain text"); + + assertThat(handler.extractDocuments(toolCallResult)).isEmpty(); + } + + @Test + void returnsEmptyListWhenContentIsNotAList() { + var toolCallResult = + createToolCallResultWithContent("call1", "MCP_mcp1___tool1", Map.of("foo", "bar")); + + assertThat(handler.extractDocuments(toolCallResult)).isEmpty(); + } + + @Test + void returnsEmptyListWhenListEntriesAreNotMcpContent() { + var toolCallResult = + createToolCallResultWithContent( + "call1", "MCP_mcp1___tool1", List.of(Map.of("type", "text", "text", "raw map"))); + + assertThat(handler.extractDocuments(toolCallResult)).isEmpty(); + } + } } diff --git a/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/model/message/DocumentXmlTagTest.java b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/model/message/DocumentXmlTagTest.java new file mode 100644 index 00000000000..126e49d3c24 --- /dev/null +++ b/connectors/agentic-ai/src/test/java/io/camunda/connector/agenticai/model/message/DocumentXmlTagTest.java @@ -0,0 +1,93 @@ +/* + * 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.agenticai.model.message; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.camunda.connector.api.document.Document; +import io.camunda.connector.api.document.DocumentMetadata; +import io.camunda.connector.api.document.DocumentReference.CamundaDocumentReference; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class DocumentXmlTagTest { + + @Nested + class ToXml { + + @Test + void generatesFullTagWithAllAttributes() { + var doc = mock(Document.class); + var ref = mock(CamundaDocumentReference.class); + var metadata = mock(DocumentMetadata.class); + when(doc.reference()).thenReturn(ref); + when(ref.getDocumentId()).thenReturn("25ece9fa-aeea-423d-98ed-67c1f08b137b"); + when(doc.metadata()).thenReturn(metadata); + when(metadata.getFileName()).thenReturn("report.pdf"); + + assertThat(DocumentXmlTag.from(doc, "call_abc", "search").toXml()) + .isEqualTo( + ""); + } + + @Test + void generatesTagWithoutToolAndCallId() { + var doc = mock(Document.class); + var ref = mock(CamundaDocumentReference.class); + var metadata = mock(DocumentMetadata.class); + when(doc.reference()).thenReturn(ref); + when(ref.getDocumentId()).thenReturn("f7b3a1d0-1234-5678-9abc-def012345678"); + when(doc.metadata()).thenReturn(metadata); + when(metadata.getFileName()).thenReturn(null); + + assertThat(DocumentXmlTag.from(doc).toXml()) + .isEqualTo(""); + } + + @Test + void generatesMinimalTagForMockedDocument() { + var doc = mock(Document.class); + assertThat(DocumentXmlTag.from(doc).toXml()).isEqualTo(""); + } + + @Test + void handlesDocumentIdWithoutDash() { + var doc = mock(Document.class); + var ref = mock(CamundaDocumentReference.class); + when(doc.reference()).thenReturn(ref); + when(ref.getDocumentId()).thenReturn("simpledocid"); + + assertThat(DocumentXmlTag.from(doc).toXml()) + .isEqualTo(""); + } + + @Test + void escapesSpecialCharactersInFilename() { + var doc = mock(Document.class); + var metadata = mock(DocumentMetadata.class); + when(doc.metadata()).thenReturn(metadata); + when(metadata.getFileName()).thenReturn("file\"with&chars'.pdf"); + + assertThat(DocumentXmlTag.from(doc).toXml()) + .isEqualTo(""); + } + + @Test + void escapesSpecialCharactersInToolName() { + var doc = mock(Document.class); + var ref = mock(CamundaDocumentReference.class); + when(doc.reference()).thenReturn(ref); + when(ref.getDocumentId()).thenReturn("abc12345-0000-0000-0000-000000000000"); + + assertThat(DocumentXmlTag.from(doc, "call_1", "tool").toXml()) + .isEqualTo( + ""); + } + } +}