responseType;
+
+ /**
+ * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response.
+ *
+ * @param response The response to process
+ * @return A model class instantiated from the response
+ * @throws OrchestrationClientException in case of a problem or the connection was aborted
+ */
+ @Override
+ public T handleResponse(@Nonnull final ClassicHttpResponse response)
+ throws OrchestrationClientException {
+ if (response.getCode() >= 300) {
+ buildExceptionAndThrow(response);
+ }
+ return parseResponse(response);
+ }
+
+ // The InputStream of the HTTP entity is closed by EntityUtils.toString
+ @SuppressWarnings("PMD.CloseResource")
+ @Nonnull
+ private T parseResponse(@Nonnull final ClassicHttpResponse response)
+ throws OrchestrationClientException {
+ final HttpEntity responseEntity = response.getEntity();
+ if (responseEntity == null) {
+ throw new OrchestrationClientException("Response from Orchestration service was empty.");
+ }
+ final var content = getContent(responseEntity);
+ try {
+ return JACKSON.readValue(content, responseType);
+ } catch (final JsonProcessingException e) {
+ log.error("Failed to parse the following response from orchestration service: {}", content);
+ throw new OrchestrationClientException(
+ "Failed to parse response from orchestration service", e);
+ }
+ }
+
+ @Nonnull
+ private static String getContent(@Nonnull final HttpEntity entity) {
+ try {
+ return EntityUtils.toString(entity, StandardCharsets.UTF_8);
+ } catch (IOException | ParseException e) {
+ throw new OrchestrationClientException("Failed to read response content.", e);
+ }
+ }
+
+ // The InputStream of the HTTP entity is closed by EntityUtils.toString
+ @SuppressWarnings("PMD.CloseResource")
+ static void buildExceptionAndThrow(@Nonnull final ClassicHttpResponse response)
+ throws OrchestrationClientException {
+ final var exception =
+ new OrchestrationClientException(
+ "Request to orchestration service failed with status %s %s"
+ .formatted(response.getCode(), response.getReasonPhrase()));
+ final var entity = response.getEntity();
+ if (entity == null) {
+ throw exception;
+ }
+ final var maybeContent = Try.of(() -> getContent(entity));
+ if (maybeContent.isFailure()) {
+ exception.addSuppressed(maybeContent.getCause());
+ throw exception;
+ }
+ final var content = maybeContent.get();
+ if (content.isBlank()) {
+ throw exception;
+ }
+
+ log.error(
+ "The orchestration service responded with an HTTP error and the following content: {}",
+ content);
+ final var contentType = ContentType.parse(entity.getContentType());
+ if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) {
+ throw exception;
+ }
+
+ parseErrorAndThrow(content, exception);
+ }
+
+ /**
+ * Parse the error response and throw an exception.
+ *
+ * @param errorResponse the error response, most likely a JSON of {@link ErrorResponse}.
+ * @param baseException a base exception to add the error message to.
+ */
+ static void parseErrorAndThrow(
+ @Nonnull final String errorResponse,
+ @Nonnull final OrchestrationClientException baseException)
+ throws OrchestrationClientException {
+ final var maybeError = Try.of(() -> JACKSON.readValue(errorResponse, ErrorResponse.class));
+ if (maybeError.isFailure()) {
+ baseException.addSuppressed(maybeError.getCause());
+ throw baseException;
+ }
+
+ throw new OrchestrationClientException(
+ "%s and error message: '%s'"
+ .formatted(baseException.getMessage(), maybeError.get().getMessage()));
+ }
+}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/client/OrchestrationCompletionApi.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/client/OrchestrationCompletionApi.java
deleted file mode 100644
index ab4786d7..00000000
--- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/client/OrchestrationCompletionApi.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.sap.ai.sdk.orchestration.client;
-
-import com.google.common.annotations.Beta;
-import com.sap.ai.sdk.orchestration.client.model.CompletionPostRequest;
-import com.sap.ai.sdk.orchestration.client.model.CompletionPostResponse;
-import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
-import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
-import com.sap.cloud.sdk.services.openapi.core.AbstractOpenApiService;
-import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException;
-import java.util.List;
-import javax.annotation.Nonnull;
-import org.springframework.core.ParameterizedTypeReference;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.MediaType;
-import org.springframework.util.LinkedMultiValueMap;
-import org.springframework.util.MultiValueMap;
-import org.springframework.web.util.UriComponentsBuilder;
-
-/**
- * Internal Orchestration Service API in version 0.0.1.
- *
- * SAP AI Core - Orchestration Service API
- */
-public class OrchestrationCompletionApi extends AbstractOpenApiService {
- /**
- * Instantiates this API class to invoke operations on the Internal Orchestration Service API.
- *
- * @param httpDestination The destination that API should be used with
- */
- public OrchestrationCompletionApi(@Nonnull final Destination httpDestination) {
- super(httpDestination);
- }
-
- /**
- * Instantiates this API class to invoke operations on the Internal Orchestration Service API
- * based on a given {@link ApiClient}.
- *
- * @param apiClient ApiClient to invoke the API on
- */
- @Beta
- public OrchestrationCompletionApi(@Nonnull final ApiClient apiClient) {
- super(apiClient);
- }
-
- /**
- * 200 - Successful response
- *
- *
400 - Bad Request
- *
- *
0 - Common Error
- *
- * @param completionPostRequest The value for the parameter completionPostRequest
- * @return CompletionPostResponse
- * @throws OpenApiRequestException if an error occurs while attempting to invoke the API
- */
- @Nonnull
- public CompletionPostResponse orchestrationV1EndpointsCreate(
- @Nonnull final CompletionPostRequest completionPostRequest) throws OpenApiRequestException {
- final Object localVarPostBody = completionPostRequest;
-
- // verify the required parameter 'completionPostRequest' is set
- if (completionPostRequest == null) {
- throw new OpenApiRequestException(
- "Missing the required parameter 'completionPostRequest' when calling orchestrationV1EndpointsCreate");
- }
-
- final String localVarPath = UriComponentsBuilder.fromPath("/completion").build().toUriString();
-
- final MultiValueMap localVarQueryParams =
- new LinkedMultiValueMap();
- final HttpHeaders localVarHeaderParams = new HttpHeaders();
- final MultiValueMap localVarFormParams =
- new LinkedMultiValueMap();
-
- final String[] localVarAccepts = {"application/json"};
- final List localVarAccept = apiClient.selectHeaderAccept(localVarAccepts);
- final String[] localVarContentTypes = {"application/json"};
- final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes);
-
- final String[] localVarAuthNames = new String[] {};
-
- final ParameterizedTypeReference localVarReturnType =
- new ParameterizedTypeReference() {};
- return apiClient.invokeAPI(
- localVarPath,
- HttpMethod.POST,
- localVarQueryParams,
- localVarPostBody,
- localVarHeaderParams,
- localVarFormParams,
- localVarAccept,
- localVarContentType,
- localVarAuthNames,
- localVarReturnType);
- }
-}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/client/OrchestrationHealthzApi.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/client/OrchestrationHealthzApi.java
deleted file mode 100644
index 6b6054c5..00000000
--- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/client/OrchestrationHealthzApi.java
+++ /dev/null
@@ -1,85 +0,0 @@
-package com.sap.ai.sdk.orchestration.client;
-
-import com.google.common.annotations.Beta;
-import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
-import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
-import com.sap.cloud.sdk.services.openapi.core.AbstractOpenApiService;
-import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException;
-import java.util.List;
-import javax.annotation.Nonnull;
-import org.springframework.core.ParameterizedTypeReference;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.MediaType;
-import org.springframework.util.LinkedMultiValueMap;
-import org.springframework.util.MultiValueMap;
-import org.springframework.web.util.UriComponentsBuilder;
-
-/**
- * Internal Orchestration Service API in version 0.0.1.
- *
- * SAP AI Core - Orchestration Service API
- */
-public class OrchestrationHealthzApi extends AbstractOpenApiService {
- /**
- * Instantiates this API class to invoke operations on the Internal Orchestration Service API.
- *
- * @param httpDestination The destination that API should be used with
- */
- public OrchestrationHealthzApi(@Nonnull final Destination httpDestination) {
- super(httpDestination);
- }
-
- /**
- * Instantiates this API class to invoke operations on the Internal Orchestration Service API
- * based on a given {@link ApiClient}.
- *
- * @param apiClient ApiClient to invoke the API on
- */
- @Beta
- public OrchestrationHealthzApi(@Nonnull final ApiClient apiClient) {
- super(apiClient);
- }
-
- /**
- * 200 - Service is up and running.
- *
- *
503 - Service is unavailable.
- *
- * @return String
- * @throws OpenApiRequestException if an error occurs while attempting to invoke the API
- */
- @Nonnull
- public String orchestrationV1EndpointsHealthz() throws OpenApiRequestException {
- final Object localVarPostBody = null;
-
- final String localVarPath = UriComponentsBuilder.fromPath("/healthz").build().toUriString();
-
- final MultiValueMap localVarQueryParams =
- new LinkedMultiValueMap();
- final HttpHeaders localVarHeaderParams = new HttpHeaders();
- final MultiValueMap localVarFormParams =
- new LinkedMultiValueMap();
-
- final String[] localVarAccepts = {"text/plain", "application/json"};
- final List localVarAccept = apiClient.selectHeaderAccept(localVarAccepts);
- final String[] localVarContentTypes = {};
- final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes);
-
- final String[] localVarAuthNames = new String[] {};
-
- final ParameterizedTypeReference localVarReturnType =
- new ParameterizedTypeReference() {};
- return apiClient.invokeAPI(
- localVarPath,
- HttpMethod.GET,
- localVarQueryParams,
- localVarPostBody,
- localVarHeaderParams,
- localVarFormParams,
- localVarAccept,
- localVarContentType,
- localVarAuthNames,
- localVarReturnType);
- }
-}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationAutoConfiguration.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationAutoConfiguration.java
new file mode 100644
index 00000000..7f5e27df
--- /dev/null
+++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationAutoConfiguration.java
@@ -0,0 +1,38 @@
+package com.sap.ai.sdk.orchestration.spring;
+
+import com.sap.ai.sdk.orchestration.OrchestrationClient;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+
+@Slf4j
+@AutoConfiguration
+@EnableConfigurationProperties(OrchestrationSpringProperties.class)
+public class OrchestrationAutoConfiguration {
+ @Bean
+ @ConditionalOnMissingBean
+ OrchestrationClient orchestrationClient(OrchestrationSpringProperties properties) {
+ var llmConfig =
+ LLMModuleConfig.create().modelName(properties.llm().modelName()).modelParams(Map.of());
+ if (properties.llm().modelVersion() != null) {
+ llmConfig.modelVersion(properties.llm().modelVersion());
+ }
+ return new OrchestrationClient().withLlmConfig(llmConfig);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ OrchestrationChatModel orchestrationChatModel(OrchestrationClient client) {
+ return new OrchestrationChatModel(client);
+ }
+ //
+ // @Bean
+ // @ConditionalOnMissingBean
+ // ChatClient orchestrationChatClient(OrchestrationChatModel model) {
+ // return ChatClient.create(model);
+ // }
+}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java
new file mode 100644
index 00000000..32306e3a
--- /dev/null
+++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java
@@ -0,0 +1,63 @@
+package com.sap.ai.sdk.orchestration.spring;
+
+import com.sap.ai.sdk.orchestration.OrchestrationClient;
+import com.sap.ai.sdk.orchestration.OrchestrationPrompt;
+import javax.annotation.Nonnull;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.chat.prompt.Prompt;
+
+/** Spring AI integration for the orchestration service. */
+@Slf4j
+public class OrchestrationChatModel implements ChatModel {
+ @Nonnull private final OrchestrationClient client;
+
+ public OrchestrationChatModel(@Nonnull final OrchestrationClient client) {
+ this.client = client;
+ }
+
+ @Override
+ @Nonnull
+ public ChatOptions getDefaultOptions() {
+ return new OrchestrationChatOptions();
+ }
+
+ @Override
+ public OrchestrationChatResponse call(@Nonnull final Prompt prompt) {
+ var orchestrationPrompt = toOrchestrationPrompt(prompt);
+ var response = client.chatCompletion(orchestrationPrompt);
+ return OrchestrationChatResponse.fromOrchestrationResponse(response);
+ }
+
+ @Nonnull
+ private static OrchestrationPrompt toOrchestrationPrompt(@Nonnull final Prompt prompt) {
+ var messages = OrchestrationChatOptions.toChatMessages(prompt.getInstructions());
+
+ var opts = getChatOptions(prompt);
+ var orchestrationPrompt = new OrchestrationPrompt(messages, opts.getTemplateParameters());
+
+ opts.getLlmConfig().forEach(orchestrationPrompt::withLlmConfig);
+ opts.getTemplate().forEach(orchestrationPrompt::withTemplate);
+ opts.getMaskingConfig().forEach(orchestrationPrompt::withMaskingConfig);
+ opts.getInputContentFilter().forEach(orchestrationPrompt::withInputContentFilter);
+ opts.getOutputContentFilter().forEach(orchestrationPrompt::withOutputContentFilter);
+
+ return orchestrationPrompt;
+ }
+
+ @Nonnull
+ private static OrchestrationChatOptions getChatOptions(@Nonnull final Prompt prompt) {
+ if (prompt.getOptions() == null) {
+ return new OrchestrationChatOptions();
+ }
+ if (prompt.getOptions() instanceof OrchestrationChatOptions opts) {
+ return opts;
+ }
+ // TODO: Should we build the LLM config out of the provided options instead?
+ log.warn(
+ "Prompt options are not of type {}. Ignoring provided options.",
+ OrchestrationChatOptions.class.getSimpleName());
+ return new OrchestrationChatOptions();
+ }
+}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptions.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptions.java
new file mode 100644
index 00000000..23c2bf51
--- /dev/null
+++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptions.java
@@ -0,0 +1,131 @@
+package com.sap.ai.sdk.orchestration.spring;
+
+import com.sap.ai.sdk.orchestration.DefaultOrchestrationConfig;
+import com.sap.ai.sdk.orchestration.OrchestrationConfig;
+import com.sap.ai.sdk.orchestration.client.model.ChatMessage;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import com.sap.ai.sdk.orchestration.client.model.TemplatingModuleConfig;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Delegate;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.prompt.ChatOptions;
+
+/** Configuration to be used for orchestration requests. */
+@Data
+@Getter(AccessLevel.NONE)
+@Setter(AccessLevel.NONE)
+public class OrchestrationChatOptions
+ implements ChatOptions, OrchestrationConfig {
+ private interface IDelegate extends OrchestrationConfig {}
+
+ @Getter(AccessLevel.NONE)
+ @Nonnull
+ @Delegate(types = IDelegate.class)
+ private final DefaultOrchestrationConfig delegate =
+ DefaultOrchestrationConfig.asDelegateFor(this);
+
+ @Getter(AccessLevel.PUBLIC)
+ @Nonnull
+ private Map templateParameters = Map.of();
+
+ @Nonnull
+ public OrchestrationChatOptions withTemplateParameters(
+ @Nonnull final Map templateParameters) {
+ this.templateParameters = templateParameters;
+ return this;
+ }
+
+ @Nonnull
+ public OrchestrationChatOptions withTemplate(@Nonnull final List template) {
+ delegate.withTemplate(TemplatingModuleConfig.create().template(toChatMessages(template)));
+ return this;
+ }
+
+ @Nonnull
+ static List toChatMessages(@Nonnull final List messages) {
+ return messages.stream()
+ .map(m -> ChatMessage.create().role(m.getMessageType().getValue()).content(m.getContent()))
+ .toList();
+ }
+
+ // region satisfy the ChatOptions interface, delegating to the LLM config
+ @Nullable
+ @Override
+ public String getModel() {
+ return delegate.getLlmConfig().map(LLMModuleConfig::getModelName).getOrNull();
+ }
+
+ @Nullable
+ String getModelVersion() {
+ return delegate.getLlmConfig().map(LLMModuleConfig::getModelVersion).getOrNull();
+ }
+
+ @Nullable
+ @Override
+ public Double getFrequencyPenalty() {
+ return getLlmConfigParam("frequencyPenalty");
+ }
+
+ @Nullable
+ @Override
+ public Integer getMaxTokens() {
+ return getLlmConfigParam("maxTokens");
+ }
+
+ @Nullable
+ @Override
+ public Double getPresencePenalty() {
+ return getLlmConfigParam("presencePenalty");
+ }
+
+ @Nullable
+ @Override
+ public List getStopSequences() {
+ return getLlmConfigParam("stopSequences");
+ }
+
+ @Nullable
+ @Override
+ public Double getTemperature() {
+ return getLlmConfigParam("temperature");
+ }
+
+ @Nullable
+ @Override
+ public Integer getTopK() {
+ return getLlmConfigParam("topK");
+ }
+
+ @Nullable
+ @Override
+ public Double getTopP() {
+ return getLlmConfigParam("topP");
+ }
+
+ @Override
+ public OrchestrationChatOptions copy() {
+ var copy = new OrchestrationChatOptions();
+ copy.delegate.copyFrom(this.delegate);
+ copy.templateParameters.putAll(this.templateParameters);
+ return copy;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ private T getLlmConfigParam(@Nonnull final String param) {
+ return delegate
+ .getLlmConfig()
+ .map(LLMModuleConfig::getModelParams)
+ .map(it -> (Map) it)
+ .map(m -> (T) m.get(param))
+ .getOrNull();
+ }
+ // endregion
+}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatResponse.java
new file mode 100644
index 00000000..1a58bd4b
--- /dev/null
+++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatResponse.java
@@ -0,0 +1,82 @@
+package com.sap.ai.sdk.orchestration.spring;
+
+import com.sap.ai.sdk.orchestration.client.model.CompletionPostResponse;
+import com.sap.ai.sdk.orchestration.client.model.LLMChoice;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleResult;
+import com.sap.ai.sdk.orchestration.client.model.ModuleResults;
+import com.sap.ai.sdk.orchestration.client.model.TokenUsage;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.metadata.DefaultUsage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+
+@Value
+@EqualsAndHashCode(callSuper = true)
+public class OrchestrationChatResponse extends ChatResponse {
+ @Nonnull ModuleResults moduleResults;
+
+ private OrchestrationChatResponse(
+ @Nonnull final List generations,
+ @Nonnull final ChatResponseMetadata metadata,
+ @Nonnull final ModuleResults moduleResults) {
+ super(generations, metadata);
+ this.moduleResults = moduleResults;
+ }
+
+ @Nonnull
+ static OrchestrationChatResponse fromOrchestrationResponse(
+ @Nonnull final CompletionPostResponse response) {
+ final var generations = toGenerations(response.getOrchestrationResult());
+
+ final var metadata = toChatResponseMetadata(response.getOrchestrationResult());
+ return new OrchestrationChatResponse(generations, metadata, response.getModuleResults());
+ }
+
+ @Nonnull
+ static List toGenerations(@Nonnull final LLMModuleResult result) {
+ return result.getChoices().stream()
+ .map(OrchestrationChatResponse::toAssistantMessage)
+ .map(Generation::new)
+ .toList();
+ }
+
+ @Nonnull
+ static AssistantMessage toAssistantMessage(@Nonnull final LLMChoice choice) {
+ Map metadata = new HashMap<>();
+ metadata.put("finish_reason", choice.getFinishReason());
+ metadata.put("index", choice.getIndex());
+ if (!choice.getLogprobs().isEmpty()) {
+ metadata.put("logprobs", choice.getLogprobs());
+ }
+ return new AssistantMessage(choice.getMessage().getContent(), metadata);
+ }
+
+ @Nonnull
+ static ChatResponseMetadata toChatResponseMetadata(
+ @Nonnull final LLMModuleResult orchestrationResult) {
+ var metadataBuilder = ChatResponseMetadata.builder();
+
+ metadataBuilder.withId(orchestrationResult.getId());
+ metadataBuilder.withModel(orchestrationResult.getModel());
+ metadataBuilder.withKeyValue("object", orchestrationResult.getObject());
+ metadataBuilder.withKeyValue("created", orchestrationResult.getCreated());
+ metadataBuilder.withUsage(toDefaultUsage(orchestrationResult.getUsage()));
+
+ return metadataBuilder.build();
+ }
+
+ @Nonnull
+ private static DefaultUsage toDefaultUsage(@Nonnull final TokenUsage usage) {
+ return new DefaultUsage(
+ usage.getPromptTokens().longValue(),
+ usage.getCompletionTokens().longValue(),
+ usage.getTotalTokens().longValue());
+ }
+}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationSpringProperties.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationSpringProperties.java
new file mode 100644
index 00000000..278a1b88
--- /dev/null
+++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationSpringProperties.java
@@ -0,0 +1,11 @@
+package com.sap.ai.sdk.orchestration.spring;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+// TODO can this be a static inner class?
+@ConfigurationProperties(prefix = OrchestrationSpringProperties.CONFIG_PREFIX)
+public record OrchestrationSpringProperties(Llm llm) {
+ public static final String CONFIG_PREFIX = "com.sap.ai.sdk.orchestration";
+
+ public record Llm(String modelName, String modelVersion) {}
+}
diff --git a/orchestration/src/main/resources/META-INF.spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/orchestration/src/main/resources/META-INF.spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 00000000..920fda0c
--- /dev/null
+++ b/orchestration/src/main/resources/META-INF.spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+com.sap.ai.genai.orchestration.OrchestrationAutoConfiguration
\ No newline at end of file
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/DefaultOrchestrationConfigTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/DefaultOrchestrationConfigTest.java
new file mode 100644
index 00000000..88e1eae9
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/DefaultOrchestrationConfigTest.java
@@ -0,0 +1,60 @@
+package com.sap.ai.sdk.orchestration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import java.util.Map;
+
+import io.vavr.control.Option;
+import org.junit.jupiter.api.Test;
+
+class DefaultOrchestrationConfigTest {
+ private static final OrchestrationConfig> DEFAULT_CONFIG =
+ DefaultOrchestrationConfig.standalone()
+ .withLlmConfig(mock(LLMModuleConfig.class))
+ .withMaskingConfig(mock(MaskingConfig.class));
+
+ @Test
+ void testStandalone() {
+ var config = DefaultOrchestrationConfig.standalone();
+
+ assertThat(config.withMaskingConfig(null)).isSameAs(config);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testDelegation() {
+ var mock = mock(OrchestrationConfig.class);
+
+ var config = DefaultOrchestrationConfig.asDelegateFor(mock);
+ assertThat(config.withMaskingConfig(null)).isSameAs(mock);
+ }
+
+ @Test
+ void testCopy() {
+ var config = DefaultOrchestrationConfig.standalone();
+ var duplicate = config.copyFrom(DEFAULT_CONFIG);
+
+ assertThat(duplicate)
+ .isEqualTo(DEFAULT_CONFIG)
+ .hasSameHashCodeAs(DEFAULT_CONFIG)
+ .isSameAs(config)
+ .isNotSameAs(DEFAULT_CONFIG);
+ }
+
+ @Test
+ void testApplyingDefaults() {
+ var config = DefaultOrchestrationConfig.standalone();
+ var llm = LLMModuleConfig.create().modelName("foo").modelParams(Map.of());
+ config.withLlmConfig(llm);
+
+ config.copyFrom(DEFAULT_CONFIG);
+
+ assertThat(config)
+ .isNotEqualTo(DEFAULT_CONFIG)
+ .extracting(OrchestrationConfig::getLlmConfig)
+ .extracting(Option::get)
+ .isEqualTo(llm);
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/ModuleConfigFactoryTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/ModuleConfigFactoryTest.java
new file mode 100644
index 00000000..49634d20
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/ModuleConfigFactoryTest.java
@@ -0,0 +1,132 @@
+package com.sap.ai.sdk.orchestration;
+
+import static com.sap.ai.sdk.orchestration.AzureContentFilter.Sensitivity.HIGH;
+import static com.sap.ai.sdk.orchestration.ModuleConfigFactory.toModuleConfigDTO;
+import static com.sap.ai.sdk.orchestration.client.model.FilterConfig.TypeEnum.AZURE_CONTENT_SAFETY;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+
+import com.sap.ai.sdk.orchestration.client.model.ChatMessage;
+import com.sap.ai.sdk.orchestration.client.model.DPIEntities;
+import com.sap.ai.sdk.orchestration.client.model.DPIEntityConfig;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import com.sap.ai.sdk.orchestration.client.model.MaskingProviderConfig;
+import com.sap.ai.sdk.orchestration.client.model.TemplatingModuleConfig;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ModuleConfigFactoryTest {
+ private static final List messages = List.of(mock(ChatMessage.class));
+ private DefaultOrchestrationConfig> config;
+
+ @BeforeEach
+ void setUp() {
+ config = DefaultOrchestrationConfig.standalone();
+ config.withLlmConfig(mock(LLMModuleConfig.class));
+ }
+
+ @Test
+ void testThrowsOnMissingConfig() {
+ config = DefaultOrchestrationConfig.standalone();
+
+ assertThatThrownBy(() -> toModuleConfigDTO(config, messages))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("LLM module config is required");
+
+ config.withLlmConfig(mock(LLMModuleConfig.class));
+ assertThatThrownBy(() -> toModuleConfigDTO(config, List.of()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("prompt is required");
+ }
+
+ @Test
+ void testLlmConfig() {
+ var llmConfig = mock(LLMModuleConfig.class);
+
+ config.withLlmConfig(llmConfig);
+
+ var result = toModuleConfigDTO(config, messages).getLlmModuleConfig();
+ assertThat(result).isSameAs(llmConfig);
+ }
+
+ @Test
+ void testTemplateIsCreatedFromMessages() {
+ var result = toModuleConfigDTO(config, messages).getTemplatingModuleConfig();
+
+ assertThat(result.getTemplate()).containsExactly(messages.get(0));
+ assertThat(result.getDefaults()).isNull();
+ }
+
+ @Test
+ void testMessagesAreMergedIntoTemplate() {
+ var message1 = mock(ChatMessage.class);
+ var message2 = mock(ChatMessage.class);
+ config.withTemplate(TemplatingModuleConfig.create().template(List.of(message1)));
+
+ var result = toModuleConfigDTO(config, List.of(message2)).getTemplatingModuleConfig();
+
+ assertThat(result.getTemplate()).containsExactly(message2, message1);
+ }
+
+ @Test
+ void testInputFilter() {
+ var filter = new AzureContentFilter().hate(HIGH);
+ config.withInputContentFilter(filter);
+
+ var result = toModuleConfigDTO(config, messages).getFilteringModuleConfig();
+
+ assertThat(result.getInput().getFilters()).isNotEmpty();
+
+ var filterDto = result.getInput().getFilters().get(0);
+ assertThat(filterDto.getType()).isEqualTo(AZURE_CONTENT_SAFETY);
+ assertThat(filterDto.getConfig().getHate().getValue()).isZero();
+ assertThat(filterDto.getConfig().getViolence()).isNull();
+
+ assertThat(result.getOutput()).isNull();
+ }
+
+ @Test
+ void testOutputFilter() {
+ var filter = new AzureContentFilter().hate(HIGH);
+ config.withOutputContentFilter(filter);
+
+ var result = toModuleConfigDTO(config, messages).getFilteringModuleConfig();
+
+ assertThat(result.getOutput().getFilters()).isNotEmpty();
+ var filterDto = result.getOutput().getFilters().get(0);
+ assertThat(filterDto.getType()).isEqualTo(AZURE_CONTENT_SAFETY);
+ assertThat(filterDto.getConfig().getHate().getValue()).isZero();
+ assertThat(filterDto.getConfig().getViolence()).isNull();
+ assertThat(result.getInput()).isNull();
+ }
+
+ @Test
+ void testInputAndOutputFilter() {
+ var inputFilter = new AzureContentFilter();
+ var outputFilter = new AzureContentFilter();
+ config.withInputContentFilter(inputFilter);
+ config.withOutputContentFilter(outputFilter);
+
+ var result = toModuleConfigDTO(config, messages).getFilteringModuleConfig();
+
+ assertThat(result.getInput().getFilters()).isNotEmpty();
+ assertThat(result.getOutput().getFilters()).isNotEmpty();
+ }
+
+ @Test
+ void testMasking() {
+ var maskingConfig = DpiMaskingConfig.anonymization().withEntities(DPIEntities.ADDRESS);
+ config.withMaskingConfig(maskingConfig);
+
+ var result = toModuleConfigDTO(config, messages).getMaskingModuleConfig();
+
+ assertThat(result.getMaskingProviders())
+ .isNotEmpty()
+ .extracting(MaskingProviderConfig::getEntities)
+ .extracting(it -> it.get(0))
+ .extracting(DPIEntityConfig::getType)
+ .containsOnly(DPIEntities.ADDRESS);
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationClientTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationClientTest.java
new file mode 100644
index 00000000..c96221ea
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationClientTest.java
@@ -0,0 +1,78 @@
+package com.sap.ai.sdk.orchestration;
+
+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.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.sap.ai.sdk.orchestration.client.model.ChatMessage;
+import com.sap.ai.sdk.orchestration.client.model.CompletionPostResponse;
+import com.sap.ai.sdk.orchestration.client.model.LLMChoice;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleResult;
+import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+
+/**
+ * Test that queries are on the right URL, with the right headers. Also check that the received
+ * response is parsed correctly in the generated client.
+ */
+public class OrchestrationClientTest {
+ private OrchestrationClient client;
+
+ private static final LLMModuleConfig LLM_CONFIG =
+ LLMModuleConfig.create().modelName("gpt-35-turbo-16k").modelParams(Map.of());
+
+ @BeforeEach
+ void setup() {
+ var destination = DefaultHttpDestination.builder("").build();
+ client = spy(new OrchestrationClient(destination).withLlmConfig(LLM_CONFIG));
+ }
+
+ @Test
+ void testSimpleChatCompletion() {
+ stubResponse("stop");
+
+ var result = client.chatCompletion("Hello there!");
+ assertThat(result).isEqualTo("General Kenobi!");
+
+ var expected = ChatMessage.create().role("user").content("Hello there!");
+ verify(client)
+ .chatCompletion(
+ ArgumentMatchers.argThat(
+ prompt -> prompt.getMessages().contains(expected)));
+ }
+
+ @Test
+ void testSimpleChatCompletionThrowsOnOutputContentFilter() {
+
+ stubResponse("content_filter");
+
+ assertThatThrownBy(() -> client.chatCompletion("foo"))
+ .isInstanceOf(OrchestrationClientException.class)
+ .hasMessageContaining("content filter");
+ }
+
+ private void stubResponse(String finishReason) {
+ var response = mock(CompletionPostResponse.class);
+ var orchestrationResult = mock(LLMModuleResult.class);
+ var llmChoice =
+ LLMChoice.create()
+ .index(0)
+ .message(ChatMessage.create().role("assistant").content("General Kenobi!"))
+ .finishReason(finishReason);
+
+ when(orchestrationResult.getChoices()).thenReturn(List.of(llmChoice));
+ when(response.getOrchestrationResult()).thenReturn(orchestrationResult);
+
+ doReturn(response).when(client).executeRequest(any());
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationResponseHandlerTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationResponseHandlerTest.java
new file mode 100644
index 00000000..f77b26de
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationResponseHandlerTest.java
@@ -0,0 +1,82 @@
+package com.sap.ai.sdk.orchestration;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl;
+import static com.github.tomakehurst.wiremock.client.WireMock.badRequest;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.serverError;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Cache;
+import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test that queries are on the right URL, with the right headers. Also check that the received
+ * response is parsed correctly in the generated client.
+ */
+@SuppressWarnings("UnstableApiUsage")
+@WireMockTest
+public class OrchestrationResponseHandlerTest {
+ private OrchestrationClient client;
+
+ private static final LLMModuleConfig LLM_CONFIG =
+ LLMModuleConfig.create().modelName("gpt-35-turbo-16k").modelParams(Map.of());
+
+ @BeforeEach
+ void setup(WireMockRuntimeInfo server) {
+ var destination = DefaultHttpDestination.builder(server.getHttpBaseUrl()).build();
+ client = new OrchestrationClient(destination).withLlmConfig(LLM_CONFIG);
+ ApacheHttpClient5Accessor.setHttpClientCache(ApacheHttpClient5Cache.DISABLED);
+ }
+
+ @AfterEach
+ void reset() {
+ ApacheHttpClient5Accessor.setHttpClientCache(null);
+ ApacheHttpClient5Accessor.setHttpClientFactory(null);
+ }
+
+ @Test
+ void testSuccessResponse() {
+ var response =
+ ok().withBodyFile("serializedResponse.json").withHeader("Content-Type", "application/json");
+ stubFor(post(anyUrl()).willReturn(response));
+
+ var result = client.chatCompletion("Hello there!");
+
+ assertThat(result).isEqualTo("General Kenobi!");
+ }
+
+ @Test
+ void testGenericErrorHandling() {
+ stubFor(post(anyUrl()).willReturn(serverError()));
+
+ assertThatThrownBy(() -> client.chatCompletion("Hello World!"))
+ .isInstanceOf(OrchestrationClientException.class)
+ .hasMessageContaining("500 Server Error");
+ }
+
+ @Test
+ void testOrchestrationErrorParsing() {
+ stubFor(
+ post(anyUrl())
+ .willReturn(
+ badRequest()
+ .withHeader("Content-Type", "application/json")
+ .withBodyFile("errorResponse.json")));
+
+ assertThatThrownBy(() -> client.chatCompletion("Hello World!"))
+ .isInstanceOf(OrchestrationClientException.class)
+ .hasMessageContaining("400 Bad Request")
+ .hasMessageContaining("'orchestration_config' is a required property");
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/SerializationTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/SerializationTest.java
new file mode 100644
index 00000000..6474fd29
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/SerializationTest.java
@@ -0,0 +1,139 @@
+package com.sap.ai.sdk.orchestration;
+
+import static com.sap.ai.sdk.orchestration.AzureContentFilter.Sensitivity.LENIENT;
+import static com.sap.ai.sdk.orchestration.AzureContentFilter.Sensitivity.LOW;
+import static com.sap.ai.sdk.orchestration.AzureContentFilter.Sensitivity.MEDIUM;
+import static com.sap.ai.sdk.orchestration.AzureContentFilter.Sensitivity.HIGH;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.sap.ai.sdk.orchestration.client.model.ChatMessage;
+import com.sap.ai.sdk.orchestration.client.model.CompletionPostResponse;
+import com.sap.ai.sdk.orchestration.client.model.DPIEntities;
+import com.sap.ai.sdk.orchestration.client.model.GenericModuleResult;
+import com.sap.ai.sdk.orchestration.client.model.LLMChoice;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleResult;
+import com.sap.ai.sdk.orchestration.client.model.ModuleResults;
+import com.sap.ai.sdk.orchestration.client.model.TemplatingModuleConfig;
+import com.sap.ai.sdk.orchestration.client.model.TokenUsage;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class SerializationTest {
+
+ @Test
+ void testSerialization() throws IOException {
+ var llm =
+ LLMModuleConfig.create()
+ .modelName("gpt-35-turbo-16k")
+ .modelParams(Map.of("temperature", 0.5, "frequency_penalty", 1));
+ var template =
+ TemplatingModuleConfig.create()
+ .template(List.of(ChatMessage.create().role("user").content("{{?input}}")))
+ .defaults(Map.of("input", "Hello World!"));
+
+ var inputFilter = new AzureContentFilter().selfHarm(LOW);
+ var outputFilter =
+ new AzureContentFilter()
+ .hate(HIGH)
+ .selfHarm(MEDIUM)
+ .sexual(LOW);
+
+ var masking =
+ DpiMaskingConfig.anonymization()
+ .withEntities(DPIEntities.ADDRESS, DPIEntities.IBAN, DPIEntities.LOCATION);
+ var inputParams = Map.of("input", "Reply with 'Orchestration Service is working!' in German");
+
+ var dto =
+ new OrchestrationPrompt(inputParams)
+ .withLlmConfig(llm)
+ .withTemplate(template)
+ .withInputContentFilter(inputFilter)
+ .withOutputContentFilter(outputFilter)
+ .withMaskingConfig(masking)
+ .toCompletionPostRequestDTO(DefaultOrchestrationConfig.standalone());
+
+ var actual = OrchestrationClient.JACKSON.valueToTree(dto);
+
+ var expected =
+ OrchestrationClient.JACKSON.readValue(
+ getClass().getClassLoader().getResource("serializedRequest.json"), JsonNode.class);
+
+ assertThat(actual)
+ .withRepresentation(it -> ((JsonNode) it).toPrettyString())
+ .isEqualTo(expected);
+ }
+
+ @Test
+ void testDeserialization() throws IOException {
+
+ var llmResult =
+ LLMModuleResult.create()
+ .id("chatcmpl-9lzPV4kLrXjFckOp2yY454wksWBoj")
+ ._object("chat.completion")
+ .created(1721224505)
+ .model("gpt-35-turbo-16k")
+ .choices(
+ List.of(
+ LLMChoice.create()
+ .index(0)
+ .message(ChatMessage.create().role("assistant").content("General Kenobi!"))
+ .finishReason("stop")))
+ .usage(TokenUsage.create().completionTokens(7).promptTokens(19).totalTokens(26));
+
+ var orchestrationResult =
+ LLMModuleResult.create()
+ .id("chatcmpl-9lzPV4kLrXjFckOp2yY454wksWBoj")
+ ._object("chat.completion")
+ .created(1721224505)
+ .model("gpt-35-turbo-16k")
+ .choices(
+ List.of(
+ LLMChoice.create()
+ .index(0)
+ .message(ChatMessage.create().role("assistant").content("General Kenobi!"))
+ .finishReason("stop")))
+ .usage(TokenUsage.create().completionTokens(7).promptTokens(19).totalTokens(26));
+
+ var inputFilterResult =
+ GenericModuleResult.create()
+ .message("Input filter passed successfully.")
+ .data(
+ Map.of(
+ "original_service_response",
+ Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 2),
+ "checked_text",
+ "Hello there!"));
+ var outputFilterResult =
+ GenericModuleResult.create()
+ .message("Output filter passed successfully.")
+ .data(
+ Map.of(
+ "original_service_response",
+ Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 2),
+ "checked_text",
+ "General Kenobi!"));
+ var expected =
+ CompletionPostResponse.create()
+ .requestId("26ea36b5-c196-4806-a9a6-a686f0c6ad91")
+ .moduleResults(
+ ModuleResults.create()
+ .templating(List.of(ChatMessage.create().role("user").content("Hello there!")))
+ .llm(llmResult)
+ .inputFiltering(inputFilterResult)
+ .outputFiltering(outputFilterResult)
+ .inputMasking(null)
+ .outputUnmasking(List.of()))
+ .orchestrationResult(orchestrationResult);
+
+ var actual =
+ OrchestrationClient.JACKSON.readValue(
+ getClass().getClassLoader().getResourceAsStream("serializedResponse.json"),
+ CompletionPostResponse.class);
+
+ assertThat(actual).isEqualTo(expected);
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/client/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/client/OrchestrationUnitTest.java
deleted file mode 100644
index e9a2f79b..00000000
--- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/client/OrchestrationUnitTest.java
+++ /dev/null
@@ -1,291 +0,0 @@
-package com.sap.ai.sdk.orchestration.client;
-
-import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
-import static com.github.tomakehurst.wiremock.client.WireMock.jsonResponse;
-import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
-import static com.github.tomakehurst.wiremock.client.WireMock.post;
-import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
-import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
-import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
-import static com.github.tomakehurst.wiremock.client.WireMock.verify;
-import static com.sap.ai.sdk.core.Core.getClient;
-import static com.sap.ai.sdk.orchestration.client.model.AzureThreshold.NUMBER_0;
-import static com.sap.ai.sdk.orchestration.client.model.AzureThreshold.NUMBER_4;
-import static org.apache.hc.core5.http.HttpStatus.SC_BAD_REQUEST;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
-import com.github.tomakehurst.wiremock.junit5.WireMockTest;
-import com.sap.ai.sdk.orchestration.client.model.AzureContentSafety;
-import com.sap.ai.sdk.orchestration.client.model.AzureThreshold;
-import com.sap.ai.sdk.orchestration.client.model.ChatMessage;
-import com.sap.ai.sdk.orchestration.client.model.CompletionPostRequest;
-import com.sap.ai.sdk.orchestration.client.model.FilterConfig;
-import com.sap.ai.sdk.orchestration.client.model.FilteringConfig;
-import com.sap.ai.sdk.orchestration.client.model.FilteringModuleConfig;
-import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
-import com.sap.ai.sdk.orchestration.client.model.ModuleConfigs;
-import com.sap.ai.sdk.orchestration.client.model.OrchestrationConfig;
-import com.sap.ai.sdk.orchestration.client.model.TemplatingModuleConfig;
-import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.web.client.HttpClientErrorException;
-
-/**
- * Test that queries are on the right URL, with the right headers. Also check that the received
- * response is parsed correctly in the generated client.
- */
-@WireMockTest
-public class OrchestrationUnitTest {
- private OrchestrationCompletionApi client;
-
- private static final LLMModuleConfig LLM_CONFIG =
- LLMModuleConfig.create()
- .modelName("gpt-35-turbo-16k")
- .modelParams(
- Map.of(
- "max_tokens", 50,
- "temperature", 0.1,
- "frequency_penalty", 0,
- "presence_penalty", 0));
-
- private static final Function TEMPLATE_CONFIG =
- (TemplatingModuleConfig templatingModuleConfig) ->
- CompletionPostRequest.create()
- .orchestrationConfig(
- OrchestrationConfig.create()
- .moduleConfigurations(
- ModuleConfigs.create()
- .llmModuleConfig(LLM_CONFIG)
- .templatingModuleConfig(templatingModuleConfig)))
- .inputParams(Map.of());
-
- /**
- * Creates a config from a filter threshold. The config includes a template and has input and
- * output filters
- */
- private static final Function FILTERING_CONFIG =
- (AzureThreshold filterThreshold) -> {
- final var inputParams =
- Map.of(
- "disclaimer",
- "```DISCLAIMER: The area surrounding the apartment is known for prostitutes and gang violence including armed conflicts, gun violence is frequent.");
- final var template =
- ChatMessage.create()
- .role("user")
- .content(
- "Create a rental posting for subletting my apartment in the downtown area. Keep it short. Make sure to add the following disclaimer to the end. Do not change it! {{?disclaimer}}");
- final var templatingConfig = TemplatingModuleConfig.create().template(template);
-
- final var filter =
- FilterConfig.create()
- .type(FilterConfig.TypeEnum.AZURE_CONTENT_SAFETY)
- .config(
- AzureContentSafety.create()
- .hate(filterThreshold)
- .selfHarm(filterThreshold)
- .sexual(filterThreshold)
- .violence(filterThreshold));
- final var filteringConfig =
- FilteringModuleConfig.create()
- .input(FilteringConfig.create().filters(filter))
- .output(FilteringConfig.create().filters(filter));
-
- return CompletionPostRequest.create()
- .orchestrationConfig(
- OrchestrationConfig.create()
- .moduleConfigurations(
- ModuleConfigs.create()
- .llmModuleConfig(LLM_CONFIG)
- .templatingModuleConfig(templatingConfig)
- .filteringModuleConfig(filteringConfig)))
- .inputParams(inputParams);
- };
-
- @BeforeEach
- void setup(WireMockRuntimeInfo server) {
- final DefaultHttpDestination destination =
- DefaultHttpDestination.builder(server.getHttpBaseUrl()).build();
- client = new OrchestrationCompletionApi(getClient(destination));
- }
-
- @Test
- void testTemplating() throws IOException {
- final String response =
- new String(
- getClass()
- .getClassLoader()
- .getResourceAsStream("templatingResponse.json")
- .readAllBytes());
- stubFor(post(urlPathEqualTo("/completion")).willReturn(okJson(response)));
-
- final var template = ChatMessage.create().role("user").content("{{?input}}");
- final var inputParams =
- Map.of("input", "Reply with 'Orchestration Service is working!' in German");
-
- final var config =
- TEMPLATE_CONFIG
- .apply(TemplatingModuleConfig.create().template(template))
- .inputParams(inputParams);
-
- final var result = client.orchestrationV1EndpointsCreate(config);
-
- assertThat(result.getRequestId()).isEqualTo("26ea36b5-c196-4806-a9a6-a686f0c6ad91");
- assertThat(result.getModuleResults().getTemplating().get(0).getContent())
- .isEqualTo("Reply with 'Orchestration Service is working!' in German");
- assertThat(result.getModuleResults().getTemplating().get(0).getRole()).isEqualTo("user");
- var llm = result.getModuleResults().getLlm();
- assertThat(llm.getId()).isEqualTo("chatcmpl-9lzPV4kLrXjFckOp2yY454wksWBoj");
- assertThat(llm.getObject()).isEqualTo("chat.completion");
- assertThat(llm.getCreated()).isEqualTo(1721224505);
- assertThat(llm.getModel()).isEqualTo("gpt-35-turbo-16k");
- var choices = llm.getChoices();
- assertThat(choices.get(0).getIndex()).isEqualTo(0);
- assertThat(choices.get(0).getMessage().getContent())
- .isEqualTo("Orchestration Service funktioniert!");
- assertThat(choices.get(0).getMessage().getRole()).isEqualTo("assistant");
- assertThat(choices.get(0).getFinishReason()).isEqualTo("stop");
- var usage = llm.getUsage();
- assertThat(usage.getCompletionTokens()).isEqualTo(7);
- assertThat(usage.getPromptTokens()).isEqualTo(19);
- assertThat(usage.getTotalTokens()).isEqualTo(26);
- assertThat(result.getOrchestrationResult().getId())
- .isEqualTo("chatcmpl-9lzPV4kLrXjFckOp2yY454wksWBoj");
- assertThat(result.getOrchestrationResult().getObject()).isEqualTo("chat.completion");
- assertThat(result.getOrchestrationResult().getCreated()).isEqualTo(1721224505);
- assertThat(result.getOrchestrationResult().getModel()).isEqualTo("gpt-35-turbo-16k");
- choices = result.getOrchestrationResult().getChoices();
- assertThat(choices.get(0).getIndex()).isEqualTo(0);
- assertThat(choices.get(0).getMessage().getContent())
- .isEqualTo("Orchestration Service funktioniert!");
- assertThat(choices.get(0).getMessage().getRole()).isEqualTo("assistant");
- assertThat(choices.get(0).getFinishReason()).isEqualTo("stop");
- usage = result.getOrchestrationResult().getUsage();
- assertThat(usage.getCompletionTokens()).isEqualTo(7);
- assertThat(usage.getPromptTokens()).isEqualTo(19);
- assertThat(usage.getTotalTokens()).isEqualTo(26);
-
- // verify that null fields are absent from the sent request
- final String request =
- new String(
- getClass()
- .getClassLoader()
- .getResourceAsStream("templatingRequest.json")
- .readAllBytes());
- verify(postRequestedFor(urlPathEqualTo("/completion")).withRequestBody(equalToJson(request)));
- }
-
- @Test
- void testTemplatingBadRequest() {
- stubFor(
- post(urlPathEqualTo("/completion"))
- .willReturn(
- jsonResponse(
- """
- {
- "request_id": "51043a32-01f5-429a-b0e7-3a99432e43a4",
- "code": 400,
- "message": "Missing required parameters: ['input']",
- "location": "Module: Templating",
- "module_results": {}
- }
- """,
- SC_BAD_REQUEST)));
-
- final var template = ChatMessage.create().role("user").content("{{?input}}");
- // input params are omitted on purpose to trigger an error
- Map inputParams = Map.of();
-
- final var config =
- TEMPLATE_CONFIG
- .apply(TemplatingModuleConfig.create().template(template))
- .inputParams(inputParams);
-
- assertThatThrownBy(() -> client.orchestrationV1EndpointsCreate(config))
- .isInstanceOf(HttpClientErrorException.class)
- .hasMessage(
- "400 Bad Request: \"{ \"request_id\": \"51043a32-01f5-429a-b0e7-3a99432e43a4\", \"code\": 400, \"message\": \"Missing required parameters: ['input']\", \"location\": \"Module: Templating\", \"module_results\": {}}\"");
- }
-
- @Test
- void testFilteringLoose() throws IOException {
- final String response =
- new String(
- getClass()
- .getClassLoader()
- .getResourceAsStream("filteringLooseResponse.json")
- .readAllBytes());
- stubFor(post(urlPathEqualTo("/completion")).willReturn(okJson(response)));
-
- final var config = FILTERING_CONFIG.apply(NUMBER_4);
-
- client.orchestrationV1EndpointsCreate(config);
- // the result is asserted in the verify step below
-
- // verify that null fields are absent from the sent request
- final String request =
- new String(
- getClass()
- .getClassLoader()
- .getResourceAsStream("filteringLooseRequest.json")
- .readAllBytes());
- verify(postRequestedFor(urlPathEqualTo("/completion")).withRequestBody(equalToJson(request)));
- }
-
- @Test
- void testFilteringStrict() {
- final String response =
- """
- {"request_id": "bf6d6792-7adf-4d3c-9368-a73615af8c5a", "code": 400, "message": "Content filtered due to Safety violations. Please modify the prompt and try again.", "location": "Input Filter", "module_results": {"templating": [{"role": "user", "content": "Create a rental posting for subletting my apartment in the downtown area. Keep it short. Make sure to add the following disclaimer to the end. Do not change it! ```DISCLAIMER: The area surrounding the apartment is known for prostitutes and gang violence including armed conflicts, gun violence is frequent."}], "input_filtering": {"message": "Content filtered due to Safety violations. Please modify the prompt and try again.", "data": {"original_service_response": {"Hate": 0, "SelfHarm": 0, "Sexual": 0, "Violence": 2}, "checked_text": "Create a rental posting for subletting my apartment in the downtown area. Keep it short. Make sure to add the following disclaimer to the end. Do not change it! ```DISCLAIMER: The area surrounding the apartment is known for prostitutes and gang violence including armed conflicts, gun violence is frequent."}}}}""";
- stubFor(post(urlPathEqualTo("/completion")).willReturn(jsonResponse(response, SC_BAD_REQUEST)));
-
- final var config = FILTERING_CONFIG.apply(NUMBER_0);
-
- assertThatThrownBy(() -> client.orchestrationV1EndpointsCreate(config))
- .isInstanceOf(HttpClientErrorException.class)
- .hasMessage("400 Bad Request: \"" + response + "\"");
- }
-
- @Test
- void testMessagesHistory() throws IOException {
- final String response =
- new String(
- getClass()
- .getClassLoader()
- // the response is not asserted in this test
- .getResourceAsStream("templatingResponse.json")
- .readAllBytes());
- stubFor(post(urlPathEqualTo("/completion")).willReturn(okJson(response)));
-
- final List messagesHistory =
- List.of(
- ChatMessage.create().role("user").content("What is the capital of France?"),
- ChatMessage.create().role("assistant").content("The capital of France is Paris."));
- final var message =
- ChatMessage.create().role("user").content("What is the typical food there?");
-
- final var config =
- TEMPLATE_CONFIG
- .apply(TemplatingModuleConfig.create().template(message))
- .messagesHistory(messagesHistory);
-
- final var result = client.orchestrationV1EndpointsCreate(config);
-
- assertThat(result.getRequestId()).isEqualTo("26ea36b5-c196-4806-a9a6-a686f0c6ad91");
-
- // verify that the history is sent correctly
- final String request =
- new String(
- getClass()
- .getClassLoader()
- .getResourceAsStream("messagesHistoryRequest.json")
- .readAllBytes());
- verify(postRequestedFor(urlPathEqualTo("/completion")).withRequestBody(equalToJson(request)));
- }
-}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptionsTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptionsTest.java
new file mode 100644
index 00000000..a06b8e7d
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptionsTest.java
@@ -0,0 +1,55 @@
+package com.sap.ai.sdk.orchestration.spring;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleConfig;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class OrchestrationChatOptionsTest {
+
+ OrchestrationChatOptions opts;
+
+ @BeforeEach
+ void setUp() {
+ opts = new OrchestrationChatOptions();
+ }
+
+ @Test
+ void testFluentApi() {
+ assertThat(opts).isSameAs(opts.withLlmConfig(null));
+ }
+
+ @Test
+ void testHyperParameters() {
+ var llm =
+ LLMModuleConfig.create()
+ .modelName("foo")
+ .modelParams(Map.of("temperature", 0.5, "maxTokens", 100));
+ opts.withLlmConfig(llm);
+
+ assertThat(opts.getTemperature()).isEqualTo(0.5);
+ assertThat(opts.getMaxTokens()).isEqualTo(100);
+ }
+
+ @Test
+ void testEqualsAndHashCode() {
+ var llm =
+ LLMModuleConfig.create()
+ .modelName("foo")
+ .modelParams(Map.of("temperature", 0.5, "maxTokens", 100));
+
+ var opts1 =
+ new OrchestrationChatOptions()
+ .withTemplateParameters(Map.of("foo", "bar"))
+ .withLlmConfig(llm);
+ var opts2 =
+ new OrchestrationChatOptions()
+ .withTemplateParameters(Map.of("foo", "bar"))
+ .withLlmConfig(llm);
+
+ assertThat(opts1).isEqualTo(opts2);
+ assertThat(opts1.hashCode()).isEqualTo(opts2.hashCode());
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatResponseTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatResponseTest.java
new file mode 100644
index 00000000..5a188ca5
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatResponseTest.java
@@ -0,0 +1,54 @@
+package com.sap.ai.sdk.orchestration.spring;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.sap.ai.sdk.orchestration.client.model.ChatMessage;
+import com.sap.ai.sdk.orchestration.client.model.LLMChoice;
+import com.sap.ai.sdk.orchestration.client.model.LLMModuleResult;
+import com.sap.ai.sdk.orchestration.client.model.TokenUsage;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.messages.AssistantMessage;
+
+class OrchestrationChatResponseTest {
+
+ @Test
+ void testToAssistantMessage() {
+ var choice =
+ LLMChoice.create()
+ .index(0)
+ .message(ChatMessage.create().role("assistant").content("Hello, world!"))
+ .finishReason("stop");
+
+ AssistantMessage message = OrchestrationChatResponse.toAssistantMessage(choice);
+
+ assertThat(message.getContent()).isEqualTo("Hello, world!");
+ assertThat(message.getMetadata()).containsEntry("finish_reason", "stop");
+ assertThat(message.getMetadata()).containsEntry("index", 0);
+ }
+
+ @Test
+ void testToChatResponseMetadata() {
+ var moduleResult =
+ LLMModuleResult.create()
+ .id("test-id")
+ ._object("test-object")
+ .created(123456789)
+ .model("test-model")
+ .choices(List.of())
+ .usage(TokenUsage.create().completionTokens(20).promptTokens(10).totalTokens(30));
+
+ var metadata = OrchestrationChatResponse.toChatResponseMetadata(moduleResult);
+
+ assertThat(metadata.getId()).isEqualTo("test-id");
+ assertThat(metadata.getModel()).isEqualTo("test-model");
+ assertThat(metadata.get("object")).isEqualTo("test-object");
+ assertThat(metadata.get("created")).isEqualTo(123456789);
+
+ var usage = metadata.getUsage();
+
+ assertThat(usage.getPromptTokens()).isEqualTo(10L);
+ assertThat(usage.getGenerationTokens()).isEqualTo(20L);
+ assertThat(usage.getTotalTokens()).isEqualTo(30L);
+ }
+}
diff --git a/orchestration/src/test/resources/errorResponse.json b/orchestration/src/test/resources/errorResponse.json
new file mode 100644
index 00000000..6964cbc1
--- /dev/null
+++ b/orchestration/src/test/resources/errorResponse.json
@@ -0,0 +1,7 @@
+{
+ "request_id": "59468e72-7309-4299-b988-bf3bbea461f8",
+ "code": 400,
+ "message": "'orchestration_config' is a required property",
+ "location": "request body",
+ "module_results": {}
+}
\ No newline at end of file
diff --git a/orchestration/src/test/resources/filteringLooseRequest.json b/orchestration/src/test/resources/filteringLooseRequest.json
deleted file mode 100644
index 40c1aa91..00000000
--- a/orchestration/src/test/resources/filteringLooseRequest.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "orchestration_config": {
- "module_configurations": {
- "llm_module_config": {
- "model_name": "gpt-35-turbo-16k",
- "model_params": {
- "temperature": 0.1,
- "max_tokens": 50,
- "frequency_penalty": 0,
- "presence_penalty": 0
- },
- "model_version": "latest"
- },
- "templating_module_config": {
- "template": [
- {
- "role": "user",
- "content": "Create a rental posting for subletting my apartment in the downtown area. Keep it short. Make sure to add the following disclaimer to the end. Do not change it! {{?disclaimer}}"
- }
- ]
- },
- "filtering_module_config": {
- "input": {
- "filters": [
- {
- "type": "azure_content_safety",
- "config": {
- "Hate": 4,
- "SelfHarm": 4,
- "Sexual": 4,
- "Violence": 4
- }
- }
- ]
- },
- "output": {
- "filters": [
- {
- "type": "azure_content_safety",
- "config": {
- "Hate": 4,
- "SelfHarm": 4,
- "Sexual": 4,
- "Violence": 4
- }
- }
- ]
- }
- }
- }
- },
- "input_params": {
- "disclaimer": "```DISCLAIMER: The area surrounding the apartment is known for prostitutes and gang violence including armed conflicts, gun violence is frequent."
- }
-}
diff --git a/orchestration/src/test/resources/filteringLooseResponse.json b/orchestration/src/test/resources/filteringLooseResponse.json
deleted file mode 100644
index ce2545f0..00000000
--- a/orchestration/src/test/resources/filteringLooseResponse.json
+++ /dev/null
@@ -1,86 +0,0 @@
-{
- "request_id": "b329745f-4b6b-4d42-b891-974b33689a19",
- "module_results": {
- "grounding": null,
- "templating": [
- {
- "role": "user",
- "content": "Create a rental posting for subletting my apartment in the downtown area. Keep it short. Make sure to add the following disclaimer to the end. Do not change it! ```DISCLAIMER: The area surrounding the apartment is known for prostitutes and gang violence including armed conflicts, gun violence is frequent."
- }
- ],
- "input_masking": null,
- "input_filtering": {
- "message": "Input filter passed successfully.",
- "data": {
- "original_service_response": {
- "Hate": 0,
- "SelfHarm": 0,
- "Sexual": 0,
- "Violence": 2
- },
- "checked_text": "Create a rental posting for subletting my apartment in the downtown area. Keep it short. Make sure to add the following disclaimer to the end. Do not change it! ```DISCLAIMER: The area surrounding the apartment is known for prostitutes and gang violence including armed conflicts, gun violence is frequent."
- }
- },
- "llm": {
- "object": "chat.completion",
- "id": "chatcmpl-9o4df7DpIjY6CJdfe9hws1lrWZbHq",
- "created": 1721721259,
- "model": "gpt-35-turbo",
- "system_fingerprint": null,
- "choices": [
- {
- "index": 0,
- "message": {
- "role": "assistant",
- "content": "Cozy Downtown Apartment for Sublet!\n\nLooking for a temporary place to call home in the heart of downtown? Look no further! This cozy apartment is up for subletting and offers a convenient and vibrant city living experience.\n\nFeatures:\n- Prime location"
- },
- "logprobs": {
- },
- "finish_reason": "length"
- }
- ],
- "usage": {
- "completion_tokens": 50,
- "prompt_tokens": 68,
- "total_tokens": 118
- }
- },
- "output_filtering": {
- "message": "Output filter passed successfully.",
- "data": {
- "original_service_response": {
- "Hate": 0,
- "SelfHarm": 0,
- "Sexual": 0,
- "Violence": 0
- },
- "checked_text": "Cozy Downtown Apartment for Sublet!\n\nLooking for a temporary place to call home in the heart of downtown? Look no further! This cozy apartment is up for subletting and offers a convenient and vibrant city living experience.\n\nFeatures:\n- Prime location"
- }
- },
- "output_unmasking": null
- },
- "orchestration_result": {
- "object": "chat.completion",
- "id": "chatcmpl-9o4df7DpIjY6CJdfe9hws1lrWZbHq",
- "created": 1721721259,
- "model": "gpt-35-turbo",
- "system_fingerprint": null,
- "choices": [
- {
- "index": 0,
- "message": {
- "role": "assistant",
- "content": "Cozy Downtown Apartment for Sublet!\n\nLooking for a temporary place to call home in the heart of downtown? Look no further! This cozy apartment is up for subletting and offers a convenient and vibrant city living experience.\n\nFeatures:\n- Prime location"
- },
- "logprobs": {
- },
- "finish_reason": "length"
- }
- ],
- "usage": {
- "completion_tokens": 50,
- "prompt_tokens": 68,
- "total_tokens": 118
- }
- }
-}
diff --git a/orchestration/src/test/resources/messagesHistoryRequest.json b/orchestration/src/test/resources/messagesHistoryRequest.json
deleted file mode 100644
index 984e8d08..00000000
--- a/orchestration/src/test/resources/messagesHistoryRequest.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "orchestration_config": {
- "module_configurations": {
- "llm_module_config": {
- "model_name": "gpt-35-turbo-16k",
- "model_params": {
- "presence_penalty": 0,
- "frequency_penalty": 0,
- "max_tokens": 50,
- "temperature": 0.1
- },
- "model_version": "latest"
- },
- "templating_module_config": {
- "template": [
- {
- "role": "user",
- "content": "What is the typical food there?"
- }
- ]
- }
- }
- },
- "input_params": {},
- "messages_history": [
- {
- "role": "user",
- "content": "What is the capital of France?"
- },
- {
- "role": "assistant",
- "content": "The capital of France is Paris."
- }
- ]
-}
diff --git a/orchestration/src/test/resources/serializedRequest.json b/orchestration/src/test/resources/serializedRequest.json
new file mode 100644
index 00000000..a4101d65
--- /dev/null
+++ b/orchestration/src/test/resources/serializedRequest.json
@@ -0,0 +1,60 @@
+{
+ "orchestration_config" : {
+ "module_configurations" : {
+ "llm_module_config" : {
+ "model_name" : "gpt-35-turbo-16k",
+ "model_params" : {
+ "frequency_penalty" : 1,
+ "temperature" : 0.5
+ },
+ "model_version" : "latest"
+ },
+ "templating_module_config" : {
+ "template" : [ {
+ "role" : "user",
+ "content" : "{{?input}}"
+ } ],
+ "defaults" : {
+ "input" : "Hello World!"
+ }
+ },
+ "filtering_module_config" : {
+ "input" : {
+ "filters" : [ {
+ "type" : "azure_content_safety",
+ "config" : {
+ "SelfHarm" : 6
+ }
+ } ]
+ },
+ "output" : {
+ "filters" : [ {
+ "type" : "azure_content_safety",
+ "config" : {
+ "Hate" : 0,
+ "SelfHarm" : 2,
+ "Sexual": 4,
+ "Violence": 6
+ }
+ } ]
+ }
+ },
+ "masking_module_config" : {
+ "masking_providers" : [ {
+ "type" : "sap_data_privacy_integration",
+ "method" : "anonymization",
+ "entities" : [ {
+ "type" : "profile-address"
+ }, {
+ "type" : "profile-iban"
+ }, {
+ "type" : "profile-location"
+ } ]
+ } ]
+ }
+ }
+ },
+ "input_params" : {
+ "input" : "Reply with 'Orchestration Service is working!' in German"
+ }
+}
\ No newline at end of file
diff --git a/orchestration/src/test/resources/templatingResponse.json b/orchestration/src/test/resources/serializedResponse.json
similarity index 59%
rename from orchestration/src/test/resources/templatingResponse.json
rename to orchestration/src/test/resources/serializedResponse.json
index 3fcc0a30..d471d2f5 100644
--- a/orchestration/src/test/resources/templatingResponse.json
+++ b/orchestration/src/test/resources/serializedResponse.json
@@ -4,7 +4,7 @@
"templating": [
{
"role": "user",
- "content": "Reply with 'Orchestration Service is working!' in German"
+ "content": "Hello there!"
}
],
"llm": {
@@ -17,7 +17,7 @@
"index": 0,
"message": {
"role": "assistant",
- "content": "Orchestration Service funktioniert!"
+ "content": "General Kenobi!"
},
"finish_reason": "stop"
}
@@ -27,6 +27,30 @@
"prompt_tokens": 19,
"total_tokens": 26
}
+ },
+ "input_filtering": {
+ "message": "Input filter passed successfully.",
+ "data": {
+ "original_service_response": {
+ "Hate": 0,
+ "SelfHarm": 0,
+ "Sexual": 0,
+ "Violence": 2
+ },
+ "checked_text": "Hello there!"
+ }
+ },
+ "output_filtering": {
+ "message": "Output filter passed successfully.",
+ "data": {
+ "original_service_response": {
+ "Hate": 0,
+ "SelfHarm": 0,
+ "Sexual": 0,
+ "Violence": 2
+ },
+ "checked_text": "General Kenobi!"
+ }
}
},
"orchestration_result": {
@@ -39,7 +63,7 @@
"index": 0,
"message": {
"role": "assistant",
- "content": "Orchestration Service funktioniert!"
+ "content": "General Kenobi!"
},
"finish_reason": "stop"
}
diff --git a/orchestration/src/test/resources/templatingRequest.json b/orchestration/src/test/resources/templatingRequest.json
deleted file mode 100644
index 3cde90a4..00000000
--- a/orchestration/src/test/resources/templatingRequest.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "orchestration_config": {
- "module_configurations": {
- "templating_module_config": {
- "template": [
- {
- "role": "user",
- "content": "{{?input}}"
- }
- ]
- },
- "llm_module_config": {
- "model_name": "gpt-35-turbo-16k",
- "model_params": {
- "max_tokens": 50,
- "temperature": 0.1,
- "frequency_penalty": 0,
- "presence_penalty": 0
- },
- "model_version": "latest"
- }
- }
- },
- "input_params": {
- "input": "Reply with 'Orchestration Service is working!' in German"
- }
-}
diff --git a/pom.xml b/pom.xml
index 04528a89..c33d8ca8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,6 +33,7 @@
orchestration
foundation-models/openai
sample-code/spring-app
+ sample-code/spring-ai-app
@@ -46,17 +47,21 @@
17
17
UTF-8
+
5.12.0
+ 6.1.13
+ 3.3.3
+ 1.0.0-SNAPSHOT
+ 2.0.16
+
5.11.0
3.9.1
3.26.3
- 2.0.16
2.43.0
10.18.1
2.1.3
3.5.0
2.1.3
- 6.1.13
5.13.0
false
@@ -74,16 +79,30 @@
pom
import
-
org.springframework
- spring-core
- ${springframework.version}
+ spring-framework-bom
+ ${spring.version}
+ pom
+ import
- org.springframework
- spring-web
- ${springframework.version}
+ org.springframework.ai
+ spring-ai-bom
+ ${spring.ai.version}
+ pom
+ import
+
+
+
+ org.springframework.boot
+ spring-boot
+ ${spring.boot.version}
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+ ${spring.boot.version}
@@ -146,6 +165,26 @@
test
+
+
+
+
+ true
+
+ spring-milestones
+ Spring Milestones
+ https://repo.spring.io/milestone
+
+
+
+ false
+
+ spring-snapshots
+ Spring Snapshots
+ https://repo.spring.io/snapshot
+
+
+
@@ -165,7 +204,7 @@
-
+