From e8635920d25de7fee1ede94bca7813ad0d53d727 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 8 Jul 2025 15:20:39 +0200 Subject: [PATCH 01/30] Input Filtering resolved --- .../ai/sdk/core/common/ClientException.java | 8 +++- .../core/common/ClientResponseHandler.java | 11 +++-- .../orchestration/OrchestrationClient.java | 12 +++++ .../OrchestrationClientException.java | 2 + .../OrchestrationFilterException.java | 44 +++++++++++++++++++ .../app/services/OrchestrationService.java | 13 ++++++ .../app/controllers/OrchestrationTest.java | 15 +++++++ 7 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java index 221d670a7..0e8ea6ed1 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java @@ -1,6 +1,9 @@ package com.sap.ai.sdk.core.common; import com.google.common.annotations.Beta; +import javax.annotation.Nullable; +import lombok.Getter; +import lombok.Setter; import lombok.experimental.StandardException; /** @@ -10,4 +13,7 @@ */ @Beta @StandardException -public class ClientException extends RuntimeException {} +public class ClientException extends RuntimeException { + + @Getter @Setter @Nullable protected ClientError clientError; +} diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index c1949f14f..7bf68106c 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -145,8 +145,13 @@ public void parseErrorAndThrow( throw baseException; } - val error = Objects.requireNonNullElse(maybeError.get().getMessage(), ""); - val message = "%s and error message: '%s'".formatted(baseException.getMessage(), error); - throw exceptionConstructor.apply(message, baseException); + val clientError = maybeError.get(); + val errorMessage = Objects.requireNonNullElse(clientError.getMessage(), ""); + val finalMessage = + "%s and error message: '%s'".formatted(baseException.getMessage(), errorMessage); + val finalException = exceptionConstructor.apply(finalMessage, baseException); + finalException.setClientError(clientError); + + throw finalException; } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 9efe16064..f41dc67bb 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -230,6 +230,18 @@ CompletionPostResponse executeRequest(@Nonnull final String request) { | HttpClientInstantiationException | IOException e) { throw new OrchestrationClientException("Failed to execute request", e); + } catch (OrchestrationClientException e) { + if (e.getClientError() instanceof OrchestrationError clientError + && clientError + .getOriginalResponse() + .getLocation() + .equals("Filtering Module - Input Filter")) { + throw new OrchestrationFilterException( + "Content filtered out due to policy restrictions in the filtering module.", + e, + clientError); + } + throw e; } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index bb96adba9..e5130fea1 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -1,8 +1,10 @@ package com.sap.ai.sdk.orchestration; import com.sap.ai.sdk.core.common.ClientException; +import lombok.Getter; import lombok.experimental.StandardException; /** Exception thrown by the {@link OrchestrationClient} in case of an error. */ @StandardException +@Getter public class OrchestrationClientException extends ClientException {} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java new file mode 100644 index 000000000..7ae898d91 --- /dev/null +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -0,0 +1,44 @@ +package com.sap.ai.sdk.orchestration; + +import java.util.Map; +import javax.annotation.Nullable; +import lombok.Getter; +import lombok.experimental.StandardException; + +@StandardException +public class OrchestrationFilterException extends OrchestrationClientException { + + @Getter @Nullable private FilterLocation location; + + enum FilterLocation { + INPUT_FILTER, + OUTPUT_FILTER + } + + public OrchestrationFilterException( + String message, Throwable cause, OrchestrationError clientError) { + super(message, cause); + this.clientError = clientError; + + if (clientError.getOriginalResponse().getLocation().equals("Filtering Module - Input Filter")) { + this.location = FilterLocation.INPUT_FILTER; + } else { + this.location = FilterLocation.OUTPUT_FILTER; + } + } + + @Nullable + public Map getFilteringReason() { + if (getClientError() != null) { + var moduleResult = + ((OrchestrationError) getClientError()).getOriginalResponse().getModuleResults(); + + if (this.location == FilterLocation.INPUT_FILTER) { + return (Map) moduleResult.getInputFiltering().getData(); + } else { + return (Map) moduleResult.getOutputFiltering().getData(); + } + } + return null; + } +} diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java index 7504b5b15..56c331fea 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java @@ -592,4 +592,17 @@ public OrchestrationChatResponse translation() { return client.chatCompletion(prompt, configWithTranslation); } + + @Nonnull + public OrchestrationChatResponse convenientInputFiltering(AzureFilterThreshold policy) { + val prompt = + new OrchestrationPrompt( + "Please rephrase the following sentence for me: 'We shall spill blood tonight', said the operation in-charge."); + val filterConfig = + new AzureContentFilter().hate(policy).selfHarm(policy).sexual(policy).violence(policy); + + val configWithFilter = config.withInputFiltering(filterConfig); + + return client.chatCompletion(prompt, configWithFilter); + } } diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 5d29f1cf8..286d8b117 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -14,6 +14,7 @@ import com.sap.ai.sdk.orchestration.Message; import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -434,4 +435,18 @@ void testTranslation() { assertThat(inputTranslation.getMessage()).isEqualTo("Input to LLM is translated successfully."); assertThat(outputTranslation.getMessage()).isEqualTo("Output Translation successful"); } + + @Test + void testConvenientFiltering() { + + try { + var response = service.convenientInputFiltering(AzureFilterThreshold.ALLOW_SAFE); + + assertThat(response).isNotNull(); + } catch (OrchestrationFilterException e) { + log.error(e.getMessage()); + log.error(e.getFilteringReason().toString()); + throw e; + } + } } From c16677fe4fb1ba5d7d319645a216de98fa795a5c Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 8 Jul 2025 16:09:21 +0200 Subject: [PATCH 02/30] Input Filtering and Output filtering resolved --- .../OrchestrationChatResponse.java | 13 +++++-- .../orchestration/OrchestrationClient.java | 12 +++++-- .../OrchestrationClientException.java | 21 +++++++++-- .../OrchestrationFilterException.java | 36 ++++++------------- .../app/services/OrchestrationService.java | 13 ------- .../app/controllers/OrchestrationTest.java | 14 -------- 6 files changed, 47 insertions(+), 62 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index eb44cce71..05f16fcfb 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -17,6 +17,7 @@ import com.sap.ai.sdk.orchestration.model.UserChatMessage; import java.util.ArrayList; import java.util.List; +import java.util.Map; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; import lombok.Value; @@ -34,14 +35,20 @@ public class OrchestrationChatResponse { *

Note: If there are multiple choices only the first one is returned * * @return the message content or empty string. - * @throws OrchestrationClientException if the content filter filtered the output. + * @throws OrchestrationFilterException if the content filter filtered the output. */ @Nonnull - public String getContent() throws OrchestrationClientException { + public String getContent() throws OrchestrationFilterException { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { - throw new OrchestrationClientException("Content filter filtered the output."); + final var filterDetails = + (Map) + getOriginalResponse().getModuleResults().getOutputFiltering().getData(); + throw new OrchestrationFilterException( + "Content filtered out due to policy restrictions in the output filtering module.", + OrchestrationFilterException.FilterLocation.INPUT_FILTER, + filterDetails); } return choice.getMessage().getContent(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index f41dc67bb..9b60dada9 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -22,6 +22,7 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException; import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException; import java.io.IOException; +import java.util.Map; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -236,10 +237,15 @@ CompletionPostResponse executeRequest(@Nonnull final String request) { .getOriginalResponse() .getLocation() .equals("Filtering Module - Input Filter")) { + + final var filerDetails = + (Map) + clientError.getOriginalResponse().getModuleResults().getInputFiltering().getData(); throw new OrchestrationFilterException( - "Content filtered out due to policy restrictions in the filtering module.", - e, - clientError); + "Content filtered out due to policy restrictions in the input filtering module.", + e.getCause(), + OrchestrationFilterException.FilterLocation.INPUT_FILTER, + filerDetails); } throw e; } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index e5130fea1..0e29d6453 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -1,10 +1,25 @@ package com.sap.ai.sdk.orchestration; import com.sap.ai.sdk.core.common.ClientException; -import lombok.Getter; import lombok.experimental.StandardException; /** Exception thrown by the {@link OrchestrationClient} in case of an error. */ @StandardException -@Getter -public class OrchestrationClientException extends ClientException {} +public class OrchestrationClientException extends ClientException { + public OrchestrationClientException() { + this(null, null); + } + + public OrchestrationClientException(final String message) { + this(message, null); + } + + public OrchestrationClientException(final Throwable cause) { + this(cause != null ? cause.getMessage() : null, cause); + } + + public OrchestrationClientException(final String message, final Throwable cause) { + super(message); + if (cause != null) super.initCause(cause); + } +} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index 7ae898d91..04344d855 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -1,44 +1,28 @@ package com.sap.ai.sdk.orchestration; import java.util.Map; -import javax.annotation.Nullable; +import javax.annotation.Nonnull; import lombok.Getter; -import lombok.experimental.StandardException; -@StandardException public class OrchestrationFilterException extends OrchestrationClientException { - @Getter @Nullable private FilterLocation location; + @Getter @Nonnull private final FilterLocation location; + @Getter @Nonnull private final Map filterDetails; - enum FilterLocation { + public enum FilterLocation { INPUT_FILTER, OUTPUT_FILTER } public OrchestrationFilterException( - String message, Throwable cause, OrchestrationError clientError) { + String message, Throwable cause, FilterLocation location, Map filterDetails) { super(message, cause); - this.clientError = clientError; - - if (clientError.getOriginalResponse().getLocation().equals("Filtering Module - Input Filter")) { - this.location = FilterLocation.INPUT_FILTER; - } else { - this.location = FilterLocation.OUTPUT_FILTER; - } + this.location = location; + this.filterDetails = filterDetails; } - @Nullable - public Map getFilteringReason() { - if (getClientError() != null) { - var moduleResult = - ((OrchestrationError) getClientError()).getOriginalResponse().getModuleResults(); - - if (this.location == FilterLocation.INPUT_FILTER) { - return (Map) moduleResult.getInputFiltering().getData(); - } else { - return (Map) moduleResult.getOutputFiltering().getData(); - } - } - return null; + public OrchestrationFilterException( + String message, FilterLocation location, Map filterDetails) { + this(message, null, location, filterDetails); } } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java index 56c331fea..7504b5b15 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java @@ -592,17 +592,4 @@ public OrchestrationChatResponse translation() { return client.chatCompletion(prompt, configWithTranslation); } - - @Nonnull - public OrchestrationChatResponse convenientInputFiltering(AzureFilterThreshold policy) { - val prompt = - new OrchestrationPrompt( - "Please rephrase the following sentence for me: 'We shall spill blood tonight', said the operation in-charge."); - val filterConfig = - new AzureContentFilter().hate(policy).selfHarm(policy).sexual(policy).violence(policy); - - val configWithFilter = config.withInputFiltering(filterConfig); - - return client.chatCompletion(prompt, configWithFilter); - } } diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 286d8b117..09d86abf6 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -435,18 +435,4 @@ void testTranslation() { assertThat(inputTranslation.getMessage()).isEqualTo("Input to LLM is translated successfully."); assertThat(outputTranslation.getMessage()).isEqualTo("Output Translation successful"); } - - @Test - void testConvenientFiltering() { - - try { - var response = service.convenientInputFiltering(AzureFilterThreshold.ALLOW_SAFE); - - assertThat(response).isNotNull(); - } catch (OrchestrationFilterException e) { - log.error(e.getMessage()); - log.error(e.getFilteringReason().toString()); - throw e; - } - } } From 842d53ca080d5886e67437c032d2e025aa9f6cbf Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 8 Jul 2025 17:50:50 +0200 Subject: [PATCH 03/30] Refactor - from enum to individual types of filtering exception --- .../OrchestrationChatResponse.java | 4 +-- .../orchestration/OrchestrationClient.java | 21 ++++++++++++-- .../OrchestrationClientException.java | 19 +----------- .../OrchestrationFilterException.java | 29 +++++++++---------- 4 files changed, 35 insertions(+), 38 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 05f16fcfb..6bfde53c8 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -45,9 +45,9 @@ public String getContent() throws OrchestrationFilterException { final var filterDetails = (Map) getOriginalResponse().getModuleResults().getOutputFiltering().getData(); - throw new OrchestrationFilterException( + + throw new OrchestrationFilterException.OrchestrationOutputFilterException( "Content filtered out due to policy restrictions in the output filtering module.", - OrchestrationFilterException.FilterLocation.INPUT_FILTER, filterDetails); } return choice.getMessage().getContent(); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 9b60dada9..807a98f00 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -241,10 +241,9 @@ CompletionPostResponse executeRequest(@Nonnull final String request) { final var filerDetails = (Map) clientError.getOriginalResponse().getModuleResults().getInputFiltering().getData(); - throw new OrchestrationFilterException( + throw new OrchestrationFilterException.OrchestrationInputFilterException( "Content filtered out due to policy restrictions in the input filtering module.", - e.getCause(), - OrchestrationFilterException.FilterLocation.INPUT_FILTER, + e, filerDetails); } throw e; @@ -299,6 +298,22 @@ private Stream streamRequest( .handleStreamingResponse(client.executeOpen(null, request, null)); } catch (final IOException e) { throw new OrchestrationClientException("Request to the Orchestration service failed", e); + } catch (OrchestrationClientException e) { + if (e.getClientError() instanceof OrchestrationError clientError + && clientError + .getOriginalResponse() + .getLocation() + .equals("Filtering Module - Input Filter")) { + + final var filerDetails = + (Map) + clientError.getOriginalResponse().getModuleResults().getInputFiltering().getData(); + throw new OrchestrationFilterException.OrchestrationInputFilterException( + "Content filtered out due to policy restrictions in the input filtering module.", + e, + filerDetails); + } + throw e; } } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index 0e29d6453..bb96adba9 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -5,21 +5,4 @@ /** Exception thrown by the {@link OrchestrationClient} in case of an error. */ @StandardException -public class OrchestrationClientException extends ClientException { - public OrchestrationClientException() { - this(null, null); - } - - public OrchestrationClientException(final String message) { - this(message, null); - } - - public OrchestrationClientException(final Throwable cause) { - this(cause != null ? cause.getMessage() : null, cause); - } - - public OrchestrationClientException(final String message, final Throwable cause) { - super(message); - if (cause != null) super.initCause(cause); - } -} +public class OrchestrationClientException extends ClientException {} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index 04344d855..fb778bab6 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -3,26 +3,25 @@ import java.util.Map; import javax.annotation.Nonnull; import lombok.Getter; +import lombok.experimental.StandardException; +@StandardException public class OrchestrationFilterException extends OrchestrationClientException { - @Getter @Nonnull private final FilterLocation location; - @Getter @Nonnull private final Map filterDetails; + @Getter @Nonnull protected Map filterDetails; - public enum FilterLocation { - INPUT_FILTER, - OUTPUT_FILTER + public static class OrchestrationInputFilterException extends OrchestrationFilterException { + OrchestrationInputFilterException( + String message, Throwable cause, Map filterDetails) { + super(message, cause); + this.filterDetails = filterDetails; + } } - public OrchestrationFilterException( - String message, Throwable cause, FilterLocation location, Map filterDetails) { - super(message, cause); - this.location = location; - this.filterDetails = filterDetails; - } - - public OrchestrationFilterException( - String message, FilterLocation location, Map filterDetails) { - this(message, null, location, filterDetails); + public static class OrchestrationOutputFilterException extends OrchestrationFilterException { + OrchestrationOutputFilterException(String message, Map filterDetails) { + super(message); + this.filterDetails = filterDetails; + } } } From 4a1a026b786fcfbbff0c470abfbcf9b98705cf4e Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 10 Jul 2025 11:13:50 +0200 Subject: [PATCH 04/30] Refactor - from enum to individual types of filtering exception --- .../ai/sdk/core/common/ClientException.java | 8 +- .../core/common/ClientExceptionFactory.java | 16 ++++ .../core/common/ClientResponseHandler.java | 74 +++++++------------ .../core/common/ClientStreamingHandler.java | 24 +++--- .../core/common/IterableStreamConverter.java | 13 ++-- .../orchestration/OrchestrationClient.java | 34 +++++---- .../OrchestrationClientException.java | 12 ++- .../OrchestrationExceptionFactory.java | 21 ++++++ .../app/controllers/OrchestrationTest.java | 9 ++- 9 files changed, 119 insertions(+), 92 deletions(-) create mode 100644 core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java create mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java index 0e8ea6ed1..221d670a7 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java @@ -1,9 +1,6 @@ package com.sap.ai.sdk.core.common; import com.google.common.annotations.Beta; -import javax.annotation.Nullable; -import lombok.Getter; -import lombok.Setter; import lombok.experimental.StandardException; /** @@ -13,7 +10,4 @@ */ @Beta @StandardException -public class ClientException extends RuntimeException { - - @Getter @Setter @Nullable protected ClientError clientError; -} +public class ClientException extends RuntimeException {} diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java new file mode 100644 index 000000000..770ac687c --- /dev/null +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java @@ -0,0 +1,16 @@ +package com.sap.ai.sdk.core.common; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Knows how to turn a 4xx/5xx HTTP response into a ClientException (and subtype). */ +public interface ClientExceptionFactory { + /** Creates a “base” exception with a message and optional cause. */ + E create(@Nonnull final String message, @Nullable final Throwable cause); + + /** + * Inspect the HTTP response, attempt to deserialize a JSON error payload, and return either the + * base exception or a richer one carrying a ClientError. + */ + E fromClientError(@Nonnull R clientError); +} diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 7bf68106c..8275f73bd 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -8,8 +8,6 @@ import io.vavr.control.Try; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Objects; -import java.util.function.BiFunction; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,17 +23,17 @@ * Parse incoming JSON responses and handles any errors. For internal use only. * * @param The type of the response. - * @param The type of the exception to throw. + * @param The type of the error. * @since 1.1.0 */ @Beta @Slf4j @RequiredArgsConstructor -public class ClientResponseHandler +public class ClientResponseHandler implements HttpClientResponseHandler { - @Nonnull final Class responseType; - @Nonnull private final Class errorType; - @Nonnull final BiFunction exceptionConstructor; + @Nonnull protected final Class responseType; + @Nonnull protected final Class errorType; + @Nonnull protected final ClientExceptionFactory exceptionFactory; /** The parses for JSON responses, will be private once we can remove mixins */ @Nonnull ObjectMapper objectMapper = getDefaultObjectMapper(); @@ -48,7 +46,7 @@ public class ClientResponseHandler */ @Beta @Nonnull - public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { + public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { objectMapper = jackson; return this; } @@ -58,24 +56,23 @@ public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jack * * @param response The response to process * @return A model class instantiated from the response - * @throws E in case of a problem or the connection was aborted */ @Nonnull @Override - public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { + public T handleResponse(@Nonnull final ClassicHttpResponse response) { if (response.getCode() >= 300) { buildExceptionAndThrow(response); } - return parseResponse(response); + return parseSuccess(response); } // The InputStream of the HTTP entity is closed by EntityUtils.toString @SuppressWarnings("PMD.CloseResource") @Nonnull - private T parseResponse(@Nonnull final ClassicHttpResponse response) throws E { + private T parseSuccess(@Nonnull final ClassicHttpResponse response) { final HttpEntity responseEntity = response.getEntity(); if (responseEntity == null) { - throw exceptionConstructor.apply("Response was empty.", null); + throw exceptionFactory.create("Response was empty.", null); } val content = getContent(responseEntity); log.debug("Parsing response from JSON response: {}", content); @@ -83,7 +80,7 @@ private T parseResponse(@Nonnull final ClassicHttpResponse response) throws E { return objectMapper.readValue(content, responseType); } catch (final JsonProcessingException e) { log.error("Failed to parse the following response: {}", content); - throw exceptionConstructor.apply("Failed to parse response", e); + throw exceptionFactory.create("Failed to parse response", e); } } @@ -92,66 +89,47 @@ private String getContent(@Nonnull final HttpEntity entity) { try { return EntityUtils.toString(entity, StandardCharsets.UTF_8); } catch (IOException | ParseException e) { - throw exceptionConstructor.apply("Failed to read response content.", e); + throw exceptionFactory.create("Failed to read response content.", e); } } /** * Parse the error response and throw an exception. * - * @param response The response to process + * @param httpResponse The response to process + * @throws ClientException if the response is an error (4xx/5xx) */ @SuppressWarnings("PMD.CloseResource") - public void buildExceptionAndThrow(@Nonnull final ClassicHttpResponse response) throws E { - val exception = - exceptionConstructor.apply( + protected void buildExceptionAndThrow(@Nonnull final ClassicHttpResponse httpResponse) { + val baseException = + exceptionFactory.create( "Request failed with status %s %s" - .formatted(response.getCode(), response.getReasonPhrase()), + .formatted(httpResponse.getCode(), httpResponse.getReasonPhrase()), null); - val entity = response.getEntity(); + val entity = httpResponse.getEntity(); if (entity == null) { - throw exception; + throw baseException; } val maybeContent = Try.of(() -> getContent(entity)); if (maybeContent.isFailure()) { - exception.addSuppressed(maybeContent.getCause()); - throw exception; + baseException.addSuppressed(maybeContent.getCause()); + throw baseException; } val content = maybeContent.get(); if (content.isBlank()) { - throw exception; + throw baseException; } - log.error("The service responded with an HTTP error and the following content: {}", content); val contentType = ContentType.parse(entity.getContentType()); if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - throw exception; + throw baseException; } - - parseErrorAndThrow(content, exception); - } - - /** - * Parse the error response and throw an exception. - * - * @param errorResponse the error response, most likely a unique JSON class. - * @param baseException a base exception to add the error message to. - */ - public void parseErrorAndThrow( - @Nonnull final String errorResponse, @Nonnull final E baseException) throws E { - val maybeError = Try.of(() -> objectMapper.readValue(errorResponse, errorType)); + val maybeError = Try.of(() -> objectMapper.readValue(content, errorType)); if (maybeError.isFailure()) { baseException.addSuppressed(maybeError.getCause()); throw baseException; } - val clientError = maybeError.get(); - val errorMessage = Objects.requireNonNullElse(clientError.getMessage(), ""); - val finalMessage = - "%s and error message: '%s'".formatted(baseException.getMessage(), errorMessage); - val finalException = exceptionConstructor.apply(finalMessage, baseException); - finalException.setClientError(clientError); - - throw finalException; + throw exceptionFactory.fromClientError(maybeError.get()); } } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java index c366b8482..d80f1763d 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import java.io.IOException; -import java.util.function.BiFunction; import java.util.stream.Stream; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -13,13 +12,13 @@ * Parse incoming JSON responses and handles any errors. For internal use only. * * @param The type of the response. - * @param The type of the exception to throw. + * @param The type of the error. * @since 1.2.0 */ @Beta @Slf4j -public class ClientStreamingHandler - extends ClientResponseHandler { +public class ClientStreamingHandler + extends ClientResponseHandler { /** * Set the {@link ObjectMapper} to use for parsing JSON responses. @@ -28,7 +27,7 @@ public class ClientStreamingHandler objectMapper(@Nonnull final ObjectMapper jackson) { + public ClientStreamingHandler objectMapper(@Nonnull final ObjectMapper jackson) { super.objectMapper(jackson); return this; } @@ -38,13 +37,13 @@ public ClientStreamingHandler objectMapper(@Nonnull final ObjectMapper jac * * @param deltaType The type of the response. * @param errorType The type of the error. - * @param exceptionType The type of the exception to throw. + * @param exceptionFactory The type of the exception to throw. */ public ClientStreamingHandler( @Nonnull final Class deltaType, - @Nonnull final Class errorType, - @Nonnull final BiFunction exceptionType) { - super(deltaType, errorType, exceptionType); + @Nonnull final Class errorType, + @Nonnull final ClientExceptionFactory exceptionFactory) { + super(deltaType, errorType, exceptionFactory); } /** @@ -53,22 +52,21 @@ public ClientStreamingHandler( * * @param response The response to process * @return A {@link Stream} of a model class instantiated from the response - * @throws E in case of a problem or the connection was aborted */ @SuppressWarnings("PMD.CloseResource") // Stream is closed automatically when consumed @Nonnull - public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse response) throws E { + public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse response) { if (response.getCode() >= 300) { super.buildExceptionAndThrow(response); } - return IterableStreamConverter.lines(response.getEntity(), exceptionConstructor) + return IterableStreamConverter.lines(response.getEntity(), exceptionFactory) // half of the lines are empty newlines, the last line is "data: [DONE]" .filter(line -> !line.isEmpty() && !"data: [DONE]".equals(line.trim())) .peek( line -> { if (!line.startsWith("data: ")) { final String msg = "Failed to parse response"; - super.parseErrorAndThrow(line, exceptionConstructor.apply(msg, null)); + super.parseErrorAndThrow(line, exceptionFactory.create(msg, null)); } }) .map( diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java b/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java index f886a4e41..953b1160f 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java @@ -13,7 +13,6 @@ import java.util.NoSuchElementException; import java.util.Spliterators; import java.util.concurrent.Callable; -import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -91,32 +90,32 @@ public T next() { * when an exception occurred. * * @param entity The HTTP entity object. - * @param exceptionType The type of the client exception to throw in case of an error. + * @param exceptionFactory The exception factory to use for creating exceptions. * @return A sequential Stream object. * @throws ClientException if the provided HTTP entity object is {@code null} or empty. */ @SuppressWarnings("PMD.CloseResource") // Stream is closed automatically when consumed @Nonnull - static Stream lines( + static Stream lines( @Nullable final HttpEntity entity, - @Nonnull final BiFunction exceptionType) + @Nonnull final ClientExceptionFactory exceptionFactory) throws ClientException { if (entity == null) { - throw exceptionType.apply("Orchestration service response was empty.", null); + throw exceptionFactory.create("Orchestration service response was empty.", null); } final InputStream inputStream; try { inputStream = entity.getContent(); } catch (final IOException e) { - throw exceptionType.apply("Failed to read response content.", e); + throw exceptionFactory.create("Failed to read response content.", e); } final var reader = new BufferedReader(new InputStreamReader(inputStream, UTF_8), BUFFER_SIZE); final Runnable closeHandler = () -> Try.run(reader::close).onFailure(e -> log.error("Could not close input stream", e)); final Function errHandler = - e -> exceptionType.apply("Parsing response content was interrupted.", e); + e -> exceptionFactory.create("Parsing response content was interrupted.", e); final var iterator = new IterableStreamConverter<>(reader::readLine, closeHandler, errHandler); final var spliterator = Spliterators.spliteratorUnknownSize(iterator, ORDERED | NONNULL); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 807a98f00..cd60a1dd4 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -222,7 +222,7 @@ CompletionPostResponse executeRequest(@Nonnull final String request) { new ClientResponseHandler<>( CompletionPostResponse.class, OrchestrationError.class, - OrchestrationClientException::new) + new OrchestrationExceptionFactory()) .objectMapper(JACKSON); return client.execute(postRequest, handler); } catch (DeploymentResolutionException @@ -232,15 +232,18 @@ CompletionPostResponse executeRequest(@Nonnull final String request) { | IOException e) { throw new OrchestrationClientException("Failed to execute request", e); } catch (OrchestrationClientException e) { - if (e.getClientError() instanceof OrchestrationError clientError - && clientError - .getOriginalResponse() - .getLocation() - .equals("Filtering Module - Input Filter")) { + if (e.getClientError() + .getOriginalResponse() + .getLocation() + .equals("Filtering Module - Input Filter")) { final var filerDetails = (Map) - clientError.getOriginalResponse().getModuleResults().getInputFiltering().getData(); + e.getClientError() + .getOriginalResponse() + .getModuleResults() + .getInputFiltering() + .getData(); throw new OrchestrationFilterException.OrchestrationInputFilterException( "Content filtered out due to policy restrictions in the input filtering module.", e, @@ -293,21 +296,24 @@ private Stream streamRequest( log.debug("Using destination {} to connect to orchestration service", destination); val client = ApacheHttpClient5Accessor.getHttpClient(destination); return new ClientStreamingHandler<>( - deltaType, OrchestrationError.class, OrchestrationClientException::new) + deltaType, OrchestrationError.class, new OrchestrationExceptionFactory()) .objectMapper(JACKSON) .handleStreamingResponse(client.executeOpen(null, request, null)); } catch (final IOException e) { throw new OrchestrationClientException("Request to the Orchestration service failed", e); } catch (OrchestrationClientException e) { - if (e.getClientError() instanceof OrchestrationError clientError - && clientError - .getOriginalResponse() - .getLocation() - .equals("Filtering Module - Input Filter")) { + if (e.getClientError() + .getOriginalResponse() + .getLocation() + .equals("Filtering Module - Input Filter")) { final var filerDetails = (Map) - clientError.getOriginalResponse().getModuleResults().getInputFiltering().getData(); + e.getClientError() + .getOriginalResponse() + .getModuleResults() + .getInputFiltering() + .getData(); throw new OrchestrationFilterException.OrchestrationInputFilterException( "Content filtered out due to policy restrictions in the input filtering module.", e, diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index bb96adba9..e452433cf 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -1,8 +1,18 @@ package com.sap.ai.sdk.orchestration; import com.sap.ai.sdk.core.common.ClientException; +import javax.annotation.Nullable; + +import lombok.Getter; import lombok.experimental.StandardException; /** Exception thrown by the {@link OrchestrationClient} in case of an error. */ @StandardException -public class OrchestrationClientException extends ClientException {} +public class OrchestrationClientException extends ClientException { + @Getter @Nullable protected OrchestrationError clientError; + + OrchestrationClientException(OrchestrationError clientError) { + super(clientError.getMessage()); + this.clientError = clientError; + } +} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java new file mode 100644 index 000000000..c3c5d4a38 --- /dev/null +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -0,0 +1,21 @@ +package com.sap.ai.sdk.orchestration; + +import com.sap.ai.sdk.core.common.ClientExceptionFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class OrchestrationExceptionFactory + implements ClientExceptionFactory { + + public OrchestrationClientException create(@Nonnull String message, @Nullable Throwable cause) { + return new OrchestrationClientException(message, cause); + } + + @Override + public OrchestrationClientException fromClientError( + @Nonnull OrchestrationError orchestrationError) { + return new OrchestrationClientException(orchestrationError); + } +} diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 09d86abf6..d879fb42b 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -228,8 +228,13 @@ void testInputFilteringStrict() { assertThatThrownBy(() -> service.inputFiltering(policy)) .isInstanceOf(OrchestrationClientException.class) .hasMessageContaining( - "Prompt filtered due to safety violations. Please modify the prompt and try again.") - .hasMessageContaining("400 Bad Request"); + "Content filtered out due to policy restrictions in the input filtering module."); + + try { + service.inputFiltering(policy); + } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { + ((OrchestrationClientException) e.getCause()).getClientError(); + } } @Test From 69087b6a51528baf7d2b88cc1cfe9265a2ac0bac Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 11 Jul 2025 14:39:52 +0200 Subject: [PATCH 05/30] Exception factory introduced --- .../ai/sdk/core/common/ClientException.java | 8 +- .../core/common/ClientExceptionFactory.java | 6 +- .../core/common/ClientResponseHandler.java | 68 ++++++++--------- .../core/common/ClientStreamingHandler.java | 21 +++--- .../common/ClientResponseHandlerTest.java | 75 +++++++++++-------- .../common/IterableStreamConverterTest.java | 23 +++++- .../foundationmodels/openai/OpenAiClient.java | 6 +- .../openai/OpenAiClientException.java | 17 ++++- .../openai/OpenAiExceptionFactory.java | 21 ++++++ .../OrchestrationClientException.java | 17 +++-- .../OrchestrationExceptionFactory.java | 13 ++-- 11 files changed, 182 insertions(+), 93 deletions(-) create mode 100644 foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java index 221d670a7..da76d57da 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java @@ -1,6 +1,8 @@ package com.sap.ai.sdk.core.common; import com.google.common.annotations.Beta; +import javax.annotation.Nullable; +import lombok.Getter; import lombok.experimental.StandardException; /** @@ -10,4 +12,8 @@ */ @Beta @StandardException -public class ClientException extends RuntimeException {} +public class ClientException extends RuntimeException { + @Nullable + @Getter(onMethod_ = @Beta) + public ClientError clientError; +} diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java index 770ac687c..82ab8cd33 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java @@ -1,16 +1,18 @@ package com.sap.ai.sdk.core.common; +import com.google.common.annotations.Beta; import javax.annotation.Nonnull; import javax.annotation.Nullable; /** Knows how to turn a 4xx/5xx HTTP response into a ClientException (and subtype). */ +@Beta public interface ClientExceptionFactory { + /** Creates a “base” exception with a message and optional cause. */ E create(@Nonnull final String message, @Nullable final Throwable cause); /** * Inspect the HTTP response, attempt to deserialize a JSON error payload, and return either the - * base exception or a richer one carrying a ClientError. */ - E fromClientError(@Nonnull R clientError); + E fromClientError(@Nonnull final String message, @Nonnull final R clientError); } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 8275f73bd..a52b07d9c 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import io.vavr.control.Try; -import java.io.IOException; import java.nio.charset.StandardCharsets; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; @@ -15,7 +14,6 @@ import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpEntity; -import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.io.HttpClientResponseHandler; import org.apache.hc.core5.http.io.entity.EntityUtils; @@ -23,17 +21,18 @@ * Parse incoming JSON responses and handles any errors. For internal use only. * * @param The type of the response. + * @param The type of the exception to throw. * @param The type of the error. * @since 1.1.0 */ @Beta @Slf4j @RequiredArgsConstructor -public class ClientResponseHandler +public class ClientResponseHandler implements HttpClientResponseHandler { - @Nonnull protected final Class responseType; + @Nonnull protected final Class successType; @Nonnull protected final Class errorType; - @Nonnull protected final ClientExceptionFactory exceptionFactory; + @Nonnull protected final ClientExceptionFactory exceptionFactory; /** The parses for JSON responses, will be private once we can remove mixins */ @Nonnull ObjectMapper objectMapper = getDefaultObjectMapper(); @@ -46,8 +45,8 @@ public class ClientResponseHandler */ @Beta @Nonnull - public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { - objectMapper = jackson; + public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { + this.objectMapper = jackson; return this; } @@ -61,7 +60,7 @@ public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jack @Override public T handleResponse(@Nonnull final ClassicHttpResponse response) { if (response.getCode() >= 300) { - buildExceptionAndThrow(response); + throw buildException(response); } return parseSuccess(response); } @@ -74,23 +73,22 @@ private T parseSuccess(@Nonnull final ClassicHttpResponse response) { if (responseEntity == null) { throw exceptionFactory.create("Response was empty.", null); } - val content = getContent(responseEntity); + + val content = + tryGetContent(responseEntity) + .getOrElseThrow(e -> exceptionFactory.create("Failed to read response content.", e)); log.debug("Parsing response from JSON response: {}", content); try { - return objectMapper.readValue(content, responseType); + return objectMapper.readValue(content, successType); } catch (final JsonProcessingException e) { log.error("Failed to parse the following response: {}", content); - throw exceptionFactory.create("Failed to parse response", e); + throw exceptionFactory.create("Failed to parse response:", e); } } @Nonnull - private String getContent(@Nonnull final HttpEntity entity) { - try { - return EntityUtils.toString(entity, StandardCharsets.UTF_8); - } catch (IOException | ParseException e) { - throw exceptionFactory.create("Failed to read response content.", e); - } + private Try tryGetContent(@Nonnull final HttpEntity entity) { + return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); } /** @@ -100,36 +98,38 @@ private String getContent(@Nonnull final HttpEntity entity) { * @throws ClientException if the response is an error (4xx/5xx) */ @SuppressWarnings("PMD.CloseResource") - protected void buildExceptionAndThrow(@Nonnull final ClassicHttpResponse httpResponse) { - val baseException = - exceptionFactory.create( - "Request failed with status %s %s" - .formatted(httpResponse.getCode(), httpResponse.getReasonPhrase()), - null); + protected E buildException(@Nonnull final ClassicHttpResponse httpResponse) { + val baseErrorMessage = + "Request failed with status %d %s" + .formatted(httpResponse.getCode(), httpResponse.getReasonPhrase()); + val baseException = exceptionFactory.create(baseErrorMessage, null); + val entity = httpResponse.getEntity(); + if (entity == null) { - throw baseException; + return baseException; } - val maybeContent = Try.of(() -> getContent(entity)); + val maybeContent = tryGetContent(entity); if (maybeContent.isFailure()) { baseException.addSuppressed(maybeContent.getCause()); - throw baseException; + return baseException; } val content = maybeContent.get(); if (content.isBlank()) { - throw baseException; + return baseException; } log.error("The service responded with an HTTP error and the following content: {}", content); val contentType = ContentType.parse(entity.getContentType()); if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - throw baseException; + return baseException; } - val maybeError = Try.of(() -> objectMapper.readValue(content, errorType)); - if (maybeError.isFailure()) { - baseException.addSuppressed(maybeError.getCause()); - throw baseException; + val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); + if (maybeClientError.isFailure()) { + baseException.addSuppressed(maybeClientError.getCause()); + return baseException; } - - throw exceptionFactory.fromClientError(maybeError.get()); + R clientError = maybeClientError.get(); + var extendErrorMessage = "%s: %s".formatted(baseErrorMessage, clientError.getMessage()); + return exceptionFactory.fromClientError(extendErrorMessage, clientError); } } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java index d80f1763d..c09a2ae9d 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java @@ -12,13 +12,15 @@ * Parse incoming JSON responses and handles any errors. For internal use only. * * @param The type of the response. + * @param The type of the exception to throw. * @param The type of the error. * @since 1.2.0 */ @Beta @Slf4j -public class ClientStreamingHandler - extends ClientResponseHandler { +public class ClientStreamingHandler< + D extends StreamedDelta, R extends ClientError, E extends ClientException> + extends ClientResponseHandler { /** * Set the {@link ObjectMapper} to use for parsing JSON responses. @@ -27,7 +29,7 @@ public class ClientStreamingHandler objectMapper(@Nonnull final ObjectMapper jackson) { + public ClientStreamingHandler objectMapper(@Nonnull final ObjectMapper jackson) { super.objectMapper(jackson); return this; } @@ -42,7 +44,7 @@ public ClientStreamingHandler objectMapper(@Nonnull final ObjectMapper jac public ClientStreamingHandler( @Nonnull final Class deltaType, @Nonnull final Class errorType, - @Nonnull final ClientExceptionFactory exceptionFactory) { + @Nonnull final ClientExceptionFactory exceptionFactory) { super(deltaType, errorType, exceptionFactory); } @@ -57,26 +59,27 @@ public ClientStreamingHandler( @Nonnull public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse response) { if (response.getCode() >= 300) { - super.buildExceptionAndThrow(response); + throw buildException(response); } + return IterableStreamConverter.lines(response.getEntity(), exceptionFactory) // half of the lines are empty newlines, the last line is "data: [DONE]" .filter(line -> !line.isEmpty() && !"data: [DONE]".equals(line.trim())) .peek( line -> { if (!line.startsWith("data: ")) { - final String msg = "Failed to parse response"; - super.parseErrorAndThrow(line, exceptionFactory.create(msg, null)); + final String msg = "Failed to parse response: " + line; + throw exceptionFactory.create(msg, null); } }) .map( line -> { final String data = line.substring(5); // remove "data: " try { - return objectMapper.readValue(data, responseType); + return objectMapper.readValue(data, successType); } catch (final IOException e) { // exception message e gets lost log.error("Failed to parse the following response: {}", line); - throw exceptionConstructor.apply("Failed to parse delta message: " + line, e); + throw exceptionFactory.create("Failed to parse delta message: " + line, e); } }); } diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java index f8a90e90e..2ed900df0 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonParseException; import java.io.IOException; import lombok.Data; import lombok.SneakyThrows; @@ -15,6 +16,7 @@ import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; class ClientResponseHandlerTest { @@ -30,30 +32,25 @@ static class MyError implements ClientError { @StandardException static class MyException extends ClientException {} - @Test - public void testParseErrorAndThrow() { - var sut = new ClientResponseHandler<>(MyResponse.class, MyError.class, MyException::new); - - MyException cause = new MyException("Something wrong"); - - assertThatThrownBy(() -> sut.parseErrorAndThrow("{\"message\":\"foobar\"}", cause)) - .isInstanceOf(MyException.class) - .hasMessage("Something wrong and error message: 'foobar'") - .hasCause(cause); + static class MyExceptionFactory implements ClientExceptionFactory { + @Override + public MyException create(@NotNull String message, Throwable cause) { + return new MyException(message, cause); + } - assertThatThrownBy(() -> sut.parseErrorAndThrow("{\"foo\":\"bar\"}", cause)) - .isInstanceOf(MyException.class) - .hasMessage("Something wrong and error message: ''") - .hasCause(cause); - - assertThatThrownBy(() -> sut.parseErrorAndThrow("foobar", cause)) - .isEqualTo(cause); + @Override + public MyException fromClientError(@NotNull String message, @NotNull MyError clientError) { + var ex = new MyException(message); + ex.clientError = clientError; + return ex; + } } @SneakyThrows @Test public void testBuildExceptionAndThrow() { - var sut = new ClientResponseHandler<>(MyResponse.class, MyError.class, MyException::new); + var sut = + new ClientResponseHandler<>(MyResponse.class, MyError.class, new MyExceptionFactory()); HttpEntity entityWithNetworkIssues = spy(new StringEntity("")); doThrow(new IOException("Network issues")).when(entityWithNetworkIssues).writeTo(any()); @@ -65,27 +62,45 @@ public void testBuildExceptionAndThrow() { .thenReturn(entityWithNetworkIssues) .thenReturn(new StringEntity("", ContentType.APPLICATION_JSON)) .thenReturn(new StringEntity("oh", ContentType.TEXT_HTML)) - .thenReturn(new StringEntity("{\"message\":\"foobar\"}", ContentType.APPLICATION_JSON)); + .thenReturn(new StringEntity("{\"message\":\"foobar\"}", ContentType.APPLICATION_JSON)) + .thenReturn(new StringEntity("{\"message\"-\"foobar\"}", ContentType.APPLICATION_JSON)); - assertThatThrownBy(() -> sut.buildExceptionAndThrow(response)) + assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) .hasMessage("Request failed with status 400 Bad Request") - .hasNoCause(); - assertThatThrownBy(() -> sut.buildExceptionAndThrow(response)) + .hasNoCause() + .extracting(e -> ((MyException) e).getClientError()) + .isNull(); + assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) .hasMessage("Request failed with status 400 Bad Request") - .hasNoCause(); - assertThatThrownBy(() -> sut.buildExceptionAndThrow(response)) + .extracting(e -> e.getSuppressed()[0]) + .isInstanceOf(IOException.class) + .extracting(Throwable::getMessage) + .isEqualTo("Network issues"); + assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) .hasMessage("Request failed with status 400 Bad Request") - .hasNoCause(); - assertThatThrownBy(() -> sut.buildExceptionAndThrow(response)) + .hasNoCause() + .extracting(e -> ((MyException) e).getClientError()) + .isNull(); + assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) .hasMessage("Request failed with status 400 Bad Request") - .hasNoCause(); - assertThatThrownBy(() -> sut.buildExceptionAndThrow(response)) + .hasNoCause() + .extracting(e -> ((MyException) e).getClientError()) + .isNull(); + assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request and error message: 'foobar'") - .hasCause(new MyException("Request failed with status 400 Bad Request")); + .hasMessage("Request failed with status 400 Bad Request: foobar") + .hasNoCause() + .extracting(e -> ((MyException) e).getClientError()) + .isNotNull(); + assertThatThrownBy(() -> sut.handleResponse(response)) + .isInstanceOf(MyException.class) + .hasMessage("Request failed with status 400 Bad Request") + .hasNoCause() + .extracting(e -> e.getSuppressed()[0]) + .isInstanceOf(JsonParseException.class); } } diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java index b9123026e..263495c81 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java @@ -21,6 +21,7 @@ import lombok.experimental.StandardException; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -34,7 +35,7 @@ void testLines() { final var inputStream = spy(new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8))); final var entity = new InputStreamEntity(inputStream, ContentType.TEXT_PLAIN); - final var sut = IterableStreamConverter.lines(entity, TestClientException::new); + final var sut = IterableStreamConverter.lines(entity, new TestClientExceptionFactory()); verify(inputStream, never()).read(); verify(inputStream, never()).read(any()); verify(inputStream, never()).read(any(), anyInt(), anyInt()); @@ -70,7 +71,7 @@ void testLinesFindFirst() { final var entity = new InputStreamEntity(inputStream, ContentType.TEXT_PLAIN); - final var sut = IterableStreamConverter.lines(entity, TestClientException::new); + final var sut = IterableStreamConverter.lines(entity, new TestClientExceptionFactory()); assertThat(sut.findFirst()).contains("Foo Bar"); verify(inputStream, times(1)).read(any(), anyInt(), anyInt()); verify(inputStream, never()).close(); @@ -94,7 +95,7 @@ void testLinesThrows() { final var entity = new InputStreamEntity(inputStream, ContentType.TEXT_PLAIN); - final var sut = IterableStreamConverter.lines(entity, TestClientException::new); + final var sut = IterableStreamConverter.lines(entity, new TestClientExceptionFactory()); assertThatThrownBy(sut::count) .isInstanceOf(TestClientException.class) .hasMessage("Parsing response content was interrupted.") @@ -107,4 +108,20 @@ void testLinesThrows() { @StandardException public static class TestClientException extends ClientException {} + + static class TestClientExceptionFactory + implements ClientExceptionFactory { + + @Override + public TestClientException create(@NotNull String message, Throwable cause) { + return new TestClientException(message, cause); + } + + @Override + public TestClientException fromClientError(@NotNull String message, @NotNull ClientError clientError) { + TestClientException exception = new TestClientException(message); + exception.clientError = clientError; + return exception; + } + } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java index b498a1b5e..71416711a 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java @@ -431,7 +431,8 @@ private T executeRequest( final var client = ApacheHttpClient5Accessor.getHttpClient(destination); return client.execute( request, - new ClientResponseHandler<>(responseType, OpenAiError.class, OpenAiClientException::new)); + new ClientResponseHandler<>( + responseType, OpenAiError.class, new OpenAiExceptionFactory())); } catch (final IOException e) { throw new OpenAiClientException("Request to OpenAI model failed", e); } @@ -442,7 +443,8 @@ private Stream streamRequest( final BasicClassicHttpRequest request, @Nonnull final Class deltaType) { try { final var client = ApacheHttpClient5Accessor.getHttpClient(destination); - return new ClientStreamingHandler<>(deltaType, OpenAiError.class, OpenAiClientException::new) + return new ClientStreamingHandler<>( + deltaType, OpenAiError.class, new OpenAiExceptionFactory()) .objectMapper(JACKSON) .handleStreamingResponse(client.executeOpen(null, request, null)); } catch (final IOException e) { diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java index a61493af0..a561b179c 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java @@ -1,8 +1,23 @@ package com.sap.ai.sdk.foundationmodels.openai; +import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientException; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.experimental.StandardException; /** Generic exception for errors occurring when using OpenAI foundation models. */ @StandardException -public class OpenAiClientException extends ClientException {} +public class OpenAiClientException extends ClientException { + OpenAiClientException(@Nonnull final String message, @Nonnull final OpenAiError clientError) { + super(message); + this.clientError = clientError; + } + + @Beta + @Nullable + @Override + public OpenAiError getClientError() { + return (OpenAiError) super.getClientError(); + } +} diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java new file mode 100644 index 000000000..0d720b81a --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java @@ -0,0 +1,21 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +import com.google.common.annotations.Beta; +import com.sap.ai.sdk.core.common.ClientExceptionFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +@Beta +class OpenAiExceptionFactory implements ClientExceptionFactory { + + public OpenAiClientException create( + @Nonnull final String message, @Nullable final Throwable cause) { + return new OpenAiClientException(message, cause); + } + + @Override + public OpenAiClientException fromClientError( + @Nonnull final String message, @Nonnull final OpenAiError openAiError) { + return new OpenAiClientException(message, openAiError); + } +} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index e452433cf..7f783f10d 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -1,18 +1,25 @@ package com.sap.ai.sdk.orchestration; +import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientException; +import javax.annotation.Nonnull; import javax.annotation.Nullable; - -import lombok.Getter; import lombok.experimental.StandardException; /** Exception thrown by the {@link OrchestrationClient} in case of an error. */ @StandardException public class OrchestrationClientException extends ClientException { - @Getter @Nullable protected OrchestrationError clientError; - OrchestrationClientException(OrchestrationError clientError) { - super(clientError.getMessage()); + OrchestrationClientException( + @Nonnull final String message, @Nonnull final OrchestrationError clientError) { + super(message); this.clientError = clientError; } + + @Beta + @Nullable + @Override + public OrchestrationError getClientError() { + return (OrchestrationError) super.getClientError(); + } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index c3c5d4a38..1cb47bf7f 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -1,21 +1,22 @@ package com.sap.ai.sdk.orchestration; +import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor -public class OrchestrationExceptionFactory +@Beta +class OrchestrationExceptionFactory implements ClientExceptionFactory { - public OrchestrationClientException create(@Nonnull String message, @Nullable Throwable cause) { + public OrchestrationClientException create( + @Nonnull final String message, @Nullable final Throwable cause) { return new OrchestrationClientException(message, cause); } @Override public OrchestrationClientException fromClientError( - @Nonnull OrchestrationError orchestrationError) { - return new OrchestrationClientException(orchestrationError); + @Nonnull final String message, @Nonnull final OrchestrationError orchestrationError) { + return new OrchestrationClientException(message, orchestrationError); } } From e6d371c9e26a5b57953e551ea94bbf094a8e5722 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 11 Jul 2025 15:09:27 +0200 Subject: [PATCH 06/30] Finish merge with main --- .../core/common/ClientResponseHandler.java | 4 +- .../core/common/ClientStreamingHandler.java | 12 +++--- .../OrchestrationHttpExecutor.java | 43 ++++++++++++++++++- .../orchestration/OrchestrationUnitTest.java | 4 +- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index a52b07d9c..db5f76ce1 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -68,7 +68,7 @@ public T handleResponse(@Nonnull final ClassicHttpResponse response) { // The InputStream of the HTTP entity is closed by EntityUtils.toString @SuppressWarnings("PMD.CloseResource") @Nonnull - private T parseSuccess(@Nonnull final ClassicHttpResponse response) { + private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E{ final HttpEntity responseEntity = response.getEntity(); if (responseEntity == null) { throw exceptionFactory.create("Response was empty.", null); @@ -76,7 +76,7 @@ private T parseSuccess(@Nonnull final ClassicHttpResponse response) { val content = tryGetContent(responseEntity) - .getOrElseThrow(e -> exceptionFactory.create("Failed to read response content.", e)); + .getOrElseThrow(e -> exceptionFactory.create("Failed to parse response entity.", e)); log.debug("Parsing response from JSON response: {}", content); try { return objectMapper.readValue(content, successType); diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java index c09a2ae9d..c0a97b636 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java @@ -39,7 +39,7 @@ public ClientStreamingHandler objectMapper(@Nonnull final ObjectMapper * * @param deltaType The type of the response. * @param errorType The type of the error. - * @param exceptionFactory The type of the exception to throw. + * @param exceptionFactory The factory to create exceptions. */ public ClientStreamingHandler( @Nonnull final Class deltaType, @@ -54,12 +54,13 @@ public ClientStreamingHandler( * * @param response The response to process * @return A {@link Stream} of a model class instantiated from the response + * @throws E in case of a problem or the connection was aborted */ @SuppressWarnings("PMD.CloseResource") // Stream is closed automatically when consumed @Nonnull - public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse response) { + public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse response) throws E { if (response.getCode() >= 300) { - throw buildException(response); + throw super.buildException(response); } return IterableStreamConverter.lines(response.getEntity(), exceptionFactory) @@ -68,7 +69,7 @@ public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse resp .peek( line -> { if (!line.startsWith("data: ")) { - final String msg = "Failed to parse response: " + line; + final String msg = "Failed to parse response: %s".formatted(line); throw exceptionFactory.create(msg, null); } }) @@ -79,7 +80,8 @@ public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse resp return objectMapper.readValue(data, successType); } catch (final IOException e) { // exception message e gets lost log.error("Failed to parse the following response: {}", line); - throw exceptionFactory.create("Failed to parse delta message: " + line, e); + final String msg = "Failed to parse delta message: %s".formatted(line); + throw exceptionFactory.create(msg, e); } }); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java index b5c51bfc8..34523bde4 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java @@ -13,6 +13,7 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException; import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException; import java.io.IOException; +import java.util.Map; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -48,7 +49,7 @@ T execute( val handler = new ClientResponseHandler<>( - responseType, OrchestrationError.class, OrchestrationClientException::new) + responseType, OrchestrationError.class, new OrchestrationExceptionFactory()) .objectMapper(JACKSON); return client.execute(request, handler); @@ -61,6 +62,25 @@ T execute( | IOException e) { throw new OrchestrationClientException( "Request to Orchestration service failed for " + path, e); + } catch (OrchestrationClientException e) { + if (e.getClientError() + .getOriginalResponse() + .getLocation() + .equals("Filtering Module - Input Filter")) { + + final var filerDetails = + (Map) + e.getClientError() + .getOriginalResponse() + .getModuleResults() + .getInputFiltering() + .getData(); + throw new OrchestrationFilterException.OrchestrationInputFilterException( + "Content filtered out due to policy restrictions in the input filtering module.", + e, + filerDetails); + } + throw e; } } @@ -76,7 +96,7 @@ Stream stream(@Nonnull final Object payload) { return new ClientStreamingHandler<>( OrchestrationChatCompletionDelta.class, OrchestrationError.class, - OrchestrationClientException::new) + new OrchestrationExceptionFactory()) .objectMapper(JACKSON) .handleStreamingResponse(client.executeOpen(null, request, null)); @@ -86,6 +106,25 @@ Stream stream(@Nonnull final Object payload) { } catch (IOException e) { throw new OrchestrationClientException( "Streaming request to the Orchestration service failed", e); + } catch (OrchestrationClientException e) { + if (e.getClientError() + .getOriginalResponse() + .getLocation() + .equals("Filtering Module - Input Filter")) { + + final var filerDetails = + (Map) + e.getClientError() + .getOriginalResponse() + .getModuleResults() + .getInputFiltering() + .getData(); + throw new OrchestrationFilterException.OrchestrationInputFilterException( + "Content filtered out due to policy restrictions in the input filtering module.", + e, + filerDetails); + } + throw e; } } diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index 7603f6a69..94c22baae 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -159,7 +159,7 @@ void testCompletionError() { assertThatThrownBy(() -> client.chatCompletion(prompt, config)) .hasMessage( - "Request failed with status 500 Server Error and error message: 'Internal Server Error located in Masking Module - Masking'"); + "Request failed with status 500 Server Error: Internal Server Error located in Masking Module - Masking"); } @Test @@ -414,7 +414,7 @@ void filteringStrict() { assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter)) .isInstanceOf(OrchestrationClientException.class) .hasMessage( - "Request failed with status 400 Bad Request and error message: 'Content filtered due to Safety violations. Please modify the prompt and try again.'"); + "Request failed with status 400 Bad Request: Content filtered due to Safety violations. Please modify the prompt and try again."); } @Test From 485482fe17e56f22b4f286c7d1433fa8a3003e1d Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 14 Jul 2025 15:10:17 +0200 Subject: [PATCH 07/30] Update tests and add better javadocs --- .../ai/sdk/core/common/ClientException.java | 5 + .../core/common/ClientExceptionFactory.java | 25 +++- .../core/common/ClientResponseHandler.java | 17 ++- .../common/ClientResponseHandlerTest.java | 8 +- .../common/ClientStreamingHandlerTest.java | 110 ++++++++++++++++++ .../common/IterableStreamConverterTest.java | 9 +- .../openai/OpenAiExceptionFactory.java | 2 + .../OrchestrationChatResponse.java | 3 +- .../orchestration/OrchestrationClient.java | 6 +- .../OrchestrationExceptionFactory.java | 27 ++++- .../OrchestrationFilterException.java | 37 +++++- .../OrchestrationHttpExecutor.java | 39 ------- .../orchestration/OrchestrationUnitTest.java | 19 ++- .../app/controllers/OrchestrationTest.java | 18 +-- 14 files changed, 247 insertions(+), 78 deletions(-) create mode 100644 core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java index da76d57da..53199756f 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java @@ -13,6 +13,11 @@ @Beta @StandardException public class ClientException extends RuntimeException { + + /** + * Wraps a structured error payload received from the remote service, if available. This can be + * used to extract more detailed error information. + */ @Nullable @Getter(onMethod_ = @Beta) public ClientError clientError; diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java index 82ab8cd33..013c61b59 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java @@ -4,15 +4,34 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -/** Knows how to turn a 4xx/5xx HTTP response into a ClientException (and subtype). */ +/** + * A factory whose implementations can provide customized exception types and error mapping logic + * for different service clients or error scenarios. + * + * @param The subtype of {@link ClientException} to be created by this factory. + * @param The subtype of {@link ClientError} payload that can be processed by this factory. + */ @Beta public interface ClientExceptionFactory { - /** Creates a “base” exception with a message and optional cause. */ + /** + * Creates an exception with a message and optional cause. + * + * @param message A descriptive message for the exception. + * @param cause An optional cause of the exception, can be null if not applicable. + * @return An instance of the specified {@link ClientException} type + */ + @Nonnull E create(@Nonnull final String message, @Nullable final Throwable cause); /** - * Inspect the HTTP response, attempt to deserialize a JSON error payload, and return either the + * Creates an exception from a given message and an HTTP error response that has been successfully + * deserialized into a {@link ClientError} object. + * + * @param message A descriptive message for the exception. + * @param clientError The structured {@link ClientError} object deserialized from the response. + * @return An instance of the specified {@link ClientException} type */ + @Nonnull E fromClientError(@Nonnull final String message, @Nonnull final R clientError); } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index db5f76ce1..03d658f50 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -20,9 +20,9 @@ /** * Parse incoming JSON responses and handles any errors. For internal use only. * - * @param The type of the response. + * @param The type of the successful response. * @param The type of the exception to throw. - * @param The type of the error. + * @param The type of the error response. * @since 1.1.0 */ @Beta @@ -30,8 +30,13 @@ @RequiredArgsConstructor public class ClientResponseHandler implements HttpClientResponseHandler { + /** The HTTP success response type */ @Nonnull protected final Class successType; + + /** The HTTP error response type */ @Nonnull protected final Class errorType; + + /** The factory to create exceptions for Http 4xx/5xx responses. */ @Nonnull protected final ClientExceptionFactory exceptionFactory; /** The parses for JSON responses, will be private once we can remove mixins */ @@ -68,7 +73,7 @@ public T handleResponse(@Nonnull final ClassicHttpResponse response) { // The InputStream of the HTTP entity is closed by EntityUtils.toString @SuppressWarnings("PMD.CloseResource") @Nonnull - private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E{ + private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { final HttpEntity responseEntity = response.getEntity(); if (responseEntity == null) { throw exceptionFactory.create("Response was empty.", null); @@ -96,8 +101,10 @@ private Try tryGetContent(@Nonnull final HttpEntity entity) { * * @param httpResponse The response to process * @throws ClientException if the response is an error (4xx/5xx) + * @return An instance of the specific exception type returned by exceptionFactory */ @SuppressWarnings("PMD.CloseResource") + @Nonnull protected E buildException(@Nonnull final ClassicHttpResponse httpResponse) { val baseErrorMessage = "Request failed with status %d %s" @@ -128,8 +135,8 @@ protected E buildException(@Nonnull final ClassicHttpResponse httpResponse) { baseException.addSuppressed(maybeClientError.getCause()); return baseException; } - R clientError = maybeClientError.get(); - var extendErrorMessage = "%s: %s".formatted(baseErrorMessage, clientError.getMessage()); + final R clientError = maybeClientError.get(); + val extendErrorMessage = "%s: %s".formatted(baseErrorMessage, clientError.getMessage()); return exceptionFactory.fromClientError(extendErrorMessage, clientError); } } diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java index 2ed900df0..35d81815a 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonParseException; import java.io.IOException; +import javax.annotation.Nonnull; import lombok.Data; import lombok.SneakyThrows; import lombok.experimental.StandardException; @@ -16,7 +17,6 @@ import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; class ClientResponseHandlerTest { @@ -33,13 +33,15 @@ static class MyError implements ClientError { static class MyException extends ClientException {} static class MyExceptionFactory implements ClientExceptionFactory { + @Nonnull @Override - public MyException create(@NotNull String message, Throwable cause) { + public MyException create(@Nonnull String message, Throwable cause) { return new MyException(message, cause); } + @Nonnull @Override - public MyException fromClientError(@NotNull String message, @NotNull MyError clientError) { + public MyException fromClientError(@Nonnull String message, @Nonnull MyError clientError) { var ex = new MyException(message); ex.clientError = clientError; return ex; diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java new file mode 100644 index 000000000..78889cb7b --- /dev/null +++ b/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java @@ -0,0 +1,110 @@ +package com.sap.ai.sdk.core.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonParseException; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Data; +import lombok.SneakyThrows; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ClientStreamingHandler}. Inherits common test utilities and mock classes from + * ClientResponseHandlerTest. + */ +class ClientStreamingHandlerTest extends ClientResponseHandlerTest { + + @Data + static class MyStreamedDelta implements StreamedDelta { + @JsonProperty("value") + private String value; // Simulates the content + + @JsonProperty("finish_reason") + private String finishReason; + + @Nonnull + @Override + public String getDeltaContent() { + return value != null ? value : ""; + } + + @Nullable + @Override + public String getFinishReason() { + return finishReason; + } + } + + @SneakyThrows + @Test + void testHandleStreamingResponse_variousScenarios() { + var sut = + new ClientStreamingHandler<>( + MyStreamedDelta.class, MyError.class, new MyExceptionFactory()); + + final String validStreamContent = + """ + data: {"value":"delta1"} + + data: {"value":"delta2", "finish_reason": "length"} + data: [DONE] + """; + + final String emptyStreamContent = + """ + data: [DONE] + + """; + + final String malformedLineContent = + """ + data: {"value":"deltaA"} + malformed line here + data: {"value":"deltaB"} + data: [DONE] + """; + + final String invalidJsonContent = + """ + data: {"value":"deltaX"} + data: {"value"-"deltaY"} + data: [DONE] + """; + + var response = spy(new BasicClassicHttpResponse(200, "OK")); + when(response.getEntity()) + .thenReturn(new StringEntity(validStreamContent)) + .thenReturn(new StringEntity(emptyStreamContent)) + .thenReturn(new StringEntity(malformedLineContent)) + .thenReturn(new StringEntity(invalidJsonContent)); + + var stream1 = sut.handleStreamingResponse(response); + var deltas1 = stream1.toList(); + assertThat(deltas1).hasSize(2); + assertThat(deltas1.get(0).getDeltaContent()).isEqualTo("delta1"); + assertThat(deltas1.get(0).getFinishReason()).isNull(); + assertThat(deltas1.get(1).getDeltaContent()).isEqualTo("delta2"); + assertThat(deltas1.get(1).getFinishReason()).isEqualTo("length"); + + var stream2 = sut.handleStreamingResponse(response); + assertThat(stream2).isEmpty(); + + var stream3 = sut.handleStreamingResponse(response); + assertThatThrownBy(() -> stream3.toList()) + .isInstanceOf(MyException.class) + .hasMessageContaining("Failed to parse response: malformed line here"); + + var stream4 = sut.handleStreamingResponse(response); + assertThatThrownBy(() -> stream4.toList()) + .isInstanceOf(MyException.class) + .hasMessageContaining("Failed to parse delta message:") + .hasCauseInstanceOf(JsonParseException.class); + } +} diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java index 263495c81..87d456f9a 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java @@ -17,11 +17,11 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nonnull; import lombok.SneakyThrows; import lombok.experimental.StandardException; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.InputStreamEntity; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -112,13 +112,16 @@ public static class TestClientException extends ClientException {} static class TestClientExceptionFactory implements ClientExceptionFactory { + @Nonnull @Override - public TestClientException create(@NotNull String message, Throwable cause) { + public TestClientException create(@Nonnull String message, Throwable cause) { return new TestClientException(message, cause); } + @Nonnull @Override - public TestClientException fromClientError(@NotNull String message, @NotNull ClientError clientError) { + public TestClientException fromClientError( + @Nonnull String message, @Nonnull ClientError clientError) { TestClientException exception = new TestClientException(message); exception.clientError = clientError; return exception; diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java index 0d720b81a..e3a5e85c8 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java @@ -8,11 +8,13 @@ @Beta class OpenAiExceptionFactory implements ClientExceptionFactory { + @Nonnull public OpenAiClientException create( @Nonnull final String message, @Nullable final Throwable cause) { return new OpenAiClientException(message, cause); } + @Nonnull @Override public OpenAiClientException fromClientError( @Nonnull final String message, @Nonnull final OpenAiError openAiError) { diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 7805588f2..2c9b4f6c7 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -46,8 +46,7 @@ public String getContent() throws OrchestrationFilterException { getOriginalResponse().getModuleResults().getOutputFiltering().getData(); throw new OrchestrationFilterException.OrchestrationOutputFilterException( - "Content filtered out due to policy restrictions in the output filtering module.", - filterDetails); + "Content filter filtered the output.", filterDetails); } return choice.getMessage().getContent(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 0f07c291f..f06a00205 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -15,6 +15,7 @@ import com.sap.ai.sdk.orchestration.model.ModuleConfigs; import com.sap.ai.sdk.orchestration.model.OrchestrationConfig; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.util.Map; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -113,7 +114,10 @@ public Stream streamChatCompletion( private static void throwOnContentFilter(@Nonnull final OrchestrationChatCompletionDelta delta) { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { - throw new OrchestrationClientException("Content filter filtered the output."); + final var filterDetails = + (Map) delta.getModuleResults().getOutputFiltering().getData(); + throw new OrchestrationFilterException.OrchestrationOutputFilterException( + "Content filter filtered the output.", filterDetails); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 1cb47bf7f..08cce4a57 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -2,6 +2,9 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -9,14 +12,34 @@ class OrchestrationExceptionFactory implements ClientExceptionFactory { + @Nonnull public OrchestrationClientException create( @Nonnull final String message, @Nullable final Throwable cause) { return new OrchestrationClientException(message, cause); } + @Nonnull @Override public OrchestrationClientException fromClientError( - @Nonnull final String message, @Nonnull final OrchestrationError orchestrationError) { - return new OrchestrationClientException(message, orchestrationError); + @Nonnull final String message, @Nonnull final OrchestrationError clientError) { + + final var inputFilterDetails = extractInputFilterDetails(clientError); + if (!inputFilterDetails.isEmpty()) { + return new OrchestrationFilterException.OrchestrationInputFilterException( + message, clientError, inputFilterDetails); + } + + return new OrchestrationClientException(message, clientError); + } + + private Map extractInputFilterDetails(@Nonnull final OrchestrationError error) { + + return Optional.ofNullable(error.getOriginalResponse()) + .flatMap(resp -> Optional.ofNullable(resp.getModuleResults())) + .flatMap(mr -> Optional.ofNullable(mr.getInputFiltering())) + .flatMap(iflt -> Optional.ofNullable(iflt.getData())) + .filter(Map.class::isInstance) + .map(map -> (Map) map) + .orElseGet(Collections::emptyMap); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index fb778bab6..f38f1c6bd 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -1,25 +1,54 @@ package com.sap.ai.sdk.orchestration; +import com.google.common.annotations.Beta; import java.util.Map; import javax.annotation.Nonnull; +import lombok.AccessLevel; import lombok.Getter; import lombok.experimental.StandardException; -@StandardException +/** + * Exception thrown when an error occurs during orchestration filtering. + * + *

This exception serves as the base for more specific filter-related exceptions in the + * orchestration process. + */ +@Beta +@StandardException(access = AccessLevel.PRIVATE) public class OrchestrationFilterException extends OrchestrationClientException { + /** Details about the filter that caused the exception. */ @Getter @Nonnull protected Map filterDetails; + /** Exception thrown when an error occurs during input filtering in orchestration. */ public static class OrchestrationInputFilterException extends OrchestrationFilterException { + /** + * Constructs a new OrchestrationInputFilterException. + * + * @param message the detail message + * @param clientError the specific client error + * @param filterDetails details about the filter that caused the exception + */ OrchestrationInputFilterException( - String message, Throwable cause, Map filterDetails) { - super(message, cause); + @Nonnull final String message, + @Nonnull final OrchestrationError clientError, + @Nonnull final Map filterDetails) { + super(message); + this.clientError = clientError; this.filterDetails = filterDetails; } } + /** Exception thrown output filtering in orchestration when finish reason is content filter */ public static class OrchestrationOutputFilterException extends OrchestrationFilterException { - OrchestrationOutputFilterException(String message, Map filterDetails) { + /** + * Constructs a new OrchestrationOutputFilterException. + * + * @param message the detail message + * @param filterDetails details about the filter that caused the exception + */ + OrchestrationOutputFilterException( + @Nonnull final String message, @Nonnull final Map filterDetails) { super(message); this.filterDetails = filterDetails; } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java index 34523bde4..6d287da21 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java @@ -13,7 +13,6 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException; import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException; import java.io.IOException; -import java.util.Map; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -62,25 +61,6 @@ responseType, OrchestrationError.class, new OrchestrationExceptionFactory()) | IOException e) { throw new OrchestrationClientException( "Request to Orchestration service failed for " + path, e); - } catch (OrchestrationClientException e) { - if (e.getClientError() - .getOriginalResponse() - .getLocation() - .equals("Filtering Module - Input Filter")) { - - final var filerDetails = - (Map) - e.getClientError() - .getOriginalResponse() - .getModuleResults() - .getInputFiltering() - .getData(); - throw new OrchestrationFilterException.OrchestrationInputFilterException( - "Content filtered out due to policy restrictions in the input filtering module.", - e, - filerDetails); - } - throw e; } } @@ -106,25 +86,6 @@ Stream stream(@Nonnull final Object payload) { } catch (IOException e) { throw new OrchestrationClientException( "Streaming request to the Orchestration service failed", e); - } catch (OrchestrationClientException e) { - if (e.getClientError() - .getOriginalResponse() - .getLocation() - .equals("Filtering Module - Input Filter")) { - - final var filerDetails = - (Map) - e.getClientError() - .getOriginalResponse() - .getModuleResults() - .getInputFiltering() - .getData(); - throw new OrchestrationFilterException.OrchestrationInputFilterException( - "Content filtered out due to policy restrictions in the input filtering module.", - e, - filerDetails); - } - throw e; } } diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index 94c22baae..cf82cde51 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -62,6 +62,7 @@ import com.sap.ai.sdk.orchestration.model.KeyValueListPair; import com.sap.ai.sdk.orchestration.model.LlamaGuard38b; import com.sap.ai.sdk.orchestration.model.MaskingModuleConfig; +import com.sap.ai.sdk.orchestration.model.ModuleResultsStreaming; import com.sap.ai.sdk.orchestration.model.ResponseFormatText; import com.sap.ai.sdk.orchestration.model.SearchDocumentKeyValueListPair; import com.sap.ai.sdk.orchestration.model.SearchSelectOptionEnum; @@ -362,7 +363,7 @@ void testBadRequest() { assertThatThrownBy(() -> client.chatCompletion(prompt, config)) .isInstanceOf(OrchestrationClientException.class) .hasMessage( - "Request failed with status 400 Bad Request and error message: 'Missing required parameters: ['input']'"); + "Request failed with status 400 Bad Request: Missing required parameters: ['input']"); } @Test @@ -412,7 +413,7 @@ void filteringStrict() { final var configWithFilter = config.withInputFiltering(filter).withOutputFiltering(filter); assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter)) - .isInstanceOf(OrchestrationClientException.class) + .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessage( "Request failed with status 400 Bad Request: Content filtered due to Safety violations. Please modify the prompt and try again."); } @@ -626,12 +627,22 @@ void testThrowsOnContentFilter() { var deltaWithContentFilter = mock(OrchestrationChatCompletionDelta.class); when(deltaWithContentFilter.getFinishReason()).thenReturn("content_filter"); + + var moduleResults = mock(ModuleResultsStreaming.class); + when(deltaWithContentFilter.getModuleResults()).thenReturn(moduleResults); + + var outputFiltering = mock(GenericModuleResult.class); + when(moduleResults.getOutputFiltering()).thenReturn(outputFiltering); + + var filterDetails = Map.of("azure_content_safety", Map.of("Hate", 0, "SelfHarm", 0)); + when(outputFiltering.getData()).thenReturn(filterDetails); + when(mock.streamChatCompletionDeltas(any())).thenReturn(Stream.of(deltaWithContentFilter)); // this must not throw, since the stream is lazily evaluated var stream = mock.streamChatCompletion(new OrchestrationPrompt(""), config); assertThatThrownBy(stream::toList) - .isInstanceOf(OrchestrationClientException.class) + .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) .hasMessageContaining("Content filter"); } @@ -653,7 +664,7 @@ void streamChatCompletionOutputFilterErrorHandling() throws IOException { try (Stream stream = client.streamChatCompletion(prompt, config)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) - .isInstanceOf(OrchestrationClientException.class) + .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) .hasMessage("Content filter filtered the output."); } diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 5b63ef1ed..06327ccf7 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -215,15 +215,9 @@ void testInputFilteringStrict() { var policy = AzureFilterThreshold.ALLOW_SAFE; assertThatThrownBy(() -> service.inputFiltering(policy)) - .isInstanceOf(OrchestrationClientException.class) + .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessageContaining( - "Content filtered out due to policy restrictions in the input filtering module."); - - try { - service.inputFiltering(policy); - } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { - ((OrchestrationClientException) e.getCause()).getClientError(); - } + "Request failed with status 400 Bad Request: 400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again."); } @Test @@ -245,7 +239,7 @@ void testOutputFilteringStrict() { var response = service.outputFiltering(policy); assertThatThrownBy(response::getContent) - .isInstanceOf(OrchestrationClientException.class) + .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) .hasMessageContaining("Content filter filtered the output."); } @@ -265,9 +259,9 @@ void testOutputFilteringLenient() { @Test void testLlamaGuardEnabled() { assertThatThrownBy(() -> service.llamaGuardInputFilter(true)) - .isInstanceOf(OrchestrationClientException.class) + .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessageContaining( - "Prompt filtered due to safety violations. Please modify the prompt and try again.") + "Request failed with status 400 Bad Request: 400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again.") .hasMessageContaining("400 Bad Request"); } @@ -394,7 +388,7 @@ void testStreamingErrorHandlingInputFilter() { val configWithFilter = config.withInputFiltering(filterConfig); assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithFilter)) - .isInstanceOf(OrchestrationClientException.class) + .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessageContaining("status 400 Bad Request") .hasMessageContaining("Filtering Module - Input Filter"); } From f6f3528e8a8e4d9c47c38f7b58d15e16a67bd2e5 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 14 Jul 2025 17:43:01 +0200 Subject: [PATCH 08/30] Updating error messages - remove crafty object mapping in response handler --- .../core/common/ClientResponseHandler.java | 2 +- .../openai/BaseOpenAiClientTest.java | 2 +- .../openai/OpenAiClientGeneratedTest.java | 3 +- .../openai/OpenAiClientTest.java | 3 +- .../OrchestrationClientException.java | 16 ++++ .../OrchestrationExceptionFactory.java | 7 +- .../orchestration/OrchestrationUnitTest.java | 40 ++++++++-- .../__files/outputFilteringStrict.json | 73 +++++++++++++++++++ .../controllers/OrchestrationController.java | 24 +++--- .../app/controllers/OrchestrationTest.java | 5 +- 10 files changed, 151 insertions(+), 24 deletions(-) create mode 100644 orchestration/src/test/resources/__files/outputFilteringStrict.json diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 03d658f50..1b9cc7a89 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -31,7 +31,7 @@ public class ClientResponseHandler implements HttpClientResponseHandler { /** The HTTP success response type */ - @Nonnull protected final Class successType; + @Nonnull final Class successType; /** The HTTP error response type */ @Nonnull protected final Class errorType; diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java index 122b074d7..4ef720efa 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java @@ -123,7 +123,7 @@ static void assertForErrorHandling(@Nonnull final Runnable request) { .assertThatThrownBy(request::run) .describedAs("Error objects from OpenAI should be interpreted") .isInstanceOf(OpenAiClientException.class) - .hasMessageContaining("error message: 'foo'"); + .hasMessageContaining("400 Bad Request: foo"); softly .assertThatThrownBy(request::run) diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java index 527ad9f36..6c177b608 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java @@ -298,7 +298,8 @@ void streamChatCompletionDeltasErrorHandling() throws IOException { try (var stream = client.streamChatCompletionDeltas(request)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) .isInstanceOf(OpenAiClientException.class) - .hasMessage("Failed to parse response and error message: 'exceeded token rate limit'"); + .hasMessage( + "Failed to parse response: {\"error\":{\"code\":\"429\",\"message\":\"exceeded token rate limit\"}}"); } Mockito.verify(inputStream, times(1)).close(); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java index eb57255a2..1f5587931 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java @@ -261,7 +261,8 @@ void streamChatCompletionDeltasErrorHandling() throws IOException { try (var stream = client.streamChatCompletionDeltas(request)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) .isInstanceOf(OpenAiClientException.class) - .hasMessage("Failed to parse response and error message: 'exceeded token rate limit'"); + .hasMessage( + "Failed to parse response: {\"error\":{\"code\":\"429\",\"message\":\"exceeded token rate limit\"}}"); } Mockito.verify(inputStream, times(1)).close(); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index 7f783f10d..382c744dc 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -2,6 +2,8 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientException; +import com.sap.ai.sdk.orchestration.model.ErrorResponse; +import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.experimental.StandardException; @@ -22,4 +24,18 @@ public class OrchestrationClientException extends ClientException { public OrchestrationError getClientError() { return (OrchestrationError) super.getClientError(); } + + /** + * Retrieves the HTTP status code from the original error response, if available. + * + * @return the HTTP status code, or {@code null} if not available + */ + @Beta + @Nullable + public Integer getStatusCode() { + return Optional.ofNullable(getClientError()) + .map(OrchestrationError::getOriginalResponse) + .map(ErrorResponse::getCode) + .orElse(null); + } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 08cce4a57..1c9b06e0b 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -32,12 +32,13 @@ public OrchestrationClientException fromClientError( return new OrchestrationClientException(message, clientError); } + @Nonnull private Map extractInputFilterDetails(@Nonnull final OrchestrationError error) { return Optional.ofNullable(error.getOriginalResponse()) - .flatMap(resp -> Optional.ofNullable(resp.getModuleResults())) - .flatMap(mr -> Optional.ofNullable(mr.getInputFiltering())) - .flatMap(iflt -> Optional.ofNullable(iflt.getData())) + .flatMap(response -> Optional.ofNullable(response.getModuleResults())) + .flatMap(moduleResults -> Optional.ofNullable(moduleResults.getInputFiltering())) + .flatMap(inputFiltering -> Optional.ofNullable(inputFiltering.getData())) .filter(Map.class::isInstance) .map(map -> (Map) map) .orElseGet(Collections::emptyMap); diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index cf82cde51..f9bfe1b32 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -397,7 +397,7 @@ void filteringLoose() throws IOException { } @Test - void filteringStrict() { + void inputFilteringStrict() { 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."}}}}"""; @@ -410,12 +410,35 @@ void filteringStrict() { .sexual(ALLOW_SAFE) .violence(ALLOW_SAFE); - final var configWithFilter = config.withInputFiltering(filter).withOutputFiltering(filter); + final var configWithFilter = config.withInputFiltering(filter); assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter)) .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessage( - "Request failed with status 400 Bad Request: Content filtered due to Safety violations. Please modify the prompt and try again."); + "Request failed with status 400 Bad Request: Content filtered due to Safety violations. Please modify the prompt and try again.") + .extracting( + e -> + ((OrchestrationFilterException.OrchestrationInputFilterException) e) + .getStatusCode()) + .isEqualTo(SC_BAD_REQUEST); + } + + @Test + void outputFilteringStrict() { + stubFor(post(anyUrl()).willReturn(aResponse().withBodyFile("outputFilteringStrict.json"))); + + final var filter = + new AzureContentFilter() + .hate(ALLOW_SAFE) + .selfHarm(ALLOW_SAFE) + .sexual(ALLOW_SAFE) + .violence(ALLOW_SAFE); + + final var configWithFilter = config.withOutputFiltering(filter); + + assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter).getContent()) + .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) + .hasMessage("Content filter filtered the output."); } @Test @@ -634,16 +657,21 @@ void testThrowsOnContentFilter() { var outputFiltering = mock(GenericModuleResult.class); when(moduleResults.getOutputFiltering()).thenReturn(outputFiltering); - var filterDetails = Map.of("azure_content_safety", Map.of("Hate", 0, "SelfHarm", 0)); + var filterDetails = Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0)); when(outputFiltering.getData()).thenReturn(filterDetails); when(mock.streamChatCompletionDeltas(any())).thenReturn(Stream.of(deltaWithContentFilter)); // this must not throw, since the stream is lazily evaluated var stream = mock.streamChatCompletion(new OrchestrationPrompt(""), config); - assertThatThrownBy(stream::toList) + assertThatThrownBy(() -> stream.toList()) .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) - .hasMessageContaining("Content filter"); + .hasMessage("Content filter filtered the output.") + .extracting( + e -> + ((OrchestrationFilterException.OrchestrationOutputFilterException) e) + .getFilterDetails()) + .isEqualTo(Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0))); } @Test diff --git a/orchestration/src/test/resources/__files/outputFilteringStrict.json b/orchestration/src/test/resources/__files/outputFilteringStrict.json new file mode 100644 index 000000000..667b11ef0 --- /dev/null +++ b/orchestration/src/test/resources/__files/outputFilteringStrict.json @@ -0,0 +1,73 @@ +{ + "request_id": "4868def8-d509-4697-9de0-5920d69d06da", + "module_results": { + "templating": [ + { + "role": "system", + "content": "Give three paraphrases for the following sentence" + }, + { + "content": "'We shall spill blood tonight', said the operation in-charge.", + "role": "user" + } + ], + "output_filtering": { + "message": "1 of 1 choices failed the output filter.", + "data": { + "choices": [ + { + "index": 0, + "azure_content_safety": { + "Hate": 0, + "SelfHarm": 0, + "Sexual": 0, + "Violence": 4 + } + } + ] + } + }, + "llm": { + "id": "", + "object": "chat.completion", + "created": 1752500843, + "model": "gemini-1.5-flash", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "1. \"There will be bloodshed tonight,\" the operation leader announced.\n2. The head of the operation declared, \"Tonight, we will have casualties.\"\n3. \"Tonight's operation will result in fatalities,\" the commander stated grimly.\n" + }, + "finish_reason": "stop" + } + ], + "usage": { + "completion_tokens": 53, + "prompt_tokens": 22, + "total_tokens": 75 + } + } + }, + "orchestration_result": { + "id": "", + "object": "chat.completion", + "created": 1752500843, + "model": "gemini-1.5-flash", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "" + }, + "finish_reason": "content_filter" + } + ], + "usage": { + "completion_tokens": 53, + "prompt_tokens": 22, + "total_tokens": 75 + } + } +} diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index a9e404b19..f5c8900f8 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -5,7 +5,7 @@ import com.sap.ai.sdk.app.services.OrchestrationService; import com.sap.ai.sdk.orchestration.AzureFilterThreshold; import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; -import com.sap.ai.sdk.orchestration.OrchestrationClientException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException; import com.sap.ai.sdk.orchestration.model.DPIEntities; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; @@ -124,8 +124,10 @@ Object inputFiltering( final OrchestrationChatResponse response; try { response = service.inputFiltering(policy); - } catch (OrchestrationClientException e) { - final var msg = "Failed to obtain a response as the content was flagged by input filter."; + } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { + final var msg = + "Failed to obtain a %d response as the content was flagged by input filter." + .formatted(e.getStatusCode()); log.debug(msg, e); return ResponseEntity.internalServerError().body(msg); } @@ -142,10 +144,12 @@ Object outputFiltering( @Nullable @RequestParam(value = "format", required = false) final String format, @Nonnull @PathVariable("policy") final AzureFilterThreshold policy) { - final OrchestrationChatResponse response; + final var response = service.outputFiltering(policy); + + final String content; try { - response = service.outputFiltering(policy); - } catch (OrchestrationClientException e) { + content = response.getContent(); + } catch (OrchestrationFilterException.OrchestrationOutputFilterException e) { final var msg = "Failed to obtain a response as the content was flagged by output filter."; log.debug(msg, e); return ResponseEntity.internalServerError().body(msg); @@ -154,7 +158,7 @@ Object outputFiltering( if ("json".equals(format)) { return response; } - return response.getContent(); + return content; } @GetMapping("/llamaGuardFilter/{enabled}") @@ -166,8 +170,10 @@ Object llamaGuardInputFiltering( final OrchestrationChatResponse response; try { response = service.llamaGuardInputFilter(enabled); - } catch (OrchestrationClientException e) { - final var msg = "Failed to obtain a response as the content was flagged by input filter."; + } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { + final var msg = + "Failed to obtain a %d response as the content was flagged by input filter." + .formatted(e.getStatusCode()); log.debug(msg, e); return ResponseEntity.internalServerError().body(msg); } diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 06327ccf7..1fe61ecce 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -217,7 +217,8 @@ void testInputFilteringStrict() { assertThatThrownBy(() -> service.inputFiltering(policy)) .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessageContaining( - "Request failed with status 400 Bad Request: 400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again."); + "Prompt filtered due to safety violations. Please modify the prompt and try again.") + .hasMessageContaining("400 Bad Request"); } @Test @@ -261,7 +262,7 @@ void testLlamaGuardEnabled() { assertThatThrownBy(() -> service.llamaGuardInputFilter(true)) .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessageContaining( - "Request failed with status 400 Bad Request: 400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again.") + "Prompt filtered due to safety violations. Please modify the prompt and try again.") .hasMessageContaining("400 Bad Request"); } From d22b7159210815d5e267347d0d017813b36d89c8 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 21 Jul 2025 18:10:08 +0200 Subject: [PATCH 09/30] Integrating charles suggestions and extend unit tests --- .../core/common/ClientExceptionFactory.java | 4 +- .../core/common/ClientResponseHandler.java | 70 +++++++++++------ .../core/common/ClientStreamingHandler.java | 6 +- .../core/common/IterableStreamConverter.java | 6 +- .../common/ClientResponseHandlerTest.java | 38 +++++++--- .../common/IterableStreamConverterTest.java | 6 +- .../openai/OpenAiExceptionFactory.java | 4 +- .../openai/BaseOpenAiClientTest.java | 4 +- .../ai/sdk/orchestration/ContentFilter.java | 3 + .../OrchestrationChatResponse.java | 15 ++-- .../orchestration/OrchestrationClient.java | 12 ++- .../OrchestrationExceptionFactory.java | 8 +- .../OrchestrationFilterException.java | 2 +- .../orchestration/OrchestrationUnitTest.java | 75 ++++++++++++------- .../__files/strictInputFilterResponse.json | 25 +++++++ .../controllers/OrchestrationController.java | 44 ++++++++--- .../app/controllers/OrchestrationTest.java | 45 ++++++++--- 17 files changed, 259 insertions(+), 108 deletions(-) create mode 100644 orchestration/src/test/resources/__files/strictInputFilterResponse.json diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java index 013c61b59..991c11c52 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java @@ -22,7 +22,7 @@ public interface ClientExceptionFactory objectMapper(@Nonnull final ObjectMapper jackson) { - this.objectMapper = jackson; + objectMapper = jackson; return this; } @@ -60,12 +62,13 @@ public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper j * * @param response The response to process * @return A model class instantiated from the response + * @throws E in case of a problem or the connection was aborted */ @Nonnull @Override - public T handleResponse(@Nonnull final ClassicHttpResponse response) { + public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { if (response.getCode() >= 300) { - throw buildException(response); + buildAndThrowException(response); } return parseSuccess(response); } @@ -76,18 +79,18 @@ public T handleResponse(@Nonnull final ClassicHttpResponse response) { private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { final HttpEntity responseEntity = response.getEntity(); if (responseEntity == null) { - throw exceptionFactory.create("Response was empty.", null); + throw exceptionFactory.build("The HTTP Response is empty", null); } val content = tryGetContent(responseEntity) - .getOrElseThrow(e -> exceptionFactory.create("Failed to parse response entity.", e)); + .getOrElseThrow(e -> exceptionFactory.build("Failed to parse response entity.", e)); log.debug("Parsing response from JSON response: {}", content); try { return objectMapper.readValue(content, successType); } catch (final JsonProcessingException e) { log.error("Failed to parse the following response: {}", content); - throw exceptionFactory.create("Failed to parse response:", e); + throw exceptionFactory.build("Failed to parse response:", e); } } @@ -97,46 +100,71 @@ private Try tryGetContent(@Nonnull final HttpEntity entity) { } /** - * Parse the error response and throw an exception. + * Process the error response and throw an exception. * * @param httpResponse The response to process * @throws ClientException if the response is an error (4xx/5xx) - * @return An instance of the specific exception type returned by exceptionFactory */ @SuppressWarnings("PMD.CloseResource") @Nonnull - protected E buildException(@Nonnull final ClassicHttpResponse httpResponse) { - val baseErrorMessage = - "Request failed with status %d %s" - .formatted(httpResponse.getCode(), httpResponse.getReasonPhrase()); - val baseException = exceptionFactory.create(baseErrorMessage, null); + protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { val entity = httpResponse.getEntity(); if (entity == null) { - return baseException; + val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); + throw exceptionFactory.build(message, null); } val maybeContent = tryGetContent(entity); if (maybeContent.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to read the response content"); + val baseException = exceptionFactory.build(message, null); baseException.addSuppressed(maybeContent.getCause()); - return baseException; + throw baseException; } val content = maybeContent.get(); - if (content.isBlank()) { - return baseException; + if (content == null || content.isBlank()) { + val message = getErrorMessage(httpResponse, "Empty or blank response content"); + throw exceptionFactory.build(message, null); } log.error("The service responded with an HTTP error and the following content: {}", content); val contentType = ContentType.parse(entity.getContentType()); if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - return baseException; + val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); + throw exceptionFactory.build(message, null); } + + parseErrorResponseAndThrow(content, httpResponse); + } + + /** + * Parses the JSON content of an error response and throws a module specific exception. + * + * @param content The JSON content of the error response. + * @param httpResponse The HTTP response that contains the error. + * @throws ClientException if the response is an error (4xx/5xx) + */ + protected void parseErrorResponseAndThrow( + @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); if (maybeClientError.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); + val baseException = exceptionFactory.build(message, null); baseException.addSuppressed(maybeClientError.getCause()); - return baseException; + throw baseException; } final R clientError = maybeClientError.get(); - val extendErrorMessage = "%s: %s".formatted(baseErrorMessage, clientError.getMessage()); - return exceptionFactory.fromClientError(extendErrorMessage, clientError); + val message = getErrorMessage(httpResponse, clientError.getMessage()); + throw exceptionFactory.buildFromClientError(message, clientError); + } + + private static String getErrorMessage( + @Nonnull final ClassicHttpResponse httpResponse, @Nullable final String additionalMessage) { + val baseErrorMessage = + "Request failed with status %d (%s)" + .formatted(httpResponse.getCode(), httpResponse.getReasonPhrase()); + + val message = Optional.ofNullable(additionalMessage).orElse(""); + return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); } } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java index c0a97b636..2477632cb 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java @@ -60,7 +60,7 @@ public ClientStreamingHandler( @Nonnull public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse response) throws E { if (response.getCode() >= 300) { - throw super.buildException(response); + super.buildAndThrowException(response); } return IterableStreamConverter.lines(response.getEntity(), exceptionFactory) @@ -70,7 +70,7 @@ public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse resp line -> { if (!line.startsWith("data: ")) { final String msg = "Failed to parse response: %s".formatted(line); - throw exceptionFactory.create(msg, null); + throw exceptionFactory.build(msg, null); } }) .map( @@ -81,7 +81,7 @@ public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse resp } catch (final IOException e) { // exception message e gets lost log.error("Failed to parse the following response: {}", line); final String msg = "Failed to parse delta message: %s".formatted(line); - throw exceptionFactory.create(msg, e); + throw exceptionFactory.build(msg, e); } }); } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java b/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java index 953b1160f..612dfb789 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java @@ -101,21 +101,21 @@ static Stream lines( @Nonnull final ClientExceptionFactory exceptionFactory) throws ClientException { if (entity == null) { - throw exceptionFactory.create("Orchestration service response was empty.", null); + throw exceptionFactory.build("The HTTP Response is empty", null); } final InputStream inputStream; try { inputStream = entity.getContent(); } catch (final IOException e) { - throw exceptionFactory.create("Failed to read response content.", e); + throw exceptionFactory.build("Failed to read response content.", e); } final var reader = new BufferedReader(new InputStreamReader(inputStream, UTF_8), BUFFER_SIZE); final Runnable closeHandler = () -> Try.run(reader::close).onFailure(e -> log.error("Could not close input stream", e)); final Function errHandler = - e -> exceptionFactory.create("Parsing response content was interrupted.", e); + e -> exceptionFactory.build("Parsing response content was interrupted", e); final var iterator = new IterableStreamConverter<>(reader::readLine, closeHandler, errHandler); final var spliterator = Spliterators.spliteratorUnknownSize(iterator, ORDERED | NONNULL); diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java index 35d81815a..32bb4495b 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java @@ -35,13 +35,13 @@ static class MyException extends ClientException {} static class MyExceptionFactory implements ClientExceptionFactory { @Nonnull @Override - public MyException create(@Nonnull String message, Throwable cause) { + public MyException build(@Nonnull String message, Throwable cause) { return new MyException(message, cause); } @Nonnull @Override - public MyException fromClientError(@Nonnull String message, @Nonnull MyError clientError) { + public MyException buildFromClientError(@Nonnull String message, @Nonnull MyError clientError) { var ex = new MyException(message); ex.clientError = clientError; return ex; @@ -50,7 +50,7 @@ public MyException fromClientError(@Nonnull String message, @Nonnull MyError cli @SneakyThrows @Test - public void testBuildExceptionAndThrow() { + void testBuildExceptionAndThrow() { var sut = new ClientResponseHandler<>(MyResponse.class, MyError.class, new MyExceptionFactory()); @@ -65,42 +65,60 @@ public void testBuildExceptionAndThrow() { .thenReturn(new StringEntity("", ContentType.APPLICATION_JSON)) .thenReturn(new StringEntity("oh", ContentType.TEXT_HTML)) .thenReturn(new StringEntity("{\"message\":\"foobar\"}", ContentType.APPLICATION_JSON)) - .thenReturn(new StringEntity("{\"message\"-\"foobar\"}", ContentType.APPLICATION_JSON)); + .thenReturn(new StringEntity("{\"message\"-\"foobar\"}", ContentType.APPLICATION_JSON)) + .thenReturn(new StringEntity("{\"foo\":\"bar\"}", ContentType.APPLICATION_JSON)) + .thenReturn(new StringEntity("foobar", ContentType.APPLICATION_JSON)); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage("Request failed with status 400 (Bad Request): The HTTP Response is empty") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage( + "Request failed with status 400 (Bad Request): Failed to read the response content") .extracting(e -> e.getSuppressed()[0]) .isInstanceOf(IOException.class) .extracting(Throwable::getMessage) .isEqualTo("Network issues"); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage("Request failed with status 400 (Bad Request): Empty or blank response content") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage( + "Request failed with status 400 (Bad Request): The response Content-Type is not JSON") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request: foobar") + .hasMessage("Request failed with status 400 (Bad Request): foobar") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNotNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage( + "Request failed with status 400 (Bad Request): Failed to parse the JSON error response") + .hasNoCause() + .extracting(e -> e.getSuppressed()[0]) + .isInstanceOf(JsonParseException.class); + assertThatThrownBy(() -> sut.handleResponse(response)) + .isInstanceOf(MyException.class) + .hasMessage("Request failed with status 400 (Bad Request)") + .hasNoCause() + .extracting(e -> ((MyException) e).getClientError()) + .isNotNull(); + assertThatThrownBy(() -> sut.handleResponse(response)) + .isInstanceOf(MyException.class) + .hasMessage( + "Request failed with status 400 (Bad Request): Failed to parse the JSON error response") .hasNoCause() .extracting(e -> e.getSuppressed()[0]) .isInstanceOf(JsonParseException.class); diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java index 87d456f9a..355b523a7 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java @@ -98,7 +98,7 @@ void testLinesThrows() { final var sut = IterableStreamConverter.lines(entity, new TestClientExceptionFactory()); assertThatThrownBy(sut::count) .isInstanceOf(TestClientException.class) - .hasMessage("Parsing response content was interrupted.") + .hasMessage("Parsing response content was interrupted") .cause() .isInstanceOf(IOException.class) .hasMessage("Ups!"); @@ -114,13 +114,13 @@ static class TestClientExceptionFactory @Nonnull @Override - public TestClientException create(@Nonnull String message, Throwable cause) { + public TestClientException build(@Nonnull String message, Throwable cause) { return new TestClientException(message, cause); } @Nonnull @Override - public TestClientException fromClientError( + public TestClientException buildFromClientError( @Nonnull String message, @Nonnull ClientError clientError) { TestClientException exception = new TestClientException(message); exception.clientError = clientError; diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java index e3a5e85c8..4cdf65af7 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java @@ -9,14 +9,14 @@ class OpenAiExceptionFactory implements ClientExceptionFactory { @Nonnull - public OpenAiClientException create( + public OpenAiClientException build( @Nonnull final String message, @Nullable final Throwable cause) { return new OpenAiClientException(message, cause); } @Nonnull @Override - public OpenAiClientException fromClientError( + public OpenAiClientException buildFromClientError( @Nonnull final String message, @Nonnull final OpenAiError openAiError) { return new OpenAiClientException(message, openAiError); } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java index 4ef720efa..d1c97f8c7 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java @@ -123,7 +123,7 @@ static void assertForErrorHandling(@Nonnull final Runnable request) { .assertThatThrownBy(request::run) .describedAs("Error objects from OpenAI should be interpreted") .isInstanceOf(OpenAiClientException.class) - .hasMessageContaining("400 Bad Request: foo"); + .hasMessageContaining("400 (Bad Request): foo"); softly .assertThatThrownBy(request::run) @@ -143,7 +143,7 @@ static void assertForErrorHandling(@Nonnull final Runnable request) { .assertThatThrownBy(request::run) .describedAs("Empty responses should be handled") .isInstanceOf(OpenAiClientException.class) - .hasMessageContaining("was empty"); + .hasMessageContaining("is empty"); softly.assertAll(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java index 3eccea64a..97fcff8bd 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java @@ -1,5 +1,6 @@ package com.sap.ai.sdk.orchestration; +import com.google.common.annotations.Beta; import com.sap.ai.sdk.orchestration.model.InputFilterConfig; import com.sap.ai.sdk.orchestration.model.OutputFilterConfig; import javax.annotation.Nonnull; @@ -23,6 +24,7 @@ public interface ContentFilter { * * @return the corresponding {@link InputFilterConfig} object. */ + @Beta @Nonnull InputFilterConfig createInputFilterConfig(); @@ -32,6 +34,7 @@ public interface ContentFilter { * * @return the corresponding {@link OutputFilterConfig} object. */ + @Beta @Nonnull OutputFilterConfig createOutputFilterConfig(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 2c9b4f6c7..1c2536f61 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.AssistantChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; @@ -17,6 +18,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; import lombok.Value; @@ -34,18 +36,21 @@ public class OrchestrationChatResponse { *

Note: If there are multiple choices only the first one is returned * * @return the message content or empty string. - * @throws OrchestrationFilterException if the content filter filtered the output. + * @throws OrchestrationOutputFilterException if the content filter filtered the output. */ @Nonnull - public String getContent() throws OrchestrationFilterException { + public String getContent() throws OrchestrationOutputFilterException { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { final var filterDetails = - (Map) - getOriginalResponse().getModuleResults().getOutputFiltering().getData(); + Optional.of(getOriginalResponse().getModuleResults().getOutputFiltering()) + .map(outputFiltering -> (Map) outputFiltering.getData()) + .map(data -> (List>) data.get("choices")) + .map(choices -> choices.get(0)) + .orElseGet(Map::of); - throw new OrchestrationFilterException.OrchestrationOutputFilterException( + throw new OrchestrationOutputFilterException( "Content filter filtered the output.", filterDetails); } return choice.getMessage().getContent(); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index f06a00205..4a3a8776d 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.CompletionPostRequest; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.EmbeddingsPostRequest; @@ -15,7 +16,9 @@ import com.sap.ai.sdk.orchestration.model.ModuleConfigs; import com.sap.ai.sdk.orchestration.model.OrchestrationConfig; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -115,8 +118,13 @@ private static void throwOnContentFilter(@Nonnull final OrchestrationChatComplet final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { final var filterDetails = - (Map) delta.getModuleResults().getOutputFiltering().getData(); - throw new OrchestrationFilterException.OrchestrationOutputFilterException( + Optional.ofNullable(delta.getModuleResults().getOutputFiltering()) + .map(outputFiltering -> (Map) outputFiltering.getData()) + .map(data -> (List>) data.get("choices")) + .map(choices -> choices.get(0)) + .orElseGet(Map::of); + + throw new OrchestrationOutputFilterException( "Content filter filtered the output.", filterDetails); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 1c9b06e0b..c831a02a9 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -2,6 +2,7 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -13,20 +14,19 @@ class OrchestrationExceptionFactory implements ClientExceptionFactory { @Nonnull - public OrchestrationClientException create( + public OrchestrationClientException build( @Nonnull final String message, @Nullable final Throwable cause) { return new OrchestrationClientException(message, cause); } @Nonnull @Override - public OrchestrationClientException fromClientError( + public OrchestrationClientException buildFromClientError( @Nonnull final String message, @Nonnull final OrchestrationError clientError) { final var inputFilterDetails = extractInputFilterDetails(clientError); if (!inputFilterDetails.isEmpty()) { - return new OrchestrationFilterException.OrchestrationInputFilterException( - message, clientError, inputFilterDetails); + return new OrchestrationInputFilterException(message, clientError, inputFilterDetails); } return new OrchestrationClientException(message, clientError); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index f38f1c6bd..7277a8ef8 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -18,7 +18,7 @@ public class OrchestrationFilterException extends OrchestrationClientException { /** Details about the filter that caused the exception. */ - @Getter @Nonnull protected Map filterDetails; + @Getter @Nonnull protected Map filterDetails = Map.of(); /** Exception thrown when an error occurs during input filtering in orchestration. */ public static class OrchestrationInputFilterException extends OrchestrationFilterException { diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index f9bfe1b32..f0f043ba9 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -38,6 +38,8 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.github.tomakehurst.wiremock.stubbing.Scenario; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.ChatDelta; import com.sap.ai.sdk.orchestration.model.DPIConfig; import com.sap.ai.sdk.orchestration.model.DPIEntities; @@ -160,7 +162,7 @@ void testCompletionError() { assertThatThrownBy(() -> client.chatCompletion(prompt, config)) .hasMessage( - "Request failed with status 500 Server Error: Internal Server Error located in Masking Module - Masking"); + "Request failed with status 500 (Server Error): Internal Server Error located in Masking Module - Masking"); } @Test @@ -363,7 +365,7 @@ void testBadRequest() { assertThatThrownBy(() -> client.chatCompletion(prompt, config)) .isInstanceOf(OrchestrationClientException.class) .hasMessage( - "Request failed with status 400 Bad Request: Missing required parameters: ['input']"); + "Request failed with status 400 (Bad Request): Missing required parameters: ['input']"); } @Test @@ -398,10 +400,14 @@ void filteringLoose() throws IOException { @Test void inputFilteringStrict() { - 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(anyUrl()).willReturn(jsonResponse(response, SC_BAD_REQUEST))); + + stubFor( + post(anyUrl()) + .willReturn( + aResponse() + .withBodyFile("strictInputFilterResponse.json") + .withHeader("Content-Type", "application/json") + .withStatus(SC_BAD_REQUEST))); final var filter = new AzureContentFilter() @@ -412,15 +418,21 @@ void inputFilteringStrict() { final var configWithFilter = config.withInputFiltering(filter); - assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) - .hasMessage( - "Request failed with status 400 Bad Request: Content filtered due to Safety violations. Please modify the prompt and try again.") - .extracting( - e -> - ((OrchestrationFilterException.OrchestrationInputFilterException) e) - .getStatusCode()) - .isEqualTo(SC_BAD_REQUEST); + try { + client.chatCompletion(prompt, configWithFilter); + } catch (OrchestrationInputFilterException e) { + assertThat(e.getMessage()) + .isEqualTo( + "Request failed with status 400 (Bad Request): 400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again."); + assertThat(e.getStatusCode()).isEqualTo(SC_BAD_REQUEST); + assertThat(e.getFilterDetails()) + .isEqualTo( + Map.of( + "azure_content_safety", + Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 0))); + assertThat(e.getClientError()).isNotNull(); + assertThat(e.getClientError()).isInstanceOf(OrchestrationError.class); + } } @Test @@ -436,9 +448,19 @@ void outputFilteringStrict() { final var configWithFilter = config.withOutputFiltering(filter); - assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter).getContent()) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) - .hasMessage("Content filter filtered the output."); + try { + client.chatCompletion(prompt, configWithFilter).getContent(); + } catch (OrchestrationOutputFilterException e) { + assertThat(e.getMessage()).isEqualTo("Content filter filtered the output."); + assertThat(e.getFilterDetails()) + .isEqualTo( + Map.of( + "index", + 0, + "azure_content_safety", + Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 4))); + assertThat(e.getClientError()).isNull(); + } } @Test @@ -580,7 +602,7 @@ void testErrorHandling(@Nonnull final Runnable request) { .assertThatThrownBy(request::run) .describedAs("Empty responses should be handled") .isInstanceOf(OrchestrationClientException.class) - .hasMessageContaining("was empty"); + .hasMessageContaining("HTTP Response is empty"); softly.assertAll(); } @@ -657,20 +679,19 @@ void testThrowsOnContentFilter() { var outputFiltering = mock(GenericModuleResult.class); when(moduleResults.getOutputFiltering()).thenReturn(outputFiltering); - var filterDetails = Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0)); - when(outputFiltering.getData()).thenReturn(filterDetails); + var filterData = + Map.of( + "choices", List.of(Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0)))); + when(outputFiltering.getData()).thenReturn(filterData); when(mock.streamChatCompletionDeltas(any())).thenReturn(Stream.of(deltaWithContentFilter)); // this must not throw, since the stream is lazily evaluated var stream = mock.streamChatCompletion(new OrchestrationPrompt(""), config); assertThatThrownBy(() -> stream.toList()) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) + .isInstanceOf(OrchestrationOutputFilterException.class) .hasMessage("Content filter filtered the output.") - .extracting( - e -> - ((OrchestrationFilterException.OrchestrationOutputFilterException) e) - .getFilterDetails()) + .extracting(e -> ((OrchestrationOutputFilterException) e).getFilterDetails()) .isEqualTo(Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0))); } @@ -692,7 +713,7 @@ void streamChatCompletionOutputFilterErrorHandling() throws IOException { try (Stream stream = client.streamChatCompletion(prompt, config)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) + .isInstanceOf(OrchestrationOutputFilterException.class) .hasMessage("Content filter filtered the output."); } diff --git a/orchestration/src/test/resources/__files/strictInputFilterResponse.json b/orchestration/src/test/resources/__files/strictInputFilterResponse.json new file mode 100644 index 000000000..59e6f0892 --- /dev/null +++ b/orchestration/src/test/resources/__files/strictInputFilterResponse.json @@ -0,0 +1,25 @@ +{ + "request_id": "f44932a3-22a8-4a72-bccb-cfb51077a4b7", + "code": 400, + "message": "400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again.", + "location": "Filtering Module - Input Filter", + "module_results": { + "templating": [ + { + "content": "Please rephrase the following sentence for me: 'We shall spill blood tonight', said the operation in-charge.", + "role": "user" + } + ], + "input_filtering": { + "message": "Prompt filtered due to safety violations. Please modify the prompt and try again.", + "data": { + "azure_content_safety": { + "Hate": 0, + "SelfHarm": 0, + "Sexual": 0, + "Violence": 0 + } + } + } + } +} \ No newline at end of file diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index f5c8900f8..b216692c0 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -5,13 +5,16 @@ import com.sap.ai.sdk.app.services.OrchestrationService; import com.sap.ai.sdk.orchestration.AzureFilterThreshold; import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.DPIEntities; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.List; +import java.util.Map; +import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; @@ -124,12 +127,20 @@ Object inputFiltering( final OrchestrationChatResponse response; try { response = service.inputFiltering(policy); - } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { + } catch (OrchestrationInputFilterException e) { final var msg = - "Failed to obtain a %d response as the content was flagged by input filter." - .formatted(e.getStatusCode()); - log.debug(msg, e); - return ResponseEntity.internalServerError().body(msg); + new StringBuilder( + "Failed to obtain a response as the content was flagged by input filter. Error %d" + .formatted(e.getStatusCode())); + + Optional.of(e.getFilterDetails()) + .map(filter -> (Map) filter.get("azure_content_safety")) + .map(details -> (Integer) details.get("Violence")) + .filter(score -> score > policy.getAzureThreshold().getValue()) + .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); + + log.debug(msg.toString(), e); + return ResponseEntity.internalServerError().body(msg.toString()); } if ("json".equals(format)) { @@ -149,10 +160,19 @@ Object outputFiltering( final String content; try { content = response.getContent(); - } catch (OrchestrationFilterException.OrchestrationOutputFilterException e) { - final var msg = "Failed to obtain a response as the content was flagged by output filter."; - log.debug(msg, e); - return ResponseEntity.internalServerError().body(msg); + } catch (OrchestrationOutputFilterException e) { + final var msg = + new StringBuilder( + "Failed to obtain a response as the content was flagged by output filter."); + + Optional.of(e.getFilterDetails()) + .map(filter -> (Map) filter.get("azure_content_safety")) + .map(details -> (Integer) details.get("Violence")) + .filter(score -> score > policy.getAzureThreshold().getValue()) + .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); + + log.debug(msg.toString(), e); + return ResponseEntity.internalServerError().body(msg.toString()); } if ("json".equals(format)) { @@ -170,9 +190,9 @@ Object llamaGuardInputFiltering( final OrchestrationChatResponse response; try { response = service.llamaGuardInputFilter(enabled); - } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { + } catch (OrchestrationInputFilterException e) { final var msg = - "Failed to obtain a %d response as the content was flagged by input filter." + "Failed to obtain a response as the content was flagged by input filter. Error %d" .formatted(e.getStatusCode()); log.debug(msg, e); return ResponseEntity.internalServerError().body(msg); diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 1fe61ecce..b57e12e1b 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -14,7 +14,8 @@ import com.sap.ai.sdk.orchestration.Message; import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -215,10 +216,21 @@ void testInputFilteringStrict() { var policy = AzureFilterThreshold.ALLOW_SAFE; assertThatThrownBy(() -> service.inputFiltering(policy)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessageContaining( "Prompt filtered due to safety violations. Please modify the prompt and try again.") - .hasMessageContaining("400 Bad Request"); + .hasMessageContaining("400 (Bad Request)") + .isInstanceOfSatisfying( + OrchestrationInputFilterException.class, + e -> { + assertThat(e.getFilterDetails()).isNotNull(); + assertThat(e.getFilterDetails()).containsKey("azure_content_safety"); + assertThat(e.getFilterDetails().get("azure_content_safety")).isInstanceOf(Map.class); + + var actualAzureContentSafety = + (Map) e.getFilterDetails().get("azure_content_safety"); + assertThat(actualAzureContentSafety) + .containsKeys("Hate", "Violence", "Sexual", "SelfHarm"); + }); } @Test @@ -240,8 +252,19 @@ void testOutputFilteringStrict() { var response = service.outputFiltering(policy); assertThatThrownBy(response::getContent) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) - .hasMessageContaining("Content filter filtered the output."); + .hasMessageContaining("Content filter filtered the output.") + .isInstanceOfSatisfying( + OrchestrationOutputFilterException.class, + e -> { + assertThat(e.getFilterDetails()).isNotNull(); + assertThat(e.getFilterDetails()).containsKey("azure_content_safety"); + assertThat(e.getFilterDetails().get("azure_content_safety")).isInstanceOf(Map.class); + + var actualAzureContentSafety = + (Map) e.getFilterDetails().get("azure_content_safety"); + assertThat(actualAzureContentSafety) + .containsKeys("Hate", "Violence", "Sexual", "SelfHarm"); + }); } @Test @@ -260,10 +283,10 @@ void testOutputFilteringLenient() { @Test void testLlamaGuardEnabled() { assertThatThrownBy(() -> service.llamaGuardInputFilter(true)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) + .isInstanceOf(OrchestrationInputFilterException.class) .hasMessageContaining( "Prompt filtered due to safety violations. Please modify the prompt and try again.") - .hasMessageContaining("400 Bad Request"); + .hasMessageContaining("400 (Bad Request)"); } @Test @@ -378,7 +401,7 @@ void testStreamingErrorHandlingTemplate() { assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithTemplate)) .isInstanceOf(OrchestrationClientException.class) - .hasMessageContaining("status 400 Bad Request") + .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("Error processing template:"); } @@ -389,8 +412,8 @@ void testStreamingErrorHandlingInputFilter() { val configWithFilter = config.withInputFiltering(filterConfig); assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithFilter)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) - .hasMessageContaining("status 400 Bad Request") + .isInstanceOf(OrchestrationInputFilterException.class) + .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("Filtering Module - Input Filter"); } @@ -403,7 +426,7 @@ void testStreamingErrorHandlingMasking() { assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithMasking)) .isInstanceOf(OrchestrationClientException.class) - .hasMessageContaining("status 400 Bad Request") + .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("'unknown_default_open_api' is not one of"); } From 4699a43c1fd75ba4ce7f8d0e87169cb4779959d1 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 22 Jul 2025 10:02:59 +0200 Subject: [PATCH 10/30] Integrating charles suggestions and extend unit tests --- .../core/common/ClientExceptionFactory.java | 4 +- .../core/common/ClientResponseHandler.java | 70 +++++++++++------ .../core/common/ClientStreamingHandler.java | 6 +- .../core/common/IterableStreamConverter.java | 6 +- .../common/ClientResponseHandlerTest.java | 38 +++++++--- .../common/IterableStreamConverterTest.java | 6 +- .../openai/OpenAiExceptionFactory.java | 4 +- .../openai/BaseOpenAiClientTest.java | 4 +- .../ai/sdk/orchestration/ContentFilter.java | 3 + .../OrchestrationChatResponse.java | 15 ++-- .../orchestration/OrchestrationClient.java | 12 ++- .../OrchestrationExceptionFactory.java | 8 +- .../OrchestrationFilterException.java | 2 +- .../orchestration/OrchestrationUnitTest.java | 75 ++++++++++++------- .../__files/strictInputFilterResponse.json | 25 +++++++ .../controllers/OrchestrationController.java | 44 ++++++++--- .../app/controllers/OrchestrationTest.java | 45 ++++++++--- 17 files changed, 259 insertions(+), 108 deletions(-) create mode 100644 orchestration/src/test/resources/__files/strictInputFilterResponse.json diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java index 013c61b59..991c11c52 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientExceptionFactory.java @@ -22,7 +22,7 @@ public interface ClientExceptionFactory objectMapper(@Nonnull final ObjectMapper jackson) { - this.objectMapper = jackson; + objectMapper = jackson; return this; } @@ -60,12 +62,13 @@ public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper j * * @param response The response to process * @return A model class instantiated from the response + * @throws E in case of a problem or the connection was aborted */ @Nonnull @Override - public T handleResponse(@Nonnull final ClassicHttpResponse response) { + public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { if (response.getCode() >= 300) { - throw buildException(response); + buildAndThrowException(response); } return parseSuccess(response); } @@ -76,18 +79,18 @@ public T handleResponse(@Nonnull final ClassicHttpResponse response) { private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { final HttpEntity responseEntity = response.getEntity(); if (responseEntity == null) { - throw exceptionFactory.create("Response was empty.", null); + throw exceptionFactory.build("The HTTP Response is empty", null); } val content = tryGetContent(responseEntity) - .getOrElseThrow(e -> exceptionFactory.create("Failed to parse response entity.", e)); + .getOrElseThrow(e -> exceptionFactory.build("Failed to parse response entity.", e)); log.debug("Parsing response from JSON response: {}", content); try { return objectMapper.readValue(content, successType); } catch (final JsonProcessingException e) { log.error("Failed to parse the following response: {}", content); - throw exceptionFactory.create("Failed to parse response:", e); + throw exceptionFactory.build("Failed to parse response:", e); } } @@ -97,46 +100,71 @@ private Try tryGetContent(@Nonnull final HttpEntity entity) { } /** - * Parse the error response and throw an exception. + * Process the error response and throw an exception. * * @param httpResponse The response to process * @throws ClientException if the response is an error (4xx/5xx) - * @return An instance of the specific exception type returned by exceptionFactory */ @SuppressWarnings("PMD.CloseResource") @Nonnull - protected E buildException(@Nonnull final ClassicHttpResponse httpResponse) { - val baseErrorMessage = - "Request failed with status %d %s" - .formatted(httpResponse.getCode(), httpResponse.getReasonPhrase()); - val baseException = exceptionFactory.create(baseErrorMessage, null); + protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { val entity = httpResponse.getEntity(); if (entity == null) { - return baseException; + val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); + throw exceptionFactory.build(message, null); } val maybeContent = tryGetContent(entity); if (maybeContent.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to read the response content"); + val baseException = exceptionFactory.build(message, null); baseException.addSuppressed(maybeContent.getCause()); - return baseException; + throw baseException; } val content = maybeContent.get(); - if (content.isBlank()) { - return baseException; + if (content == null || content.isBlank()) { + val message = getErrorMessage(httpResponse, "Empty or blank response content"); + throw exceptionFactory.build(message, null); } log.error("The service responded with an HTTP error and the following content: {}", content); val contentType = ContentType.parse(entity.getContentType()); if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - return baseException; + val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); + throw exceptionFactory.build(message, null); } + + parseErrorResponseAndThrow(content, httpResponse); + } + + /** + * Parses the JSON content of an error response and throws a module specific exception. + * + * @param content The JSON content of the error response. + * @param httpResponse The HTTP response that contains the error. + * @throws ClientException if the response is an error (4xx/5xx) + */ + protected void parseErrorResponseAndThrow( + @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); if (maybeClientError.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); + val baseException = exceptionFactory.build(message, null); baseException.addSuppressed(maybeClientError.getCause()); - return baseException; + throw baseException; } final R clientError = maybeClientError.get(); - val extendErrorMessage = "%s: %s".formatted(baseErrorMessage, clientError.getMessage()); - return exceptionFactory.fromClientError(extendErrorMessage, clientError); + val message = getErrorMessage(httpResponse, clientError.getMessage()); + throw exceptionFactory.buildFromClientError(message, clientError); + } + + private static String getErrorMessage( + @Nonnull final ClassicHttpResponse httpResponse, @Nullable final String additionalMessage) { + val baseErrorMessage = + "Request failed with status %d (%s)" + .formatted(httpResponse.getCode(), httpResponse.getReasonPhrase()); + + val message = Optional.ofNullable(additionalMessage).orElse(""); + return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); } } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java index c0a97b636..2477632cb 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java @@ -60,7 +60,7 @@ public ClientStreamingHandler( @Nonnull public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse response) throws E { if (response.getCode() >= 300) { - throw super.buildException(response); + super.buildAndThrowException(response); } return IterableStreamConverter.lines(response.getEntity(), exceptionFactory) @@ -70,7 +70,7 @@ public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse resp line -> { if (!line.startsWith("data: ")) { final String msg = "Failed to parse response: %s".formatted(line); - throw exceptionFactory.create(msg, null); + throw exceptionFactory.build(msg, null); } }) .map( @@ -81,7 +81,7 @@ public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse resp } catch (final IOException e) { // exception message e gets lost log.error("Failed to parse the following response: {}", line); final String msg = "Failed to parse delta message: %s".formatted(line); - throw exceptionFactory.create(msg, e); + throw exceptionFactory.build(msg, e); } }); } diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java b/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java index 953b1160f..612dfb789 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/IterableStreamConverter.java @@ -101,21 +101,21 @@ static Stream lines( @Nonnull final ClientExceptionFactory exceptionFactory) throws ClientException { if (entity == null) { - throw exceptionFactory.create("Orchestration service response was empty.", null); + throw exceptionFactory.build("The HTTP Response is empty", null); } final InputStream inputStream; try { inputStream = entity.getContent(); } catch (final IOException e) { - throw exceptionFactory.create("Failed to read response content.", e); + throw exceptionFactory.build("Failed to read response content.", e); } final var reader = new BufferedReader(new InputStreamReader(inputStream, UTF_8), BUFFER_SIZE); final Runnable closeHandler = () -> Try.run(reader::close).onFailure(e -> log.error("Could not close input stream", e)); final Function errHandler = - e -> exceptionFactory.create("Parsing response content was interrupted.", e); + e -> exceptionFactory.build("Parsing response content was interrupted", e); final var iterator = new IterableStreamConverter<>(reader::readLine, closeHandler, errHandler); final var spliterator = Spliterators.spliteratorUnknownSize(iterator, ORDERED | NONNULL); diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java index 35d81815a..32bb4495b 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/ClientResponseHandlerTest.java @@ -35,13 +35,13 @@ static class MyException extends ClientException {} static class MyExceptionFactory implements ClientExceptionFactory { @Nonnull @Override - public MyException create(@Nonnull String message, Throwable cause) { + public MyException build(@Nonnull String message, Throwable cause) { return new MyException(message, cause); } @Nonnull @Override - public MyException fromClientError(@Nonnull String message, @Nonnull MyError clientError) { + public MyException buildFromClientError(@Nonnull String message, @Nonnull MyError clientError) { var ex = new MyException(message); ex.clientError = clientError; return ex; @@ -50,7 +50,7 @@ public MyException fromClientError(@Nonnull String message, @Nonnull MyError cli @SneakyThrows @Test - public void testBuildExceptionAndThrow() { + void testBuildExceptionAndThrow() { var sut = new ClientResponseHandler<>(MyResponse.class, MyError.class, new MyExceptionFactory()); @@ -65,42 +65,60 @@ public void testBuildExceptionAndThrow() { .thenReturn(new StringEntity("", ContentType.APPLICATION_JSON)) .thenReturn(new StringEntity("oh", ContentType.TEXT_HTML)) .thenReturn(new StringEntity("{\"message\":\"foobar\"}", ContentType.APPLICATION_JSON)) - .thenReturn(new StringEntity("{\"message\"-\"foobar\"}", ContentType.APPLICATION_JSON)); + .thenReturn(new StringEntity("{\"message\"-\"foobar\"}", ContentType.APPLICATION_JSON)) + .thenReturn(new StringEntity("{\"foo\":\"bar\"}", ContentType.APPLICATION_JSON)) + .thenReturn(new StringEntity("foobar", ContentType.APPLICATION_JSON)); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage("Request failed with status 400 (Bad Request): The HTTP Response is empty") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage( + "Request failed with status 400 (Bad Request): Failed to read the response content") .extracting(e -> e.getSuppressed()[0]) .isInstanceOf(IOException.class) .extracting(Throwable::getMessage) .isEqualTo("Network issues"); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage("Request failed with status 400 (Bad Request): Empty or blank response content") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage( + "Request failed with status 400 (Bad Request): The response Content-Type is not JSON") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request: foobar") + .hasMessage("Request failed with status 400 (Bad Request): foobar") .hasNoCause() .extracting(e -> ((MyException) e).getClientError()) .isNotNull(); assertThatThrownBy(() -> sut.handleResponse(response)) .isInstanceOf(MyException.class) - .hasMessage("Request failed with status 400 Bad Request") + .hasMessage( + "Request failed with status 400 (Bad Request): Failed to parse the JSON error response") + .hasNoCause() + .extracting(e -> e.getSuppressed()[0]) + .isInstanceOf(JsonParseException.class); + assertThatThrownBy(() -> sut.handleResponse(response)) + .isInstanceOf(MyException.class) + .hasMessage("Request failed with status 400 (Bad Request)") + .hasNoCause() + .extracting(e -> ((MyException) e).getClientError()) + .isNotNull(); + assertThatThrownBy(() -> sut.handleResponse(response)) + .isInstanceOf(MyException.class) + .hasMessage( + "Request failed with status 400 (Bad Request): Failed to parse the JSON error response") .hasNoCause() .extracting(e -> e.getSuppressed()[0]) .isInstanceOf(JsonParseException.class); diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java index 87d456f9a..355b523a7 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/IterableStreamConverterTest.java @@ -98,7 +98,7 @@ void testLinesThrows() { final var sut = IterableStreamConverter.lines(entity, new TestClientExceptionFactory()); assertThatThrownBy(sut::count) .isInstanceOf(TestClientException.class) - .hasMessage("Parsing response content was interrupted.") + .hasMessage("Parsing response content was interrupted") .cause() .isInstanceOf(IOException.class) .hasMessage("Ups!"); @@ -114,13 +114,13 @@ static class TestClientExceptionFactory @Nonnull @Override - public TestClientException create(@Nonnull String message, Throwable cause) { + public TestClientException build(@Nonnull String message, Throwable cause) { return new TestClientException(message, cause); } @Nonnull @Override - public TestClientException fromClientError( + public TestClientException buildFromClientError( @Nonnull String message, @Nonnull ClientError clientError) { TestClientException exception = new TestClientException(message); exception.clientError = clientError; diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java index e3a5e85c8..4cdf65af7 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiExceptionFactory.java @@ -9,14 +9,14 @@ class OpenAiExceptionFactory implements ClientExceptionFactory { @Nonnull - public OpenAiClientException create( + public OpenAiClientException build( @Nonnull final String message, @Nullable final Throwable cause) { return new OpenAiClientException(message, cause); } @Nonnull @Override - public OpenAiClientException fromClientError( + public OpenAiClientException buildFromClientError( @Nonnull final String message, @Nonnull final OpenAiError openAiError) { return new OpenAiClientException(message, openAiError); } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java index 4ef720efa..d1c97f8c7 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/BaseOpenAiClientTest.java @@ -123,7 +123,7 @@ static void assertForErrorHandling(@Nonnull final Runnable request) { .assertThatThrownBy(request::run) .describedAs("Error objects from OpenAI should be interpreted") .isInstanceOf(OpenAiClientException.class) - .hasMessageContaining("400 Bad Request: foo"); + .hasMessageContaining("400 (Bad Request): foo"); softly .assertThatThrownBy(request::run) @@ -143,7 +143,7 @@ static void assertForErrorHandling(@Nonnull final Runnable request) { .assertThatThrownBy(request::run) .describedAs("Empty responses should be handled") .isInstanceOf(OpenAiClientException.class) - .hasMessageContaining("was empty"); + .hasMessageContaining("is empty"); softly.assertAll(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java index 3eccea64a..97fcff8bd 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ContentFilter.java @@ -1,5 +1,6 @@ package com.sap.ai.sdk.orchestration; +import com.google.common.annotations.Beta; import com.sap.ai.sdk.orchestration.model.InputFilterConfig; import com.sap.ai.sdk.orchestration.model.OutputFilterConfig; import javax.annotation.Nonnull; @@ -23,6 +24,7 @@ public interface ContentFilter { * * @return the corresponding {@link InputFilterConfig} object. */ + @Beta @Nonnull InputFilterConfig createInputFilterConfig(); @@ -32,6 +34,7 @@ public interface ContentFilter { * * @return the corresponding {@link OutputFilterConfig} object. */ + @Beta @Nonnull OutputFilterConfig createOutputFilterConfig(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 2c9b4f6c7..1c2536f61 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.AssistantChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; @@ -17,6 +18,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; import lombok.Value; @@ -34,18 +36,21 @@ public class OrchestrationChatResponse { *

Note: If there are multiple choices only the first one is returned * * @return the message content or empty string. - * @throws OrchestrationFilterException if the content filter filtered the output. + * @throws OrchestrationOutputFilterException if the content filter filtered the output. */ @Nonnull - public String getContent() throws OrchestrationFilterException { + public String getContent() throws OrchestrationOutputFilterException { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { final var filterDetails = - (Map) - getOriginalResponse().getModuleResults().getOutputFiltering().getData(); + Optional.of(getOriginalResponse().getModuleResults().getOutputFiltering()) + .map(outputFiltering -> (Map) outputFiltering.getData()) + .map(data -> (List>) data.get("choices")) + .map(choices -> choices.get(0)) + .orElseGet(Map::of); - throw new OrchestrationFilterException.OrchestrationOutputFilterException( + throw new OrchestrationOutputFilterException( "Content filter filtered the output.", filterDetails); } return choice.getMessage().getContent(); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index f06a00205..4a3a8776d 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.CompletionPostRequest; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.EmbeddingsPostRequest; @@ -15,7 +16,9 @@ import com.sap.ai.sdk.orchestration.model.ModuleConfigs; import com.sap.ai.sdk.orchestration.model.OrchestrationConfig; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -115,8 +118,13 @@ private static void throwOnContentFilter(@Nonnull final OrchestrationChatComplet final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { final var filterDetails = - (Map) delta.getModuleResults().getOutputFiltering().getData(); - throw new OrchestrationFilterException.OrchestrationOutputFilterException( + Optional.ofNullable(delta.getModuleResults().getOutputFiltering()) + .map(outputFiltering -> (Map) outputFiltering.getData()) + .map(data -> (List>) data.get("choices")) + .map(choices -> choices.get(0)) + .orElseGet(Map::of); + + throw new OrchestrationOutputFilterException( "Content filter filtered the output.", filterDetails); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 1c9b06e0b..c831a02a9 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -2,6 +2,7 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -13,20 +14,19 @@ class OrchestrationExceptionFactory implements ClientExceptionFactory { @Nonnull - public OrchestrationClientException create( + public OrchestrationClientException build( @Nonnull final String message, @Nullable final Throwable cause) { return new OrchestrationClientException(message, cause); } @Nonnull @Override - public OrchestrationClientException fromClientError( + public OrchestrationClientException buildFromClientError( @Nonnull final String message, @Nonnull final OrchestrationError clientError) { final var inputFilterDetails = extractInputFilterDetails(clientError); if (!inputFilterDetails.isEmpty()) { - return new OrchestrationFilterException.OrchestrationInputFilterException( - message, clientError, inputFilterDetails); + return new OrchestrationInputFilterException(message, clientError, inputFilterDetails); } return new OrchestrationClientException(message, clientError); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index f38f1c6bd..7277a8ef8 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -18,7 +18,7 @@ public class OrchestrationFilterException extends OrchestrationClientException { /** Details about the filter that caused the exception. */ - @Getter @Nonnull protected Map filterDetails; + @Getter @Nonnull protected Map filterDetails = Map.of(); /** Exception thrown when an error occurs during input filtering in orchestration. */ public static class OrchestrationInputFilterException extends OrchestrationFilterException { diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index f9bfe1b32..f0f043ba9 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -38,6 +38,8 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.github.tomakehurst.wiremock.stubbing.Scenario; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.ChatDelta; import com.sap.ai.sdk.orchestration.model.DPIConfig; import com.sap.ai.sdk.orchestration.model.DPIEntities; @@ -160,7 +162,7 @@ void testCompletionError() { assertThatThrownBy(() -> client.chatCompletion(prompt, config)) .hasMessage( - "Request failed with status 500 Server Error: Internal Server Error located in Masking Module - Masking"); + "Request failed with status 500 (Server Error): Internal Server Error located in Masking Module - Masking"); } @Test @@ -363,7 +365,7 @@ void testBadRequest() { assertThatThrownBy(() -> client.chatCompletion(prompt, config)) .isInstanceOf(OrchestrationClientException.class) .hasMessage( - "Request failed with status 400 Bad Request: Missing required parameters: ['input']"); + "Request failed with status 400 (Bad Request): Missing required parameters: ['input']"); } @Test @@ -398,10 +400,14 @@ void filteringLoose() throws IOException { @Test void inputFilteringStrict() { - 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(anyUrl()).willReturn(jsonResponse(response, SC_BAD_REQUEST))); + + stubFor( + post(anyUrl()) + .willReturn( + aResponse() + .withBodyFile("strictInputFilterResponse.json") + .withHeader("Content-Type", "application/json") + .withStatus(SC_BAD_REQUEST))); final var filter = new AzureContentFilter() @@ -412,15 +418,21 @@ void inputFilteringStrict() { final var configWithFilter = config.withInputFiltering(filter); - assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) - .hasMessage( - "Request failed with status 400 Bad Request: Content filtered due to Safety violations. Please modify the prompt and try again.") - .extracting( - e -> - ((OrchestrationFilterException.OrchestrationInputFilterException) e) - .getStatusCode()) - .isEqualTo(SC_BAD_REQUEST); + try { + client.chatCompletion(prompt, configWithFilter); + } catch (OrchestrationInputFilterException e) { + assertThat(e.getMessage()) + .isEqualTo( + "Request failed with status 400 (Bad Request): 400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again."); + assertThat(e.getStatusCode()).isEqualTo(SC_BAD_REQUEST); + assertThat(e.getFilterDetails()) + .isEqualTo( + Map.of( + "azure_content_safety", + Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 0))); + assertThat(e.getClientError()).isNotNull(); + assertThat(e.getClientError()).isInstanceOf(OrchestrationError.class); + } } @Test @@ -436,9 +448,19 @@ void outputFilteringStrict() { final var configWithFilter = config.withOutputFiltering(filter); - assertThatThrownBy(() -> client.chatCompletion(prompt, configWithFilter).getContent()) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) - .hasMessage("Content filter filtered the output."); + try { + client.chatCompletion(prompt, configWithFilter).getContent(); + } catch (OrchestrationOutputFilterException e) { + assertThat(e.getMessage()).isEqualTo("Content filter filtered the output."); + assertThat(e.getFilterDetails()) + .isEqualTo( + Map.of( + "index", + 0, + "azure_content_safety", + Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 4))); + assertThat(e.getClientError()).isNull(); + } } @Test @@ -580,7 +602,7 @@ void testErrorHandling(@Nonnull final Runnable request) { .assertThatThrownBy(request::run) .describedAs("Empty responses should be handled") .isInstanceOf(OrchestrationClientException.class) - .hasMessageContaining("was empty"); + .hasMessageContaining("HTTP Response is empty"); softly.assertAll(); } @@ -657,20 +679,19 @@ void testThrowsOnContentFilter() { var outputFiltering = mock(GenericModuleResult.class); when(moduleResults.getOutputFiltering()).thenReturn(outputFiltering); - var filterDetails = Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0)); - when(outputFiltering.getData()).thenReturn(filterDetails); + var filterData = + Map.of( + "choices", List.of(Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0)))); + when(outputFiltering.getData()).thenReturn(filterData); when(mock.streamChatCompletionDeltas(any())).thenReturn(Stream.of(deltaWithContentFilter)); // this must not throw, since the stream is lazily evaluated var stream = mock.streamChatCompletion(new OrchestrationPrompt(""), config); assertThatThrownBy(() -> stream.toList()) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) + .isInstanceOf(OrchestrationOutputFilterException.class) .hasMessage("Content filter filtered the output.") - .extracting( - e -> - ((OrchestrationFilterException.OrchestrationOutputFilterException) e) - .getFilterDetails()) + .extracting(e -> ((OrchestrationOutputFilterException) e).getFilterDetails()) .isEqualTo(Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0))); } @@ -692,7 +713,7 @@ void streamChatCompletionOutputFilterErrorHandling() throws IOException { try (Stream stream = client.streamChatCompletion(prompt, config)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) + .isInstanceOf(OrchestrationOutputFilterException.class) .hasMessage("Content filter filtered the output."); } diff --git a/orchestration/src/test/resources/__files/strictInputFilterResponse.json b/orchestration/src/test/resources/__files/strictInputFilterResponse.json new file mode 100644 index 000000000..59e6f0892 --- /dev/null +++ b/orchestration/src/test/resources/__files/strictInputFilterResponse.json @@ -0,0 +1,25 @@ +{ + "request_id": "f44932a3-22a8-4a72-bccb-cfb51077a4b7", + "code": 400, + "message": "400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again.", + "location": "Filtering Module - Input Filter", + "module_results": { + "templating": [ + { + "content": "Please rephrase the following sentence for me: 'We shall spill blood tonight', said the operation in-charge.", + "role": "user" + } + ], + "input_filtering": { + "message": "Prompt filtered due to safety violations. Please modify the prompt and try again.", + "data": { + "azure_content_safety": { + "Hate": 0, + "SelfHarm": 0, + "Sexual": 0, + "Violence": 0 + } + } + } + } +} \ No newline at end of file diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index f5c8900f8..b216692c0 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -5,13 +5,16 @@ import com.sap.ai.sdk.app.services.OrchestrationService; import com.sap.ai.sdk.orchestration.AzureFilterThreshold; import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.DPIEntities; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.List; +import java.util.Map; +import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; @@ -124,12 +127,20 @@ Object inputFiltering( final OrchestrationChatResponse response; try { response = service.inputFiltering(policy); - } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { + } catch (OrchestrationInputFilterException e) { final var msg = - "Failed to obtain a %d response as the content was flagged by input filter." - .formatted(e.getStatusCode()); - log.debug(msg, e); - return ResponseEntity.internalServerError().body(msg); + new StringBuilder( + "Failed to obtain a response as the content was flagged by input filter. Error %d" + .formatted(e.getStatusCode())); + + Optional.of(e.getFilterDetails()) + .map(filter -> (Map) filter.get("azure_content_safety")) + .map(details -> (Integer) details.get("Violence")) + .filter(score -> score > policy.getAzureThreshold().getValue()) + .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); + + log.debug(msg.toString(), e); + return ResponseEntity.internalServerError().body(msg.toString()); } if ("json".equals(format)) { @@ -149,10 +160,19 @@ Object outputFiltering( final String content; try { content = response.getContent(); - } catch (OrchestrationFilterException.OrchestrationOutputFilterException e) { - final var msg = "Failed to obtain a response as the content was flagged by output filter."; - log.debug(msg, e); - return ResponseEntity.internalServerError().body(msg); + } catch (OrchestrationOutputFilterException e) { + final var msg = + new StringBuilder( + "Failed to obtain a response as the content was flagged by output filter."); + + Optional.of(e.getFilterDetails()) + .map(filter -> (Map) filter.get("azure_content_safety")) + .map(details -> (Integer) details.get("Violence")) + .filter(score -> score > policy.getAzureThreshold().getValue()) + .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); + + log.debug(msg.toString(), e); + return ResponseEntity.internalServerError().body(msg.toString()); } if ("json".equals(format)) { @@ -170,9 +190,9 @@ Object llamaGuardInputFiltering( final OrchestrationChatResponse response; try { response = service.llamaGuardInputFilter(enabled); - } catch (OrchestrationFilterException.OrchestrationInputFilterException e) { + } catch (OrchestrationInputFilterException e) { final var msg = - "Failed to obtain a %d response as the content was flagged by input filter." + "Failed to obtain a response as the content was flagged by input filter. Error %d" .formatted(e.getStatusCode()); log.debug(msg, e); return ResponseEntity.internalServerError().body(msg); diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 1fe61ecce..b57e12e1b 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -14,7 +14,8 @@ import com.sap.ai.sdk.orchestration.Message; import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -215,10 +216,21 @@ void testInputFilteringStrict() { var policy = AzureFilterThreshold.ALLOW_SAFE; assertThatThrownBy(() -> service.inputFiltering(policy)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) .hasMessageContaining( "Prompt filtered due to safety violations. Please modify the prompt and try again.") - .hasMessageContaining("400 Bad Request"); + .hasMessageContaining("400 (Bad Request)") + .isInstanceOfSatisfying( + OrchestrationInputFilterException.class, + e -> { + assertThat(e.getFilterDetails()).isNotNull(); + assertThat(e.getFilterDetails()).containsKey("azure_content_safety"); + assertThat(e.getFilterDetails().get("azure_content_safety")).isInstanceOf(Map.class); + + var actualAzureContentSafety = + (Map) e.getFilterDetails().get("azure_content_safety"); + assertThat(actualAzureContentSafety) + .containsKeys("Hate", "Violence", "Sexual", "SelfHarm"); + }); } @Test @@ -240,8 +252,19 @@ void testOutputFilteringStrict() { var response = service.outputFiltering(policy); assertThatThrownBy(response::getContent) - .isInstanceOf(OrchestrationFilterException.OrchestrationOutputFilterException.class) - .hasMessageContaining("Content filter filtered the output."); + .hasMessageContaining("Content filter filtered the output.") + .isInstanceOfSatisfying( + OrchestrationOutputFilterException.class, + e -> { + assertThat(e.getFilterDetails()).isNotNull(); + assertThat(e.getFilterDetails()).containsKey("azure_content_safety"); + assertThat(e.getFilterDetails().get("azure_content_safety")).isInstanceOf(Map.class); + + var actualAzureContentSafety = + (Map) e.getFilterDetails().get("azure_content_safety"); + assertThat(actualAzureContentSafety) + .containsKeys("Hate", "Violence", "Sexual", "SelfHarm"); + }); } @Test @@ -260,10 +283,10 @@ void testOutputFilteringLenient() { @Test void testLlamaGuardEnabled() { assertThatThrownBy(() -> service.llamaGuardInputFilter(true)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) + .isInstanceOf(OrchestrationInputFilterException.class) .hasMessageContaining( "Prompt filtered due to safety violations. Please modify the prompt and try again.") - .hasMessageContaining("400 Bad Request"); + .hasMessageContaining("400 (Bad Request)"); } @Test @@ -378,7 +401,7 @@ void testStreamingErrorHandlingTemplate() { assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithTemplate)) .isInstanceOf(OrchestrationClientException.class) - .hasMessageContaining("status 400 Bad Request") + .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("Error processing template:"); } @@ -389,8 +412,8 @@ void testStreamingErrorHandlingInputFilter() { val configWithFilter = config.withInputFiltering(filterConfig); assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithFilter)) - .isInstanceOf(OrchestrationFilterException.OrchestrationInputFilterException.class) - .hasMessageContaining("status 400 Bad Request") + .isInstanceOf(OrchestrationInputFilterException.class) + .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("Filtering Module - Input Filter"); } @@ -403,7 +426,7 @@ void testStreamingErrorHandlingMasking() { assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithMasking)) .isInstanceOf(OrchestrationClientException.class) - .hasMessageContaining("status 400 Bad Request") + .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("'unknown_default_open_api' is not one of"); } From bcf8c444a6b7ef1d05be3320b679afd50cc5eee1 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 22 Jul 2025 10:41:39 +0200 Subject: [PATCH 11/30] Filter exc classes non static and setter for client error --- .../ai/sdk/core/common/ClientException.java | 4 ++- .../openai/OpenAiClientException.java | 2 +- .../OrchestrationChatResponse.java | 1 - .../orchestration/OrchestrationClient.java | 1 - .../OrchestrationClientException.java | 2 +- .../OrchestrationExceptionFactory.java | 1 - .../OrchestrationFilterException.java | 36 +------------------ .../OrchestrationInputFilterException.java | 23 ++++++++++++ .../OrchestrationOutputFilterException.java | 19 ++++++++++ .../orchestration/OrchestrationUnitTest.java | 2 -- .../controllers/OrchestrationController.java | 4 +-- .../app/controllers/OrchestrationTest.java | 4 +-- 12 files changed, 52 insertions(+), 47 deletions(-) create mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java create mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java index 53199756f..40fd04141 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java @@ -3,6 +3,7 @@ import com.google.common.annotations.Beta; import javax.annotation.Nullable; import lombok.Getter; +import lombok.Setter; import lombok.experimental.StandardException; /** @@ -20,5 +21,6 @@ public class ClientException extends RuntimeException { */ @Nullable @Getter(onMethod_ = @Beta) - public ClientError clientError; + @Setter(onMethod_ = @Beta) + ClientError clientError; } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java index a561b179c..869e5e467 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java @@ -11,7 +11,7 @@ public class OpenAiClientException extends ClientException { OpenAiClientException(@Nonnull final String message, @Nonnull final OpenAiError clientError) { super(message); - this.clientError = clientError; + setClientError(clientError); } @Beta diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 1c2536f61..fe479bd84 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.AssistantChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 4a3a8776d..9efc6e4fd 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.CompletionPostRequest; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.EmbeddingsPostRequest; diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index 382c744dc..b83324393 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -15,7 +15,7 @@ public class OrchestrationClientException extends ClientException { OrchestrationClientException( @Nonnull final String message, @Nonnull final OrchestrationError clientError) { super(message); - this.clientError = clientError; + setClientError(clientError); } @Beta diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index c831a02a9..eb559bfcb 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -2,7 +2,6 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; import java.util.Collections; import java.util.Map; import java.util.Optional; diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index 7277a8ef8..eb4ebf67a 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -14,43 +14,9 @@ * orchestration process. */ @Beta -@StandardException(access = AccessLevel.PRIVATE) +@StandardException(access = AccessLevel.PROTECTED) public class OrchestrationFilterException extends OrchestrationClientException { /** Details about the filter that caused the exception. */ @Getter @Nonnull protected Map filterDetails = Map.of(); - - /** Exception thrown when an error occurs during input filtering in orchestration. */ - public static class OrchestrationInputFilterException extends OrchestrationFilterException { - /** - * Constructs a new OrchestrationInputFilterException. - * - * @param message the detail message - * @param clientError the specific client error - * @param filterDetails details about the filter that caused the exception - */ - OrchestrationInputFilterException( - @Nonnull final String message, - @Nonnull final OrchestrationError clientError, - @Nonnull final Map filterDetails) { - super(message); - this.clientError = clientError; - this.filterDetails = filterDetails; - } - } - - /** Exception thrown output filtering in orchestration when finish reason is content filter */ - public static class OrchestrationOutputFilterException extends OrchestrationFilterException { - /** - * Constructs a new OrchestrationOutputFilterException. - * - * @param message the detail message - * @param filterDetails details about the filter that caused the exception - */ - OrchestrationOutputFilterException( - @Nonnull final String message, @Nonnull final Map filterDetails) { - super(message); - this.filterDetails = filterDetails; - } - } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java new file mode 100644 index 000000000..a91703058 --- /dev/null +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java @@ -0,0 +1,23 @@ +package com.sap.ai.sdk.orchestration; + +import java.util.Map; +import javax.annotation.Nonnull; + +/** Exception thrown when an error occurs during input filtering in orchestration. */ +public class OrchestrationInputFilterException extends OrchestrationFilterException { + /** + * Constructs a new OrchestrationInputFilterException. + * + * @param message the detail message + * @param clientError the specific client error + * @param filterDetails details about the filter that caused the exception + */ + OrchestrationInputFilterException( + @Nonnull final String message, + @Nonnull final OrchestrationError clientError, + @Nonnull final Map filterDetails) { + super(message); + setClientError(clientError); + this.filterDetails = filterDetails; + } +} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java new file mode 100644 index 000000000..757e9c69a --- /dev/null +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java @@ -0,0 +1,19 @@ +package com.sap.ai.sdk.orchestration; + +import java.util.Map; +import javax.annotation.Nonnull; + +/** Exception thrown output filtering in orchestration when finish reason is content filter */ +public class OrchestrationOutputFilterException extends OrchestrationFilterException { + /** + * Constructs a new OrchestrationOutputFilterException. + * + * @param message the detail message + * @param filterDetails details about the filter that caused the exception + */ + OrchestrationOutputFilterException( + @Nonnull final String message, @Nonnull final Map filterDetails) { + super(message); + this.filterDetails = filterDetails; + } +} diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index f0f043ba9..db6699d35 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -38,8 +38,6 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.github.tomakehurst.wiremock.stubbing.Scenario; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.ChatDelta; import com.sap.ai.sdk.orchestration.model.DPIConfig; import com.sap.ai.sdk.orchestration.model.DPIEntities; diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index b216692c0..71c3ac6c6 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -5,8 +5,8 @@ import com.sap.ai.sdk.app.services.OrchestrationService; import com.sap.ai.sdk.orchestration.AzureFilterThreshold; import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.DPIEntities; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index b57e12e1b..d6b2b9495 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -14,9 +14,9 @@ import com.sap.ai.sdk.orchestration.Message; import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationInputFilterException; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; +import com.sap.ai.sdk.orchestration.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; import com.sap.ai.sdk.orchestration.TextItem; From 8e3a37951e7e940486f0f038183ddfaae4867fa6 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 22 Jul 2025 10:48:22 +0200 Subject: [PATCH 12/30] Filter exc classes better static --- .../OrchestrationChatResponse.java | 1 + .../orchestration/OrchestrationClient.java | 4 ++- .../OrchestrationExceptionFactory.java | 1 + .../OrchestrationFilterException.java | 36 ++++++++++++++++++- .../OrchestrationInputFilterException.java | 23 ------------ .../OrchestrationOutputFilterException.java | 19 ---------- .../orchestration/OrchestrationUnitTest.java | 2 ++ .../controllers/OrchestrationController.java | 4 +-- .../app/controllers/OrchestrationTest.java | 4 +-- 9 files changed, 46 insertions(+), 48 deletions(-) delete mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java delete mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index fe479bd84..1c2536f61 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.AssistantChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 9efc6e4fd..4fe88959f 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.CompletionPostRequest; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.EmbeddingsPostRequest; @@ -113,7 +114,8 @@ public Stream streamChatCompletion( .map(OrchestrationChatCompletionDelta::getDeltaContent); } - private static void throwOnContentFilter(@Nonnull final OrchestrationChatCompletionDelta delta) { + private static void throwOnContentFilter(@Nonnull final OrchestrationChatCompletionDelta delta) + throws OrchestrationOutputFilterException { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { final var filterDetails = diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index eb559bfcb..c831a02a9 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -2,6 +2,7 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; import java.util.Collections; import java.util.Map; import java.util.Optional; diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index eb4ebf67a..795cc1d39 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -14,9 +14,43 @@ * orchestration process. */ @Beta -@StandardException(access = AccessLevel.PROTECTED) +@StandardException(access = AccessLevel.PRIVATE) public class OrchestrationFilterException extends OrchestrationClientException { /** Details about the filter that caused the exception. */ @Getter @Nonnull protected Map filterDetails = Map.of(); + + /** Exception thrown when an error occurs during input filtering in orchestration. */ + public static class OrchestrationInputFilterException extends OrchestrationFilterException { + /** + * Constructs a new OrchestrationInputFilterException. + * + * @param message the detail message + * @param clientError the specific client error + * @param filterDetails details about the filter that caused the exception + */ + OrchestrationInputFilterException( + @Nonnull final String message, + @Nonnull final OrchestrationError clientError, + @Nonnull final Map filterDetails) { + super(message); + setClientError(clientError); + this.filterDetails = filterDetails; + } + } + + /** Exception thrown output filtering in orchestration when finish reason is content filter */ + public static class OrchestrationOutputFilterException extends OrchestrationFilterException { + /** + * Constructs a new OrchestrationOutputFilterException. + * + * @param message the detail message + * @param filterDetails details about the filter that caused the exception + */ + OrchestrationOutputFilterException( + @Nonnull final String message, @Nonnull final Map filterDetails) { + super(message); + this.filterDetails = filterDetails; + } + } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java deleted file mode 100644 index a91703058..000000000 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationInputFilterException.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.sap.ai.sdk.orchestration; - -import java.util.Map; -import javax.annotation.Nonnull; - -/** Exception thrown when an error occurs during input filtering in orchestration. */ -public class OrchestrationInputFilterException extends OrchestrationFilterException { - /** - * Constructs a new OrchestrationInputFilterException. - * - * @param message the detail message - * @param clientError the specific client error - * @param filterDetails details about the filter that caused the exception - */ - OrchestrationInputFilterException( - @Nonnull final String message, - @Nonnull final OrchestrationError clientError, - @Nonnull final Map filterDetails) { - super(message); - setClientError(clientError); - this.filterDetails = filterDetails; - } -} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java deleted file mode 100644 index 757e9c69a..000000000 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationOutputFilterException.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sap.ai.sdk.orchestration; - -import java.util.Map; -import javax.annotation.Nonnull; - -/** Exception thrown output filtering in orchestration when finish reason is content filter */ -public class OrchestrationOutputFilterException extends OrchestrationFilterException { - /** - * Constructs a new OrchestrationOutputFilterException. - * - * @param message the detail message - * @param filterDetails details about the filter that caused the exception - */ - OrchestrationOutputFilterException( - @Nonnull final String message, @Nonnull final Map filterDetails) { - super(message); - this.filterDetails = filterDetails; - } -} diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index db6699d35..f0f043ba9 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -38,6 +38,8 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.github.tomakehurst.wiremock.stubbing.Scenario; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.ChatDelta; import com.sap.ai.sdk.orchestration.model.DPIConfig; import com.sap.ai.sdk.orchestration.model.DPIEntities; diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index 71c3ac6c6..b216692c0 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -5,8 +5,8 @@ import com.sap.ai.sdk.app.services.OrchestrationService; import com.sap.ai.sdk.orchestration.AzureFilterThreshold; import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; -import com.sap.ai.sdk.orchestration.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.DPIEntities; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index d6b2b9495..862423a5f 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -14,9 +14,9 @@ import com.sap.ai.sdk.orchestration.Message; import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; -import com.sap.ai.sdk.orchestration.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; -import com.sap.ai.sdk.orchestration.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; import com.sap.ai.sdk.orchestration.TextItem; From 25f2758060a6e3fb09ce09db739e544337e6a317 Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Tue, 22 Jul 2025 08:49:09 +0000 Subject: [PATCH 13/30] Formatting --- .../java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 862423a5f..b57e12e1b 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -15,8 +15,8 @@ import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; import com.sap.ai.sdk.orchestration.TextItem; From e503900f2e4d818a287b6bd20523ea232615f0b6 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 22 Jul 2025 11:16:48 +0200 Subject: [PATCH 14/30] Suppress unchecked warning --- .../sap/ai/sdk/orchestration/OrchestrationChatResponse.java | 4 ++-- .../com/sap/ai/sdk/orchestration/OrchestrationClient.java | 2 +- .../ai/sdk/orchestration/OrchestrationExceptionFactory.java | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 1c2536f61..a634aade0 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -43,11 +43,11 @@ public String getContent() throws OrchestrationOutputFilterException { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { - final var filterDetails = + @SuppressWarnings("unchecked") final var filterDetails = Optional.of(getOriginalResponse().getModuleResults().getOutputFiltering()) .map(outputFiltering -> (Map) outputFiltering.getData()) .map(data -> (List>) data.get("choices")) - .map(choices -> choices.get(0)) + .map(List::getFirst) .orElseGet(Map::of); throw new OrchestrationOutputFilterException( diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 4fe88959f..83b335fdc 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -118,7 +118,7 @@ private static void throwOnContentFilter(@Nonnull final OrchestrationChatComplet throws OrchestrationOutputFilterException { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { - final var filterDetails = + @SuppressWarnings("unchecked") final var filterDetails = Optional.ofNullable(delta.getModuleResults().getOutputFiltering()) .map(outputFiltering -> (Map) outputFiltering.getData()) .map(data -> (List>) data.get("choices")) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index c831a02a9..255c661a6 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -32,10 +32,11 @@ public OrchestrationClientException buildFromClientError( return new OrchestrationClientException(message, clientError); } + @SuppressWarnings("unchecked") @Nonnull private Map extractInputFilterDetails(@Nonnull final OrchestrationError error) { - - return Optional.ofNullable(error.getOriginalResponse()) + + return Optional.of(error.getOriginalResponse()) .flatMap(response -> Optional.ofNullable(response.getModuleResults())) .flatMap(moduleResults -> Optional.ofNullable(moduleResults.getInputFiltering())) .flatMap(inputFiltering -> Optional.ofNullable(inputFiltering.getData())) From 31892ea4e2ecc15c67f626ed88e513f5f10224af Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Tue, 22 Jul 2025 09:17:30 +0000 Subject: [PATCH 15/30] Formatting --- .../sap/ai/sdk/orchestration/OrchestrationChatResponse.java | 3 ++- .../com/sap/ai/sdk/orchestration/OrchestrationClient.java | 3 ++- .../ai/sdk/orchestration/OrchestrationExceptionFactory.java | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index a634aade0..aaa77f4d2 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -43,7 +43,8 @@ public String getContent() throws OrchestrationOutputFilterException { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { - @SuppressWarnings("unchecked") final var filterDetails = + @SuppressWarnings("unchecked") + final var filterDetails = Optional.of(getOriginalResponse().getModuleResults().getOutputFiltering()) .map(outputFiltering -> (Map) outputFiltering.getData()) .map(data -> (List>) data.get("choices")) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 83b335fdc..73884c410 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -118,7 +118,8 @@ private static void throwOnContentFilter(@Nonnull final OrchestrationChatComplet throws OrchestrationOutputFilterException { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { - @SuppressWarnings("unchecked") final var filterDetails = + @SuppressWarnings("unchecked") + final var filterDetails = Optional.ofNullable(delta.getModuleResults().getOutputFiltering()) .map(outputFiltering -> (Map) outputFiltering.getData()) .map(data -> (List>) data.get("choices")) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 255c661a6..9333f0ab4 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -35,8 +35,8 @@ public OrchestrationClientException buildFromClientError( @SuppressWarnings("unchecked") @Nonnull private Map extractInputFilterDetails(@Nonnull final OrchestrationError error) { - - return Optional.of(error.getOriginalResponse()) + + return Optional.of(error.getOriginalResponse()) .flatMap(response -> Optional.ofNullable(response.getModuleResults())) .flatMap(moduleResults -> Optional.ofNullable(moduleResults.getInputFiltering())) .flatMap(inputFiltering -> Optional.ofNullable(inputFiltering.getData())) From 535c9066fb50b6190094aa15846685b082ba59ae Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 22 Jul 2025 11:29:14 +0200 Subject: [PATCH 16/30] cleaning up --- .../ai/sdk/app/controllers/OrchestrationController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index b216692c0..2a574761c 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -134,8 +134,8 @@ Object inputFiltering( .formatted(e.getStatusCode())); Optional.of(e.getFilterDetails()) - .map(filter -> (Map) filter.get("azure_content_safety")) - .map(details -> (Integer) details.get("Violence")) + .map(filter -> (Map) filter.get("azure_content_safety")) + .map(details -> details.get("Violence")) .filter(score -> score > policy.getAzureThreshold().getValue()) .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); @@ -166,8 +166,8 @@ Object outputFiltering( "Failed to obtain a response as the content was flagged by output filter."); Optional.of(e.getFilterDetails()) - .map(filter -> (Map) filter.get("azure_content_safety")) - .map(details -> (Integer) details.get("Violence")) + .map(filter -> (Map) filter.get("azure_content_safety")) + .map(details -> details.get("Violence")) .filter(score -> score > policy.getAzureThreshold().getValue()) .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); From 2c0f2ea7a3ff61647cbbede0dfc0b56a5d172ba3 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 22 Jul 2025 13:53:39 +0200 Subject: [PATCH 17/30] fix method reference --- .../com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index aaa77f4d2..327426b52 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -48,7 +48,7 @@ public String getContent() throws OrchestrationOutputFilterException { Optional.of(getOriginalResponse().getModuleResults().getOutputFiltering()) .map(outputFiltering -> (Map) outputFiltering.getData()) .map(data -> (List>) data.get("choices")) - .map(List::getFirst) + .map(choices -> choices.get(0)) .orElseGet(Map::of); throw new OrchestrationOutputFilterException( From 6d3525f7a26b07d2ee281158ec2e8296a24ecc7a Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 25 Jul 2025 16:06:16 +0200 Subject: [PATCH 18/30] Shortcut getErrorResponse mask clientError add better testing add filter specific getter in exception --- .../ai/sdk/core/common/ClientException.java | 5 +- .../openai/OpenAiClientException.java | 15 +++- .../foundationmodels/openai/OpenAiError.java | 4 +- .../OrchestrationClientException.java | 19 +++-- .../sdk/orchestration/OrchestrationError.java | 8 +- .../OrchestrationExceptionFactory.java | 2 +- .../OrchestrationFilterException.java | 80 +++++++++++++++---- .../orchestration/OrchestrationUnitTest.java | 65 ++++++++++++--- .../__files/outputFilteringStrict.json | 25 +++--- .../__files/strictInputFilterResponse.json | 14 +++- .../controllers/OrchestrationController.java | 21 +++-- 11 files changed, 187 insertions(+), 71 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java index 40fd04141..cd311993f 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java @@ -2,6 +2,7 @@ import com.google.common.annotations.Beta; import javax.annotation.Nullable; +import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; import lombok.experimental.StandardException; @@ -20,7 +21,7 @@ public class ClientException extends RuntimeException { * used to extract more detailed error information. */ @Nullable - @Getter(onMethod_ = @Beta) - @Setter(onMethod_ = @Beta) + @Getter(onMethod_ = @Beta, value = AccessLevel.PROTECTED) + @Setter(onMethod_ = @Beta, value = AccessLevel.PROTECTED) ClientError clientError; } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java index 869e5e467..ca3e9d50a 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientException.java @@ -2,6 +2,7 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientException; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.ErrorResponse; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.experimental.StandardException; @@ -14,10 +15,18 @@ public class OpenAiClientException extends ClientException { setClientError(clientError); } + /** + * Retrieves the {@link ErrorResponse} from the OpenAI service, if available. + * + * @return The {@link ErrorResponse} object, or {@code null} if not available. + */ @Beta @Nullable - @Override - public OpenAiError getClientError() { - return (OpenAiError) super.getClientError(); + public ErrorResponse getErrorResponse() { + final var clientError = super.getClientError(); + if (clientError instanceof OpenAiError openAiError) { + return openAiError.getErrorResponse(); + } + return null; } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiError.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiError.java index d6618fd2a..a5c9888dc 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiError.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiError.java @@ -19,7 +19,7 @@ @AllArgsConstructor(onConstructor = @__({@JsonCreator}), access = AccessLevel.PROTECTED) public class OpenAiError implements ClientError { /** The original error response from the OpenAI API. */ - ErrorResponse originalResponse; + ErrorResponse errorResponse; /** * Gets the error message from the contained original response. @@ -28,6 +28,6 @@ public class OpenAiError implements ClientError { */ @Nonnull public String getMessage() { - return originalResponse.getError().getMessage(); + return errorResponse.getError().getMessage(); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java index b83324393..34493792f 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClientException.java @@ -18,11 +18,19 @@ public class OrchestrationClientException extends ClientException { setClientError(clientError); } + /** + * Retrieves the {@link ErrorResponse} from the orchestration service, if available. + * + * @return The {@link ErrorResponse} object, or {@code null} if not available. + */ @Beta @Nullable - @Override - public OrchestrationError getClientError() { - return (OrchestrationError) super.getClientError(); + public ErrorResponse getErrorResponse() { + final var clientError = super.getClientError(); + if (clientError instanceof OrchestrationError orchestrationError) { + return orchestrationError.getErrorResponse(); + } + return null; } /** @@ -33,9 +41,6 @@ public OrchestrationError getClientError() { @Beta @Nullable public Integer getStatusCode() { - return Optional.ofNullable(getClientError()) - .map(OrchestrationError::getOriginalResponse) - .map(ErrorResponse::getCode) - .orElse(null); + return Optional.ofNullable(getErrorResponse()).map(ErrorResponse::getCode).orElse(null); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationError.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationError.java index 4d5956edd..124535796 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationError.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationError.java @@ -18,7 +18,7 @@ @Value @Beta public class OrchestrationError implements ClientError { - ErrorResponse originalResponse; + ErrorResponse errorResponse; /** * Gets the error message from the contained original response. @@ -27,8 +27,8 @@ public class OrchestrationError implements ClientError { */ @Nonnull public String getMessage() { - return originalResponse.getCode() == 500 - ? originalResponse.getMessage() + " located in " + originalResponse.getLocation() - : originalResponse.getMessage(); + return errorResponse.getCode() == 500 + ? errorResponse.getMessage() + " located in " + errorResponse.getLocation() + : errorResponse.getMessage(); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 9333f0ab4..8c9a6cb66 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -36,7 +36,7 @@ public OrchestrationClientException buildFromClientError( @Nonnull private Map extractInputFilterDetails(@Nonnull final OrchestrationError error) { - return Optional.of(error.getOriginalResponse()) + return Optional.of(error.getErrorResponse()) .flatMap(response -> Optional.ofNullable(response.getModuleResults())) .flatMap(moduleResults -> Optional.ofNullable(moduleResults.getInputFiltering())) .flatMap(inputFiltering -> Optional.ofNullable(inputFiltering.getData())) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index 795cc1d39..ecd234751 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -1,33 +1,49 @@ package com.sap.ai.sdk.orchestration; +import static com.sap.ai.sdk.orchestration.OrchestrationJacksonConfiguration.getOrchestrationObjectMapper; + import com.google.common.annotations.Beta; +import com.sap.ai.sdk.orchestration.model.AzureContentSafetyInput; +import com.sap.ai.sdk.orchestration.model.AzureContentSafetyOutput; +import com.sap.ai.sdk.orchestration.model.LlamaGuard38b; import java.util.Map; +import java.util.Optional; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.AccessLevel; import lombok.Getter; import lombok.experimental.StandardException; -/** - * Exception thrown when an error occurs during orchestration filtering. - * - *

This exception serves as the base for more specific filter-related exceptions in the - * orchestration process. - */ +/** Base exception for errors occurring during orchestration filtering. */ @Beta @StandardException(access = AccessLevel.PRIVATE) public class OrchestrationFilterException extends OrchestrationClientException { - /** Details about the filter that caused the exception. */ + /** Details about the filters that caused the exception. */ @Getter @Nonnull protected Map filterDetails = Map.of(); - /** Exception thrown when an error occurs during input filtering in orchestration. */ + /** + * Retrieves LlamaGuard 3.8b details from {@code filterDetails}, if present. + * + * @return The LlamaGuard38b object, or {@code null} if not found or conversion fails. + * @throws IllegalArgumentException if the conversion of filter details to {@link LlamaGuard38b} + * fails due to invalid content. + */ + @Nullable + public LlamaGuard38b getLlamaGuard38b() { + return Optional.ofNullable(filterDetails.get("llama_guard_3_8b")) + .map(obj -> getOrchestrationObjectMapper().convertValue(obj, LlamaGuard38b.class)) + .orElse(null); + } + + /** Exception thrown when an error occurs during input filtering. */ public static class OrchestrationInputFilterException extends OrchestrationFilterException { /** * Constructs a new OrchestrationInputFilterException. * - * @param message the detail message - * @param clientError the specific client error - * @param filterDetails details about the filter that caused the exception + * @param message The detail message. + * @param clientError The specific client error. + * @param filterDetails Details about the filter that caused the exception. */ OrchestrationInputFilterException( @Nonnull final String message, @@ -37,20 +53,56 @@ public static class OrchestrationInputFilterException extends OrchestrationFilte setClientError(clientError); this.filterDetails = filterDetails; } + + /** + * Retrieves Azure Content Safety input details from {@code filterDetails}, if present. + * + * @return The AzureContentSafetyInput object, or {@code null} if not found or conversion fails. + * @throws IllegalArgumentException if the conversion of filter details to {@link + * AzureContentSafetyInput} fails due to invalid content. + */ + @Nullable + public AzureContentSafetyInput getAzureContentSafetyInput() { + return Optional.ofNullable(filterDetails.get("azure_content_safety")) + .map( + obj -> + getOrchestrationObjectMapper().convertValue(obj, AzureContentSafetyInput.class)) + .orElse(null); + } } - /** Exception thrown output filtering in orchestration when finish reason is content filter */ + /** + * Exception thrown when an error occurs during output filtering, specifically when the finish + * reason is a content filter. + */ public static class OrchestrationOutputFilterException extends OrchestrationFilterException { /** * Constructs a new OrchestrationOutputFilterException. * - * @param message the detail message - * @param filterDetails details about the filter that caused the exception + * @param message The detail message. + * @param filterDetails Details about the filter that caused the exception. */ OrchestrationOutputFilterException( @Nonnull final String message, @Nonnull final Map filterDetails) { super(message); this.filterDetails = filterDetails; } + + /** + * Retrieves Azure Content Safety output details from {@code filterDetails}, if present. + * + * @return The AzureContentSafetyOutput object, or {@code null} if not found or conversion + * fails. + * @throws IllegalArgumentException if the conversion of filter details to {@link + * AzureContentSafetyOutput} fails due to invalid content. + */ + @Nullable + public AzureContentSafetyOutput getAzureContentSafetyOutput() { + return Optional.ofNullable(filterDetails.get("azure_content_safety")) + .map( + obj -> + getOrchestrationObjectMapper().convertValue(obj, AzureContentSafetyOutput.class)) + .orElse(null); + } } } diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index f0f043ba9..8c08c8135 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -20,6 +20,8 @@ import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GPT_4O; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GPT_4O_MINI; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.Parameter.*; +import static com.sap.ai.sdk.orchestration.model.AzureThreshold.NUMBER_0; +import static com.sap.ai.sdk.orchestration.model.AzureThreshold.NUMBER_6; import static com.sap.ai.sdk.orchestration.model.ResponseChatMessage.RoleEnum.ASSISTANT; import static com.sap.ai.sdk.orchestration.model.UserChatMessage.RoleEnum.USER; import static org.apache.hc.core5.http.HttpStatus.SC_BAD_REQUEST; @@ -57,6 +59,7 @@ import com.sap.ai.sdk.orchestration.model.EmbeddingsPostRequest; import com.sap.ai.sdk.orchestration.model.EmbeddingsPostResponse; import com.sap.ai.sdk.orchestration.model.EmbeddingsResponse; +import com.sap.ai.sdk.orchestration.model.ErrorResponse; import com.sap.ai.sdk.orchestration.model.GenericModuleResult; import com.sap.ai.sdk.orchestration.model.GroundingFilterSearchConfiguration; import com.sap.ai.sdk.orchestration.model.GroundingModuleConfig; @@ -400,7 +403,6 @@ void filteringLoose() throws IOException { @Test void inputFilteringStrict() { - stubFor( post(anyUrl()) .willReturn( @@ -409,14 +411,16 @@ void inputFilteringStrict() { .withHeader("Content-Type", "application/json") .withStatus(SC_BAD_REQUEST))); - final var filter = + final var azureFilter = new AzureContentFilter() .hate(ALLOW_SAFE) .selfHarm(ALLOW_SAFE) .sexual(ALLOW_SAFE) .violence(ALLOW_SAFE); - final var configWithFilter = config.withInputFiltering(filter); + final var llamaFilter = + new LlamaGuardFilter().config(LlamaGuard38b.create().violentCrimes(true)); + final var configWithFilter = config.withInputFiltering(azureFilter, llamaFilter); try { client.chatCompletion(prompt, configWithFilter); @@ -429,9 +433,30 @@ void inputFilteringStrict() { .isEqualTo( Map.of( "azure_content_safety", - Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 0))); - assertThat(e.getClientError()).isNotNull(); - assertThat(e.getClientError()).isInstanceOf(OrchestrationError.class); + Map.of( + "Hate", 6, + "SelfHarm", 0, + "Sexual", 0, + "Violence", 6, + "userPromptAnalysis", Map.of("attackDetected", false)), + "llama_guard_3_8b", Map.of("violent_crimes", true))); + + final var errorResponse = e.getErrorResponse(); + assertThat(errorResponse).isNotNull(); + assertThat(errorResponse).isInstanceOf(ErrorResponse.class); + assertThat(errorResponse.getCode()).isEqualTo(SC_BAD_REQUEST); + assertThat(errorResponse.getMessage()) + .isEqualTo( + "400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again."); + + assertThat(e.getAzureContentSafetyInput()).isNotNull(); + assertThat(e.getAzureContentSafetyInput().getHate()).isEqualTo(NUMBER_6); + assertThat(e.getAzureContentSafetyInput().getSelfHarm()).isEqualTo(NUMBER_0); + assertThat(e.getAzureContentSafetyInput().getSexual()).isEqualTo(NUMBER_0); + assertThat(e.getAzureContentSafetyInput().getViolence()).isEqualTo(NUMBER_6); + + assertThat(e.getLlamaGuard38b()).isNotNull(); + assertThat(e.getLlamaGuard38b().isViolentCrimes()).isTrue(); } } @@ -439,14 +464,16 @@ void inputFilteringStrict() { void outputFilteringStrict() { stubFor(post(anyUrl()).willReturn(aResponse().withBodyFile("outputFilteringStrict.json"))); - final var filter = + final var azureFilter = new AzureContentFilter() .hate(ALLOW_SAFE) .selfHarm(ALLOW_SAFE) .sexual(ALLOW_SAFE) .violence(ALLOW_SAFE); - final var configWithFilter = config.withOutputFiltering(filter); + final var llamaFilter = + new LlamaGuardFilter().config(LlamaGuard38b.create().violentCrimes(true)); + final var configWithFilter = config.withOutputFiltering(azureFilter, llamaFilter); try { client.chatCompletion(prompt, configWithFilter).getContent(); @@ -455,11 +482,25 @@ void outputFilteringStrict() { assertThat(e.getFilterDetails()) .isEqualTo( Map.of( - "index", - 0, + "index", 0, "azure_content_safety", - Map.of("Hate", 0, "SelfHarm", 0, "Sexual", 0, "Violence", 4))); - assertThat(e.getClientError()).isNull(); + Map.of( + "Hate", 6, + "SelfHarm", 0, + "Sexual", 0, + "Violence", 6), + "llama_guard_3_8b", Map.of("violent_crimes", true))); + assertThat(e.getErrorResponse()).isNull(); + assertThat(e.getStatusCode()).isNull(); + + assertThat(e.getAzureContentSafetyOutput()).isNotNull(); + assertThat(e.getAzureContentSafetyOutput().getHate()).isEqualTo(NUMBER_6); + assertThat(e.getAzureContentSafetyOutput().getSelfHarm()).isEqualTo(NUMBER_0); + assertThat(e.getAzureContentSafetyOutput().getSexual()).isEqualTo(NUMBER_0); + assertThat(e.getAzureContentSafetyOutput().getViolence()).isEqualTo(NUMBER_6); + + assertThat(e.getLlamaGuard38b()).isNotNull(); + assertThat(e.getLlamaGuard38b().isViolentCrimes()).isTrue(); } } diff --git a/orchestration/src/test/resources/__files/outputFilteringStrict.json b/orchestration/src/test/resources/__files/outputFilteringStrict.json index 667b11ef0..72e69619f 100644 --- a/orchestration/src/test/resources/__files/outputFilteringStrict.json +++ b/orchestration/src/test/resources/__files/outputFilteringStrict.json @@ -1,5 +1,5 @@ { - "request_id": "4868def8-d509-4697-9de0-5920d69d06da", + "request_id": "3db65fd0-7945-45ed-82f5-fe3811325fd5", "module_results": { "templating": [ { @@ -7,7 +7,7 @@ "content": "Give three paraphrases for the following sentence" }, { - "content": "'We shall spill blood tonight', said the operation in-charge.", + "content": "'Lets paint the parliament in red with the blood of our enemies'", "role": "user" } ], @@ -18,10 +18,13 @@ { "index": 0, "azure_content_safety": { - "Hate": 0, + "Hate": 6, "SelfHarm": 0, "Sexual": 0, - "Violence": 4 + "Violence": 6 + }, + "llama_guard_3_8b": { + "violent_crimes": true } } ] @@ -30,29 +33,29 @@ "llm": { "id": "", "object": "chat.completion", - "created": 1752500843, + "created": 1753449453, "model": "gemini-1.5-flash", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "1. \"There will be bloodshed tonight,\" the operation leader announced.\n2. The head of the operation declared, \"Tonight, we will have casualties.\"\n3. \"Tonight's operation will result in fatalities,\" the commander stated grimly.\n" + "content": "Here are three paraphrases of \"Let's paint the parliament in red with the blood of our enemies,\" each with a slightly different emphasis:\n\n1. **We will violently overthrow the parliament and slaughter our foes.** (This version emphasizes the violent action and the killing of enemies.)\n\n2. **The parliament will be drenched in the blood of our enemies; we will make them pay.** (This version focuses on the consequence of the action and the retribution.)\n\n3. **Our enemies' blood will stain the parliament building as a symbol of our victory.** (This version highlights the symbolic nature of the act, focusing on the visual representation of victory.)\n" }, "finish_reason": "stop" } ], "usage": { - "completion_tokens": 53, + "completion_tokens": 133, "prompt_tokens": 22, - "total_tokens": 75 + "total_tokens": 155 } } }, "orchestration_result": { "id": "", "object": "chat.completion", - "created": 1752500843, + "created": 1753449453, "model": "gemini-1.5-flash", "choices": [ { @@ -65,9 +68,9 @@ } ], "usage": { - "completion_tokens": 53, + "completion_tokens": 133, "prompt_tokens": 22, - "total_tokens": 75 + "total_tokens": 155 } } } diff --git a/orchestration/src/test/resources/__files/strictInputFilterResponse.json b/orchestration/src/test/resources/__files/strictInputFilterResponse.json index 59e6f0892..2cdaaa105 100644 --- a/orchestration/src/test/resources/__files/strictInputFilterResponse.json +++ b/orchestration/src/test/resources/__files/strictInputFilterResponse.json @@ -1,12 +1,12 @@ { - "request_id": "f44932a3-22a8-4a72-bccb-cfb51077a4b7", + "request_id": "7ed0869f-05be-406b-8124-e9d94516c190", "code": 400, "message": "400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again.", "location": "Filtering Module - Input Filter", "module_results": { "templating": [ { - "content": "Please rephrase the following sentence for me: 'We shall spill blood tonight', said the operation in-charge.", + "content": "Let us paint the parliament red with blood of our enemies.", "role": "user" } ], @@ -14,10 +14,16 @@ "message": "Prompt filtered due to safety violations. Please modify the prompt and try again.", "data": { "azure_content_safety": { - "Hate": 0, + "Hate": 6, "SelfHarm": 0, "Sexual": 0, - "Violence": 0 + "Violence": 6, + "userPromptAnalysis": { + "attackDetected": false + } + }, + "llama_guard_3_8b": { + "violent_crimes": true } } } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index 2a574761c..80f0ba861 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -7,13 +7,14 @@ import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.model.AzureContentSafetyInput; +import com.sap.ai.sdk.orchestration.model.AzureContentSafetyOutput; import com.sap.ai.sdk.orchestration.model.DPIEntities; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.List; -import java.util.Map; import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -133,11 +134,10 @@ Object inputFiltering( "Failed to obtain a response as the content was flagged by input filter. Error %d" .formatted(e.getStatusCode())); - Optional.of(e.getFilterDetails()) - .map(filter -> (Map) filter.get("azure_content_safety")) - .map(details -> details.get("Violence")) - .filter(score -> score > policy.getAzureThreshold().getValue()) - .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); + Optional.ofNullable(e.getAzureContentSafetyInput()) + .map(AzureContentSafetyInput::getHate) + .filter(rating -> rating.compareTo(policy.getAzureThreshold()) > 0) + .ifPresent(rating -> msg.append("Hate score %d".formatted(rating.getValue()))); log.debug(msg.toString(), e); return ResponseEntity.internalServerError().body(msg.toString()); @@ -165,11 +165,10 @@ Object outputFiltering( new StringBuilder( "Failed to obtain a response as the content was flagged by output filter."); - Optional.of(e.getFilterDetails()) - .map(filter -> (Map) filter.get("azure_content_safety")) - .map(details -> details.get("Violence")) - .filter(score -> score > policy.getAzureThreshold().getValue()) - .ifPresent(score -> msg.append("Hate score %d > threshold.".formatted(score))); + Optional.ofNullable(e.getAzureContentSafetyOutput()) + .map(AzureContentSafetyOutput::getHate) + .filter(rating -> rating.compareTo(policy.getAzureThreshold()) > 0) + .ifPresent(rating -> msg.append("Hate score %d ".formatted(rating.getValue()))); log.debug(msg.toString(), e); return ResponseEntity.internalServerError().body(msg.toString()); From c9c5c9627ae8acde6453b19cbcd223c8f72da389 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 25 Jul 2025 16:26:36 +0200 Subject: [PATCH 19/30] merging change with careful logging --- .../com/sap/ai/sdk/core/common/ClientStreamingHandler.java | 2 +- .../sap/ai/sdk/core/common/ClientStreamingHandlerTest.java | 4 ++-- .../foundationmodels/openai/OpenAiClientGeneratedTest.java | 3 +-- .../sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java | 3 +-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java index 3c6873d64..1d27edb9d 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java @@ -69,7 +69,7 @@ public Stream handleStreamingResponse(@Nonnull final ClassicHttpResponse resp .peek( line -> { if (!line.startsWith("data: ")) { - final String msg = "Failed to parse response: %s".formatted(line); + final String msg = "Failed to parse response"; throw exceptionFactory.build(msg, null); } }) diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java index 78889cb7b..1145e4eed 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java @@ -99,12 +99,12 @@ void testHandleStreamingResponse_variousScenarios() { var stream3 = sut.handleStreamingResponse(response); assertThatThrownBy(() -> stream3.toList()) .isInstanceOf(MyException.class) - .hasMessageContaining("Failed to parse response: malformed line here"); + .hasMessageContaining("Failed to parse response"); var stream4 = sut.handleStreamingResponse(response); assertThatThrownBy(() -> stream4.toList()) .isInstanceOf(MyException.class) - .hasMessageContaining("Failed to parse delta message:") + .hasMessageContaining("Failed to parse delta chunk") .hasCauseInstanceOf(JsonParseException.class); } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java index 6c177b608..6b437a9e7 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java @@ -298,8 +298,7 @@ void streamChatCompletionDeltasErrorHandling() throws IOException { try (var stream = client.streamChatCompletionDeltas(request)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) .isInstanceOf(OpenAiClientException.class) - .hasMessage( - "Failed to parse response: {\"error\":{\"code\":\"429\",\"message\":\"exceeded token rate limit\"}}"); + .hasMessage("Failed to parse response"); } Mockito.verify(inputStream, times(1)).close(); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java index 1f5587931..abd9c85f6 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientTest.java @@ -261,8 +261,7 @@ void streamChatCompletionDeltasErrorHandling() throws IOException { try (var stream = client.streamChatCompletionDeltas(request)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) .isInstanceOf(OpenAiClientException.class) - .hasMessage( - "Failed to parse response: {\"error\":{\"code\":\"429\",\"message\":\"exceeded token rate limit\"}}"); + .hasMessage("Failed to parse response"); } Mockito.verify(inputStream, times(1)).close(); From 7b631c1cc2cf655311affb2b63069883bf6f2633 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 28 Jul 2025 15:09:49 +0200 Subject: [PATCH 20/30] Release notes --- docs/release_notes.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/release_notes.md b/docs/release_notes.md index fa5b5682e..bc3af924c 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -16,7 +16,13 @@ ### ✨ New Functionality -- +- [Core] Added `ClientExceptionFactory` interface to provide custom exception mapping logic for different service clients. +- Extend `OpenAiClientException` and `OrchestrationClientException` to retrieve error diagnostics information received from remote service using `getErrorResponse`. +- [Orchestration] Introduced filtering related exceptions along with convenience methods to obtain additional contextual information. + - `OrchestrationInputFilterException` for prompt filtering and `OrchestrationOutputFilterException` for response filtering. + - `getFilterDetails()`: Returns a map of all filter details. + - `getAzureContentSafetyInput()` and `getAzureContentSafetyInput()` : Returns Azure Content Safety filter scores + - `getLlamaGuard38b()`: Returns LlamaGuard filter scores ### 📈 Improvements From 13d0f91f0472f693a19c09e8e2e454aaabe0cc52 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal <36329474+rpanackal@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:02:03 +0200 Subject: [PATCH 21/30] Update core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java Co-authored-by: Charles Dubois <103174266+CharlesDuboisSAP@users.noreply.github.com> --- .../java/com/sap/ai/sdk/core/common/ClientResponseHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 9718e65c5..a6d349400 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -105,7 +105,6 @@ private Try tryGetContent(@Nonnull final HttpEntity entity) { * @throws ClientException if the response is an error (4xx/5xx) */ @SuppressWarnings("PMD.CloseResource") - @Nonnull protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { val entity = httpResponse.getEntity(); From 2bc9a242304edd9cea32cf0e01bb53c7d36974dc Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal <36329474+rpanackal@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:08:25 +0200 Subject: [PATCH 22/30] Update orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java Co-authored-by: Charles Dubois <103174266+CharlesDuboisSAP@users.noreply.github.com> --- .../com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index 8c08c8135..6ba201308 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -729,7 +729,7 @@ void testThrowsOnContentFilter() { // this must not throw, since the stream is lazily evaluated var stream = mock.streamChatCompletion(new OrchestrationPrompt(""), config); - assertThatThrownBy(() -> stream.toList()) + assertThatThrownBy(stream::toList) .isInstanceOf(OrchestrationOutputFilterException.class) .hasMessage("Content filter filtered the output.") .extracting(e -> ((OrchestrationOutputFilterException) e).getFilterDetails()) From 057856003d961b0962defd6f80014798b7d2f799 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 28 Jul 2025 16:14:39 +0200 Subject: [PATCH 23/30] Sample app tests use filter specific convenience pn exception --- .../app/controllers/OrchestrationTest.java | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index b57e12e1b..63d2317a4 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -2,6 +2,7 @@ import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GEMINI_1_5_FLASH; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.Parameter.TEMPERATURE; +import static com.sap.ai.sdk.orchestration.model.AzureThreshold.*; import static com.sap.ai.sdk.orchestration.model.ResponseChatMessage.RoleEnum.ASSISTANT; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -222,14 +223,12 @@ void testInputFilteringStrict() { .isInstanceOfSatisfying( OrchestrationInputFilterException.class, e -> { - assertThat(e.getFilterDetails()).isNotNull(); - assertThat(e.getFilterDetails()).containsKey("azure_content_safety"); - assertThat(e.getFilterDetails().get("azure_content_safety")).isInstanceOf(Map.class); - - var actualAzureContentSafety = - (Map) e.getFilterDetails().get("azure_content_safety"); - assertThat(actualAzureContentSafety) - .containsKeys("Hate", "Violence", "Sexual", "SelfHarm"); + var actualAzureContentSafety = e.getAzureContentSafetyInput(); + assertThat(actualAzureContentSafety).isNotNull(); + assertThat(actualAzureContentSafety.getViolence()).isGreaterThan(NUMBER_0); + assertThat(actualAzureContentSafety.getSelfHarm()).isEqualTo(NUMBER_0); + assertThat(actualAzureContentSafety.getSexual()).isEqualTo(NUMBER_0); + assertThat(actualAzureContentSafety.getHate()).isEqualTo(NUMBER_0); }); } @@ -256,14 +255,12 @@ void testOutputFilteringStrict() { .isInstanceOfSatisfying( OrchestrationOutputFilterException.class, e -> { - assertThat(e.getFilterDetails()).isNotNull(); - assertThat(e.getFilterDetails()).containsKey("azure_content_safety"); - assertThat(e.getFilterDetails().get("azure_content_safety")).isInstanceOf(Map.class); - - var actualAzureContentSafety = - (Map) e.getFilterDetails().get("azure_content_safety"); - assertThat(actualAzureContentSafety) - .containsKeys("Hate", "Violence", "Sexual", "SelfHarm"); + var actualAzureContentSafety = e.getAzureContentSafetyOutput(); + assertThat(actualAzureContentSafety).isNotNull(); + assertThat(actualAzureContentSafety.getViolence()).isGreaterThan(NUMBER_0); + assertThat(actualAzureContentSafety.getSelfHarm()).isEqualTo(NUMBER_0); + assertThat(actualAzureContentSafety.getSexual()).isEqualTo(NUMBER_0); + assertThat(actualAzureContentSafety.getHate()).isEqualTo(NUMBER_0); }); } @@ -286,7 +283,17 @@ void testLlamaGuardEnabled() { .isInstanceOf(OrchestrationInputFilterException.class) .hasMessageContaining( "Prompt filtered due to safety violations. Please modify the prompt and try again.") - .hasMessageContaining("400 (Bad Request)"); + .hasMessageContaining("400 (Bad Request)") + .isInstanceOfSatisfying( + OrchestrationInputFilterException.class, + e -> { + var llamaGuard38b = e.getLlamaGuard38b(); + assertThat(llamaGuard38b).isNotNull(); + assertThat(llamaGuard38b.isViolentCrimes()).isTrue(); + assertThat(llamaGuard38b.isHate()).isFalse(); + assertThat(llamaGuard38b.isChildExploitation()).isFalse(); + assertThat(llamaGuard38b.isDefamation()).isFalse(); + }); } @Test From cf5996464a3cfd56e7a8586d2a2b1ecd5c65f585 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Wed, 30 Jul 2025 12:43:06 +0200 Subject: [PATCH 24/30] Review suggestion --- .../core/common/ClientResponseHandler.java | 4 ++-- .../common/ClientStreamingHandlerTest.java | 6 +++--- .../controllers/OrchestrationController.java | 19 +++++++++++-------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index a6d349400..b28b6691f 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -36,10 +36,10 @@ public class ClientResponseHandler successType; /** The HTTP error response type */ - @Nonnull protected final Class errorType; + @Nonnull final Class errorType; /** The factory to create exceptions for Http 4xx/5xx responses. */ - @Nonnull protected final ClientExceptionFactory exceptionFactory; + @Nonnull final ClientExceptionFactory exceptionFactory; /** The parses for JSON responses, will be private once we can remove mixins */ @Nonnull ObjectMapper objectMapper = getDefaultObjectMapper(); diff --git a/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java b/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java index 1145e4eed..1b36bf6e2 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/common/ClientStreamingHandlerTest.java @@ -44,7 +44,7 @@ public String getFinishReason() { @SneakyThrows @Test - void testHandleStreamingResponse_variousScenarios() { + void testHandleStreamingResponse() { var sut = new ClientStreamingHandler<>( MyStreamedDelta.class, MyError.class, new MyExceptionFactory()); @@ -97,12 +97,12 @@ void testHandleStreamingResponse_variousScenarios() { assertThat(stream2).isEmpty(); var stream3 = sut.handleStreamingResponse(response); - assertThatThrownBy(() -> stream3.toList()) + assertThatThrownBy(stream3::toList) .isInstanceOf(MyException.class) .hasMessageContaining("Failed to parse response"); var stream4 = sut.handleStreamingResponse(response); - assertThatThrownBy(() -> stream4.toList()) + assertThatThrownBy(stream4::toList) .isInstanceOf(MyException.class) .hasMessageContaining("Failed to parse delta chunk") .hasCauseInstanceOf(JsonParseException.class); diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index 80f0ba861..388f9b293 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -131,13 +131,13 @@ Object inputFiltering( } catch (OrchestrationInputFilterException e) { final var msg = new StringBuilder( - "Failed to obtain a response as the content was flagged by input filter. Error %d" + "[Http %d] Failed to obtain a response as the content was flagged by input filter. " .formatted(e.getStatusCode())); Optional.ofNullable(e.getAzureContentSafetyInput()) - .map(AzureContentSafetyInput::getHate) + .map(AzureContentSafetyInput::getViolence) .filter(rating -> rating.compareTo(policy.getAzureThreshold()) > 0) - .ifPresent(rating -> msg.append("Hate score %d".formatted(rating.getValue()))); + .ifPresent(rating -> msg.append("Violence score %d".formatted(rating.getValue()))); log.debug(msg.toString(), e); return ResponseEntity.internalServerError().body(msg.toString()); @@ -163,12 +163,12 @@ Object outputFiltering( } catch (OrchestrationOutputFilterException e) { final var msg = new StringBuilder( - "Failed to obtain a response as the content was flagged by output filter."); + "Failed to obtain a response as the content was flagged by output filter. "); Optional.ofNullable(e.getAzureContentSafetyOutput()) - .map(AzureContentSafetyOutput::getHate) + .map(AzureContentSafetyOutput::getViolence) .filter(rating -> rating.compareTo(policy.getAzureThreshold()) > 0) - .ifPresent(rating -> msg.append("Hate score %d ".formatted(rating.getValue()))); + .ifPresent(rating -> msg.append("Violence score %d ".formatted(rating.getValue()))); log.debug(msg.toString(), e); return ResponseEntity.internalServerError().body(msg.toString()); @@ -190,9 +190,12 @@ Object llamaGuardInputFiltering( try { response = service.llamaGuardInputFilter(enabled); } catch (OrchestrationInputFilterException e) { - final var msg = - "Failed to obtain a response as the content was flagged by input filter. Error %d" + var msg = + "[Http %d] Failed to obtain a response as the content was flagged by input filter. " .formatted(e.getStatusCode()); + if(e.getLlamaGuard38b() != null){ + msg += " Violent crimes are %s".formatted(e.getLlamaGuard38b().isViolentCrimes()); + } log.debug(msg, e); return ResponseEntity.internalServerError().body(msg); } From 2293fdc7cf5fa0347dd7e94b1e58decc0a8c8d2f Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Wed, 30 Jul 2025 10:47:08 +0000 Subject: [PATCH 25/30] Formatting --- .../com/sap/ai/sdk/app/controllers/OrchestrationController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index 388f9b293..7b6cac95e 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -193,7 +193,7 @@ Object llamaGuardInputFiltering( var msg = "[Http %d] Failed to obtain a response as the content was flagged by input filter. " .formatted(e.getStatusCode()); - if(e.getLlamaGuard38b() != null){ + if (e.getLlamaGuard38b() != null) { msg += " Violent crimes are %s".formatted(e.getLlamaGuard38b().isViolentCrimes()); } log.debug(msg, e); From 6a348da6824b2dd4a7fc8f0d25bd5160b8d40155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 5 Aug 2025 11:49:56 +0200 Subject: [PATCH 26/30] Handle class cast exceptions --- .../OrchestrationChatResponse.java | 21 +++++++++--------- .../orchestration/OrchestrationClient.java | 22 ++++++++++--------- .../OrchestrationExceptionFactory.java | 10 ++++++--- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 327426b52..4069c09d6 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -15,10 +15,10 @@ import com.sap.ai.sdk.orchestration.model.TokenUsage; import com.sap.ai.sdk.orchestration.model.ToolChatMessage; import com.sap.ai.sdk.orchestration.model.UserChatMessage; +import io.vavr.control.Try; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Optional; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; import lombok.Value; @@ -43,20 +43,19 @@ public String getContent() throws OrchestrationOutputFilterException { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { - @SuppressWarnings("unchecked") - final var filterDetails = - Optional.of(getOriginalResponse().getModuleResults().getOutputFiltering()) - .map(outputFiltering -> (Map) outputFiltering.getData()) - .map(data -> (List>) data.get("choices")) - .map(choices -> choices.get(0)) - .orElseGet(Map::of); - - throw new OrchestrationOutputFilterException( - "Content filter filtered the output.", filterDetails); + final var filterDetails = Try.of(this::getOutputFilteringChoices).getOrElseGet(e -> Map.of()); + final var message = "Content filter filtered the output."; + throw new OrchestrationOutputFilterException(message, filterDetails); } return choice.getMessage().getContent(); } + @SuppressWarnings("unchecked") + private Map getOutputFilteringChoices() { + final var f = getOriginalResponse().getModuleResults().getOutputFiltering(); + return ((List>) ((Map) f.getData()).get("choices")).get(0); + } + /** * Get the token usage. * diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 73884c410..8df25ec39 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -22,6 +22,8 @@ import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; + +import io.vavr.control.Try; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -118,19 +120,19 @@ private static void throwOnContentFilter(@Nonnull final OrchestrationChatComplet throws OrchestrationOutputFilterException { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { - @SuppressWarnings("unchecked") - final var filterDetails = - Optional.ofNullable(delta.getModuleResults().getOutputFiltering()) - .map(outputFiltering -> (Map) outputFiltering.getData()) - .map(data -> (List>) data.get("choices")) - .map(choices -> choices.get(0)) - .orElseGet(Map::of); - - throw new OrchestrationOutputFilterException( - "Content filter filtered the output.", filterDetails); + final var filterDetails = Try.of(() -> getOutputFilteringChoices(delta)).getOrElseGet(e -> Map.of()); + final var message = "Content filter filtered the output."; + throw new OrchestrationOutputFilterException(message, filterDetails); } } + @SuppressWarnings("unchecked") + private static Map getOutputFilteringChoices(@Nonnull final OrchestrationChatCompletionDelta delta) { + final var f = delta.getModuleResults().getOutputFiltering(); + return ((List>) ((Map) f.getData()).get("choices")).get(0); + } + + /** * Serializes the given request, executes it and deserializes the response. * diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 8c9a6cb66..874ecf9d2 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -3,6 +3,10 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.model.ErrorResponse; +import com.sap.ai.sdk.orchestration.model.GenericModuleResult; +import com.sap.ai.sdk.orchestration.model.ModuleResults; + import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -37,9 +41,9 @@ public OrchestrationClientException buildFromClientError( private Map extractInputFilterDetails(@Nonnull final OrchestrationError error) { return Optional.of(error.getErrorResponse()) - .flatMap(response -> Optional.ofNullable(response.getModuleResults())) - .flatMap(moduleResults -> Optional.ofNullable(moduleResults.getInputFiltering())) - .flatMap(inputFiltering -> Optional.ofNullable(inputFiltering.getData())) + .map(ErrorResponse::getModuleResults) + .map(ModuleResults::getInputFiltering) + .map(GenericModuleResult::getData) .filter(Map.class::isInstance) .map(map -> (Map) map) .orElseGet(Collections::emptyMap); From a9b48d4768cadcbb64ec7dfa2652f431bc1b1ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 5 Aug 2025 11:54:08 +0200 Subject: [PATCH 27/30] Format --- .../sap/ai/sdk/orchestration/OrchestrationClient.java | 11 +++++------ .../orchestration/OrchestrationExceptionFactory.java | 1 - 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 8df25ec39..2456b5510 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -16,14 +16,12 @@ import com.sap.ai.sdk.orchestration.model.ModuleConfigs; import com.sap.ai.sdk.orchestration.model.OrchestrationConfig; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import io.vavr.control.Try; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; - -import io.vavr.control.Try; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -120,19 +118,20 @@ private static void throwOnContentFilter(@Nonnull final OrchestrationChatComplet throws OrchestrationOutputFilterException { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { - final var filterDetails = Try.of(() -> getOutputFilteringChoices(delta)).getOrElseGet(e -> Map.of()); + final var filterDetails = + Try.of(() -> getOutputFilteringChoices(delta)).getOrElseGet(e -> Map.of()); final var message = "Content filter filtered the output."; throw new OrchestrationOutputFilterException(message, filterDetails); } } @SuppressWarnings("unchecked") - private static Map getOutputFilteringChoices(@Nonnull final OrchestrationChatCompletionDelta delta) { + private static Map getOutputFilteringChoices( + @Nonnull final OrchestrationChatCompletionDelta delta) { final var f = delta.getModuleResults().getOutputFiltering(); return ((List>) ((Map) f.getData()).get("choices")).get(0); } - /** * Serializes the given request, executes it and deserializes the response. * diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 874ecf9d2..143986d9f 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -6,7 +6,6 @@ import com.sap.ai.sdk.orchestration.model.ErrorResponse; import com.sap.ai.sdk.orchestration.model.GenericModuleResult; import com.sap.ai.sdk.orchestration.model.ModuleResults; - import java.util.Collections; import java.util.Map; import java.util.Optional; From 789e12b10429f6756f64ba88133e184c607d23ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 5 Aug 2025 11:57:57 +0200 Subject: [PATCH 28/30] Format --- .../orchestration/OrchestrationChatResponse.java | 8 ++++---- .../ai/sdk/orchestration/OrchestrationClient.java | 6 +++--- .../OrchestrationExceptionFactory.java | 4 ++-- .../OrchestrationFilterException.java | 9 ++++----- .../sdk/orchestration/OrchestrationUnitTest.java | 12 +++++------- .../app/controllers/OrchestrationController.java | 10 +++++----- .../ai/sdk/app/controllers/OrchestrationTest.java | 14 +++++++------- 7 files changed, 30 insertions(+), 33 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 4069c09d6..ff8585d9e 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; import com.sap.ai.sdk.orchestration.model.AssistantChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; @@ -36,16 +36,16 @@ public class OrchestrationChatResponse { *

Note: If there are multiple choices only the first one is returned * * @return the message content or empty string. - * @throws OrchestrationOutputFilterException if the content filter filtered the output. + * @throws Output if the content filter filtered the output. */ @Nonnull - public String getContent() throws OrchestrationOutputFilterException { + public String getContent() throws Output { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { final var filterDetails = Try.of(this::getOutputFilteringChoices).getOrElseGet(e -> Map.of()); final var message = "Content filter filtered the output."; - throw new OrchestrationOutputFilterException(message, filterDetails); + throw new Output(message, filterDetails); } return choice.getMessage().getContent(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 2456b5510..689249db6 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -8,7 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; import com.sap.ai.sdk.orchestration.model.CompletionPostRequest; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.EmbeddingsPostRequest; @@ -115,13 +115,13 @@ public Stream streamChatCompletion( } private static void throwOnContentFilter(@Nonnull final OrchestrationChatCompletionDelta delta) - throws OrchestrationOutputFilterException { + throws Output { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { final var filterDetails = Try.of(() -> getOutputFilteringChoices(delta)).getOrElseGet(e -> Map.of()); final var message = "Content filter filtered the output."; - throw new OrchestrationOutputFilterException(message, filterDetails); + throw new Output(message, filterDetails); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 143986d9f..2230ba803 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -2,7 +2,7 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Input; import com.sap.ai.sdk.orchestration.model.ErrorResponse; import com.sap.ai.sdk.orchestration.model.GenericModuleResult; import com.sap.ai.sdk.orchestration.model.ModuleResults; @@ -29,7 +29,7 @@ public OrchestrationClientException buildFromClientError( final var inputFilterDetails = extractInputFilterDetails(clientError); if (!inputFilterDetails.isEmpty()) { - return new OrchestrationInputFilterException(message, clientError, inputFilterDetails); + return new Input(message, clientError, inputFilterDetails); } return new OrchestrationClientException(message, clientError); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java index ecd234751..13a83a117 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationFilterException.java @@ -37,7 +37,7 @@ public LlamaGuard38b getLlamaGuard38b() { } /** Exception thrown when an error occurs during input filtering. */ - public static class OrchestrationInputFilterException extends OrchestrationFilterException { + public static class Input extends OrchestrationFilterException { /** * Constructs a new OrchestrationInputFilterException. * @@ -45,7 +45,7 @@ public static class OrchestrationInputFilterException extends OrchestrationFilte * @param clientError The specific client error. * @param filterDetails Details about the filter that caused the exception. */ - OrchestrationInputFilterException( + Input( @Nonnull final String message, @Nonnull final OrchestrationError clientError, @Nonnull final Map filterDetails) { @@ -75,15 +75,14 @@ public AzureContentSafetyInput getAzureContentSafetyInput() { * Exception thrown when an error occurs during output filtering, specifically when the finish * reason is a content filter. */ - public static class OrchestrationOutputFilterException extends OrchestrationFilterException { + public static class Output extends OrchestrationFilterException { /** * Constructs a new OrchestrationOutputFilterException. * * @param message The detail message. * @param filterDetails Details about the filter that caused the exception. */ - OrchestrationOutputFilterException( - @Nonnull final String message, @Nonnull final Map filterDetails) { + Output(@Nonnull final String message, @Nonnull final Map filterDetails) { super(message); this.filterDetails = filterDetails; } diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index 6ba201308..5bd04f53a 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -40,8 +40,6 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.github.tomakehurst.wiremock.stubbing.Scenario; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; import com.sap.ai.sdk.orchestration.model.ChatDelta; import com.sap.ai.sdk.orchestration.model.DPIConfig; import com.sap.ai.sdk.orchestration.model.DPIEntities; @@ -424,7 +422,7 @@ void inputFilteringStrict() { try { client.chatCompletion(prompt, configWithFilter); - } catch (OrchestrationInputFilterException e) { + } catch (OrchestrationFilterException.Input e) { assertThat(e.getMessage()) .isEqualTo( "Request failed with status 400 (Bad Request): 400 - Filtering Module - Input Filter: Prompt filtered due to safety violations. Please modify the prompt and try again."); @@ -477,7 +475,7 @@ void outputFilteringStrict() { try { client.chatCompletion(prompt, configWithFilter).getContent(); - } catch (OrchestrationOutputFilterException e) { + } catch (Output e) { assertThat(e.getMessage()).isEqualTo("Content filter filtered the output."); assertThat(e.getFilterDetails()) .isEqualTo( @@ -730,9 +728,9 @@ void testThrowsOnContentFilter() { // this must not throw, since the stream is lazily evaluated var stream = mock.streamChatCompletion(new OrchestrationPrompt(""), config); assertThatThrownBy(stream::toList) - .isInstanceOf(OrchestrationOutputFilterException.class) + .isInstanceOf(Output.class) .hasMessage("Content filter filtered the output.") - .extracting(e -> ((OrchestrationOutputFilterException) e).getFilterDetails()) + .extracting(e -> ((Output) e).getFilterDetails()) .isEqualTo(Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0))); } @@ -754,7 +752,7 @@ void streamChatCompletionOutputFilterErrorHandling() throws IOException { try (Stream stream = client.streamChatCompletion(prompt, config)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) - .isInstanceOf(OrchestrationOutputFilterException.class) + .isInstanceOf(Output.class) .hasMessage("Content filter filtered the output."); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index 7b6cac95e..ec3d15232 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -5,8 +5,8 @@ import com.sap.ai.sdk.app.services.OrchestrationService; import com.sap.ai.sdk.orchestration.AzureFilterThreshold; import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Input; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; import com.sap.ai.sdk.orchestration.model.AzureContentSafetyInput; import com.sap.ai.sdk.orchestration.model.AzureContentSafetyOutput; import com.sap.ai.sdk.orchestration.model.DPIEntities; @@ -128,7 +128,7 @@ Object inputFiltering( final OrchestrationChatResponse response; try { response = service.inputFiltering(policy); - } catch (OrchestrationInputFilterException e) { + } catch (Input e) { final var msg = new StringBuilder( "[Http %d] Failed to obtain a response as the content was flagged by input filter. " @@ -160,7 +160,7 @@ Object outputFiltering( final String content; try { content = response.getContent(); - } catch (OrchestrationOutputFilterException e) { + } catch (Output e) { final var msg = new StringBuilder( "Failed to obtain a response as the content was flagged by output filter. "); @@ -189,7 +189,7 @@ Object llamaGuardInputFiltering( final OrchestrationChatResponse response; try { response = service.llamaGuardInputFilter(enabled); - } catch (OrchestrationInputFilterException e) { + } catch (Input e) { var msg = "[Http %d] Failed to obtain a response as the content was flagged by input filter. " .formatted(e.getStatusCode()); diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 63d2317a4..517ccdc20 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -15,8 +15,8 @@ import com.sap.ai.sdk.orchestration.Message; import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationInputFilterException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.OrchestrationOutputFilterException; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Input; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -221,7 +221,7 @@ void testInputFilteringStrict() { "Prompt filtered due to safety violations. Please modify the prompt and try again.") .hasMessageContaining("400 (Bad Request)") .isInstanceOfSatisfying( - OrchestrationInputFilterException.class, + Input.class, e -> { var actualAzureContentSafety = e.getAzureContentSafetyInput(); assertThat(actualAzureContentSafety).isNotNull(); @@ -253,7 +253,7 @@ void testOutputFilteringStrict() { assertThatThrownBy(response::getContent) .hasMessageContaining("Content filter filtered the output.") .isInstanceOfSatisfying( - OrchestrationOutputFilterException.class, + Output.class, e -> { var actualAzureContentSafety = e.getAzureContentSafetyOutput(); assertThat(actualAzureContentSafety).isNotNull(); @@ -280,12 +280,12 @@ void testOutputFilteringLenient() { @Test void testLlamaGuardEnabled() { assertThatThrownBy(() -> service.llamaGuardInputFilter(true)) - .isInstanceOf(OrchestrationInputFilterException.class) + .isInstanceOf(Input.class) .hasMessageContaining( "Prompt filtered due to safety violations. Please modify the prompt and try again.") .hasMessageContaining("400 (Bad Request)") .isInstanceOfSatisfying( - OrchestrationInputFilterException.class, + Input.class, e -> { var llamaGuard38b = e.getLlamaGuard38b(); assertThat(llamaGuard38b).isNotNull(); @@ -419,7 +419,7 @@ void testStreamingErrorHandlingInputFilter() { val configWithFilter = config.withInputFiltering(filterConfig); assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithFilter)) - .isInstanceOf(OrchestrationInputFilterException.class) + .isInstanceOf(Input.class) .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("Filtering Module - Input Filter"); } From e4b179f802d4d8fb0777662855301c0c7641380d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 5 Aug 2025 12:21:33 +0200 Subject: [PATCH 29/30] Fix compilation --- .../sap/ai/sdk/orchestration/OrchestrationUnitTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index 5bd04f53a..b06ea7f7f 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -475,7 +475,7 @@ void outputFilteringStrict() { try { client.chatCompletion(prompt, configWithFilter).getContent(); - } catch (Output e) { + } catch (OrchestrationFilterException.Output e) { assertThat(e.getMessage()).isEqualTo("Content filter filtered the output."); assertThat(e.getFilterDetails()) .isEqualTo( @@ -728,9 +728,9 @@ void testThrowsOnContentFilter() { // this must not throw, since the stream is lazily evaluated var stream = mock.streamChatCompletion(new OrchestrationPrompt(""), config); assertThatThrownBy(stream::toList) - .isInstanceOf(Output.class) + .isInstanceOf(OrchestrationFilterException.Output.class) .hasMessage("Content filter filtered the output.") - .extracting(e -> ((Output) e).getFilterDetails()) + .extracting(e -> ((OrchestrationFilterException.Output) e).getFilterDetails()) .isEqualTo(Map.of("azure_content_safety", Map.of("hate", 0, "self_harm", 0))); } @@ -752,7 +752,7 @@ void streamChatCompletionOutputFilterErrorHandling() throws IOException { try (Stream stream = client.streamChatCompletion(prompt, config)) { assertThatThrownBy(() -> stream.forEach(System.out::println)) - .isInstanceOf(Output.class) + .isInstanceOf(OrchestrationFilterException.Output.class) .hasMessage("Content filter filtered the output."); } From 0235bf190cf82b04296d466cc51b5c26a4f79b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 5 Aug 2025 12:30:48 +0200 Subject: [PATCH 30/30] Fix compilation --- .../orchestration/OrchestrationChatResponse.java | 7 +++---- .../ai/sdk/orchestration/OrchestrationClient.java | 5 ++--- .../OrchestrationExceptionFactory.java | 3 +-- .../app/controllers/OrchestrationController.java | 9 ++++----- .../ai/sdk/app/controllers/OrchestrationTest.java | 13 ++++++------- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index ff8585d9e..52c178a35 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; import com.sap.ai.sdk.orchestration.model.AssistantChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; @@ -36,16 +35,16 @@ public class OrchestrationChatResponse { *

Note: If there are multiple choices only the first one is returned * * @return the message content or empty string. - * @throws Output if the content filter filtered the output. + * @throws OrchestrationFilterException.Output if the content filter filtered the output. */ @Nonnull - public String getContent() throws Output { + public String getContent() throws OrchestrationFilterException.Output { final var choice = getChoice(); if ("content_filter".equals(choice.getFinishReason())) { final var filterDetails = Try.of(this::getOutputFilteringChoices).getOrElseGet(e -> Map.of()); final var message = "Content filter filtered the output."; - throw new Output(message, filterDetails); + throw new OrchestrationFilterException.Output(message, filterDetails); } return choice.getMessage().getContent(); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 689249db6..a16101017 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; import com.sap.ai.sdk.orchestration.model.CompletionPostRequest; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.EmbeddingsPostRequest; @@ -115,13 +114,13 @@ public Stream streamChatCompletion( } private static void throwOnContentFilter(@Nonnull final OrchestrationChatCompletionDelta delta) - throws Output { + throws OrchestrationFilterException.Output { final String finishReason = delta.getFinishReason(); if (finishReason != null && finishReason.equals("content_filter")) { final var filterDetails = Try.of(() -> getOutputFilteringChoices(delta)).getOrElseGet(e -> Map.of()); final var message = "Content filter filtered the output."; - throw new Output(message, filterDetails); + throw new OrchestrationFilterException.Output(message, filterDetails); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java index 2230ba803..62a3a1635 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationExceptionFactory.java @@ -2,7 +2,6 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.core.common.ClientExceptionFactory; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Input; import com.sap.ai.sdk.orchestration.model.ErrorResponse; import com.sap.ai.sdk.orchestration.model.GenericModuleResult; import com.sap.ai.sdk.orchestration.model.ModuleResults; @@ -29,7 +28,7 @@ public OrchestrationClientException buildFromClientError( final var inputFilterDetails = extractInputFilterDetails(clientError); if (!inputFilterDetails.isEmpty()) { - return new Input(message, clientError, inputFilterDetails); + return new OrchestrationFilterException.Input(message, clientError, inputFilterDetails); } return new OrchestrationClientException(message, clientError); diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index ec3d15232..6f1d48b1f 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -5,8 +5,7 @@ import com.sap.ai.sdk.app.services.OrchestrationService; import com.sap.ai.sdk.orchestration.AzureFilterThreshold; import com.sap.ai.sdk.orchestration.OrchestrationChatResponse; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Input; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException; import com.sap.ai.sdk.orchestration.model.AzureContentSafetyInput; import com.sap.ai.sdk.orchestration.model.AzureContentSafetyOutput; import com.sap.ai.sdk.orchestration.model.DPIEntities; @@ -128,7 +127,7 @@ Object inputFiltering( final OrchestrationChatResponse response; try { response = service.inputFiltering(policy); - } catch (Input e) { + } catch (OrchestrationFilterException.Input e) { final var msg = new StringBuilder( "[Http %d] Failed to obtain a response as the content was flagged by input filter. " @@ -160,7 +159,7 @@ Object outputFiltering( final String content; try { content = response.getContent(); - } catch (Output e) { + } catch (OrchestrationFilterException.Output e) { final var msg = new StringBuilder( "Failed to obtain a response as the content was flagged by output filter. "); @@ -189,7 +188,7 @@ Object llamaGuardInputFiltering( final OrchestrationChatResponse response; try { response = service.llamaGuardInputFilter(enabled); - } catch (Input e) { + } catch (OrchestrationFilterException.Input e) { var msg = "[Http %d] Failed to obtain a response as the content was flagged by input filter. " .formatted(e.getStatusCode()); diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java index 517ccdc20..2f89a0c08 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java @@ -15,8 +15,7 @@ import com.sap.ai.sdk.orchestration.Message; import com.sap.ai.sdk.orchestration.OrchestrationClient; import com.sap.ai.sdk.orchestration.OrchestrationClientException; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Input; -import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Output; +import com.sap.ai.sdk.orchestration.OrchestrationFilterException; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -221,7 +220,7 @@ void testInputFilteringStrict() { "Prompt filtered due to safety violations. Please modify the prompt and try again.") .hasMessageContaining("400 (Bad Request)") .isInstanceOfSatisfying( - Input.class, + OrchestrationFilterException.Input.class, e -> { var actualAzureContentSafety = e.getAzureContentSafetyInput(); assertThat(actualAzureContentSafety).isNotNull(); @@ -253,7 +252,7 @@ void testOutputFilteringStrict() { assertThatThrownBy(response::getContent) .hasMessageContaining("Content filter filtered the output.") .isInstanceOfSatisfying( - Output.class, + OrchestrationFilterException.Output.class, e -> { var actualAzureContentSafety = e.getAzureContentSafetyOutput(); assertThat(actualAzureContentSafety).isNotNull(); @@ -280,12 +279,12 @@ void testOutputFilteringLenient() { @Test void testLlamaGuardEnabled() { assertThatThrownBy(() -> service.llamaGuardInputFilter(true)) - .isInstanceOf(Input.class) + .isInstanceOf(OrchestrationFilterException.Input.class) .hasMessageContaining( "Prompt filtered due to safety violations. Please modify the prompt and try again.") .hasMessageContaining("400 (Bad Request)") .isInstanceOfSatisfying( - Input.class, + OrchestrationFilterException.Input.class, e -> { var llamaGuard38b = e.getLlamaGuard38b(); assertThat(llamaGuard38b).isNotNull(); @@ -419,7 +418,7 @@ void testStreamingErrorHandlingInputFilter() { val configWithFilter = config.withInputFiltering(filterConfig); assertThatThrownBy(() -> client.streamChatCompletion(prompt, configWithFilter)) - .isInstanceOf(Input.class) + .isInstanceOf(OrchestrationFilterException.Input.class) .hasMessageContaining("status 400 (Bad Request)") .hasMessageContaining("Filtering Module - Input Filter"); }