Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fa6a86d
Initial
newtork Aug 6, 2025
2e75315
Drive by code improvement
newtork Aug 6, 2025
8fee865
Fix test
newtork Aug 6, 2025
5bed6c2
Fix annotation
newtork Aug 6, 2025
ec016d2
Add missing http response
newtork Aug 6, 2025
c6ab27c
Reduce complexity of exception factory
newtork Aug 6, 2025
a53de59
Fix compilation
newtork Aug 6, 2025
95b9544
Improve code style
newtork Aug 6, 2025
825b9c1
Formatting
bot-sdk-js Aug 6, 2025
59cac0f
Minor Code cleanup
newtork Aug 6, 2025
7f02ee2
Merge branch 'exceptions-with-httpresponse' of https://github.com/SAP…
newtork Aug 6, 2025
2583f2c
Add/Update release note
newtork Aug 6, 2025
ba693ee
Merge remote-tracking branch 'origin/main' into exceptions-with-httpr…
newtork Aug 12, 2025
34e7ec4
Fix merge conflict
newtork Aug 12, 2025
43bec20
Establish public getMessage() in interface
newtork Aug 12, 2025
0396673
Formatting
bot-sdk-js Aug 12, 2025
6d06899
Remove duplicate
newtork Aug 12, 2025
64573d8
Merge remote-tracking branch 'origin/exceptions-with-httpresponse' in…
newtork Aug 12, 2025
65f6a4e
Initial
newtork Aug 13, 2025
e33cd9a
Merge remote-tracking branch 'origin/main' into exceptions-with-httpr…
newtork Aug 13, 2025
466ad99
Fix merge conflict
newtork Aug 13, 2025
0495712
Formatting
bot-sdk-js Aug 13, 2025
14df58c
Format
newtork Aug 13, 2025
fdd3d31
Merge remote-tracking branch 'origin/exceptions-with-httpresponse2' i…
newtork Aug 13, 2025
763b745
Revert drive-by change
newtork Aug 13, 2025
0e04245
Improve code style
newtork Aug 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion orchestration/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<coverage.instruction>94%</coverage.instruction>
<coverage.branch>75%</coverage.branch>
<coverage.method>93%</coverage.method>
<coverage.class>100%</coverage.class>
<coverage.class>97%</coverage.class>
</properties>

<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,18 @@ public class OrchestrationChatResponse {
* <p>Note: If there are multiple choices only the first one is returned
*
* @return the message content or empty string.
* @throws OrchestrationFilterException.Output if the content filter filtered the output.
* @throws OrchestrationClientException.Synchronous.OutputFilter if the content filter filtered
* the output.
*/
@Nonnull
public String getContent() throws OrchestrationFilterException.Output {
public String getContent() throws OrchestrationClientException.Synchronous.OutputFilter {
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 OrchestrationFilterException.Output(message).setFilterDetails(filterDetails);
throw new OrchestrationClientException.Synchronous.OutputFilter(message)
.setFilterDetails(filterDetails);
}
return choice.getMessage().getContent();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,14 @@ public Stream<String> streamChatCompletion(
}

private static void throwOnContentFilter(@Nonnull final OrchestrationChatCompletionDelta delta)
throws OrchestrationFilterException.Output {
throws OrchestrationClientException.Streaming.OutputFilter {
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 OrchestrationFilterException.Output(message).setFilterDetails(filterDetails);
throw new OrchestrationClientException.Streaming.OutputFilter(message)
.setFilterDetails(filterDetails);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,65 @@
package com.sap.ai.sdk.orchestration;

import static com.sap.ai.sdk.orchestration.OrchestrationJacksonConfiguration.getOrchestrationObjectMapper;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.Beta;
import com.sap.ai.sdk.core.common.ClientException;
import com.sap.ai.sdk.core.common.ClientExceptionFactory;
import com.sap.ai.sdk.orchestration.OrchestrationFilterException.Input;
import com.sap.ai.sdk.orchestration.model.AzureContentSafetyInput;
import com.sap.ai.sdk.orchestration.model.AzureContentSafetyOutput;
import com.sap.ai.sdk.orchestration.model.Error;
import com.sap.ai.sdk.orchestration.model.ErrorResponse;
import com.sap.ai.sdk.orchestration.model.ErrorResponseStreaming;
import com.sap.ai.sdk.orchestration.model.ErrorStreaming;
import com.sap.ai.sdk.orchestration.model.GenericModuleResult;
import com.sap.ai.sdk.orchestration.model.LlamaGuard38b;
import com.sap.ai.sdk.orchestration.model.ModuleResults;
import com.sap.ai.sdk.orchestration.model.ModuleResultsStreaming;
import io.vavr.control.Option;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import lombok.experimental.StandardException;

/** Exception thrown by the {@link OrchestrationClient} in case of an error. */
@StandardException
public class OrchestrationClientException extends ClientException {
private static final ObjectMapper MAPPER = getOrchestrationObjectMapper();

static final ClientExceptionFactory<OrchestrationClientException, OrchestrationError> FACTORY =
(message, clientError, cause) -> {
final var details = extractInputFilterDetails(clientError);
if (details.isEmpty()) {
return new OrchestrationClientException(message, cause).setClientError(clientError);
}
return new Input(message, cause).setFilterDetails(details).setClientError(clientError);
};

@SuppressWarnings("unchecked")
/** Details about the filters that caused the exception. */
@Setter(AccessLevel.PACKAGE)
@Getter(AccessLevel.PACKAGE)
@Accessors(chain = true)
@Nonnull
static Map<String, Object> extractInputFilterDetails(@Nullable final OrchestrationError error) {
if (error instanceof OrchestrationError.Synchronous synchronousError) {
return Optional.of(synchronousError.getErrorResponse())
.map(ErrorResponse::getError)
.map(Error::getIntermediateResults)
.map(ModuleResults::getInputFiltering)
.map(GenericModuleResult::getData)
.map(map -> (Map<String, Object>) map)
.orElseGet(Collections::emptyMap);
} else if (error instanceof OrchestrationError.Streaming streamingError) {
return Optional.of(streamingError.getErrorResponse())
protected Map<String, Object> filterDetails = Map.of();

/** Exception thrown during a streaming invocation. */
@StandardException
public static class Streaming extends OrchestrationClientException {
static final ClientExceptionFactory<Streaming, OrchestrationError.Streaming> FACTORY =
(message, clientError, cause) -> {
final var details = extractInputFilterDetails(clientError);
if (details.isEmpty()) {
return new Streaming(message, cause).setClientError(clientError);
}
return new InputFilter(message, cause)
.setFilterDetails(details)
.setClientError(clientError);
};

@SuppressWarnings("unchecked")
@Nonnull
private static Map<String, Object> extractInputFilterDetails(
@Nullable final OrchestrationError.Streaming error) {
return Optional.ofNullable(error)
.map(OrchestrationError.Streaming::getErrorResponse)
.map(ErrorResponseStreaming::getError)
.map(ErrorStreaming::getIntermediateResults)
.map(ModuleResultsStreaming::getInputFiltering)
Expand All @@ -52,57 +68,205 @@ static Map<String, Object> extractInputFilterDetails(@Nullable final Orchestrati
.map(map -> (Map<String, Object>) map)
.orElseGet(Collections::emptyMap);
}
return Collections.emptyMap();
}

@Override
@Nullable
public OrchestrationError getClientError() {
return (OrchestrationError) super.getClientError();
/**
* Retrieves the {@link ErrorResponseStreaming} from the orchestration service, if available.
*
* @return The {@link ErrorResponseStreaming} object, or {@code null} if not available.
* @since 1.10.0
*/
@Beta
@Nullable
public ErrorResponseStreaming getErrorResponse() {
return Option.of(getClientError())
.map(OrchestrationError.Streaming::getErrorResponse)
.getOrNull();
}

/**
* Retrieves the client error details from the orchestration service, if available.
*
* @return The {@link OrchestrationError.Streaming} object, or {@code null} if not available.
* @since 1.10.0
*/
@Override
public OrchestrationError.Streaming getClientError() {
return super.getClientError() instanceof OrchestrationError.Streaming e ? e : null;
}

/** Exception thrown during a streaming invocation that contains input filter details. */
@Beta
@StandardException
public static class InputFilter extends Streaming implements Filter.Input {
@Nonnull
@Override
public Map<String, Object> getFilterDetails() {
return super.getFilterDetails();
}
}

/** Exception thrown during a streaming invocation that contains output filter details. */
@Beta
@StandardException
public static class OutputFilter extends Streaming implements Filter.Output {
@Nonnull
@Override
public Map<String, Object> getFilterDetails() {
return super.getFilterDetails();
}
}
}

/**
* Retrieves the {@link ErrorResponse} from the orchestration service, if available.
*
* @return The {@link ErrorResponse} object, or {@code null} if not available.
* @since 1.10.0
*/
@Beta
@Nullable
public ErrorResponse getErrorResponse() {
if (getClientError() instanceof OrchestrationError.Synchronous orchestrationError) {
return orchestrationError.getErrorResponse();
/** Exception thrown during a synchronous invocation. */
@StandardException
public static class Synchronous extends OrchestrationClientException {
static final ClientExceptionFactory<Synchronous, OrchestrationError.Synchronous> FACTORY =
(message, clientError, cause) -> {
final var details = extractInputFilterDetails(clientError);
if (details.isEmpty()) {
return new Synchronous(message, cause).setClientError(clientError);
}
return new InputFilter(message, cause)
.setFilterDetails(details)
.setClientError(clientError);
};

@SuppressWarnings("unchecked")
@Nonnull
private static Map<String, Object> extractInputFilterDetails(
@Nullable final OrchestrationError.Synchronous error) {
return Optional.ofNullable(error)
.map(OrchestrationError.Synchronous::getErrorResponse)
.map(ErrorResponse::getError)
.map(Error::getIntermediateResults)
.map(ModuleResults::getInputFiltering)
.map(GenericModuleResult::getData)
.map(map -> (Map<String, Object>) map)
.orElseGet(Collections::emptyMap);
}

/**
* Retrieves the {@link ErrorResponse} from the orchestration service, if available.
*
* @return The {@link ErrorResponse} object, or {@code null} if not available.
* @since 1.10.0
*/
@Beta
@Nullable
public ErrorResponse getErrorResponse() {
return Option.of(getClientError())
.map(OrchestrationError.Synchronous::getErrorResponse)
.getOrNull();
}

/**
* Retrieves the client error details from the orchestration service, if available.
*
* @return The {@link OrchestrationError.Synchronous} object, or {@code null} if not available.
* @since 1.10.0
*/
@Override
public OrchestrationError.Synchronous getClientError() {
return super.getClientError() instanceof OrchestrationError.Synchronous e ? e : null;
}

/** Exception thrown during a synchronous invocation that contains input filter details. */
@Beta
@StandardException
public static class InputFilter extends Synchronous implements Filter.Input {
@Nonnull
@Override
public Map<String, Object> getFilterDetails() {
return super.getFilterDetails();
}

/**
* Retrieves the HTTP status code from the original error response, if available.
*
* @return the HTTP status code, or {@code null} if not available
* @since 1.10.0
*/
@Beta
@Nullable
public Integer getStatusCode() {
return Optional.ofNullable(getErrorResponse())
.map(e -> e.getError().getCode())
.orElse(null);
}
}

/** Exception thrown during a synchronous invocation that contains output filter details. */
@Beta
@StandardException
public static class OutputFilter extends Synchronous implements Filter.Output {
@Nonnull
@Override
public Map<String, Object> getFilterDetails() {
return super.getFilterDetails();
}
}
return null;
}

/**
* Retrieves the {@link ErrorResponseStreaming} from the orchestration service, if available.
*
* @return The {@link ErrorResponseStreaming} object, or {@code null} if not available.
* @since 1.10.0
* Interface representing the filter details that can be included in an orchestration error
* response.
*/
@Beta
@Nullable
public ErrorResponseStreaming getErrorResponseStreaming() {
if (getClientError() instanceof OrchestrationError.Streaming orchestrationError) {
return orchestrationError.getErrorResponse();
interface Filter {
/**
* Retrieves the filter details as a map.
*
* @return a map containing the filter details.
*/
@Nonnull
Map<String, Object> getFilterDetails();

/**
* Retrieves the Azure Content Safety input filter details, if available.
*
* @return the {@link AzureContentSafetyInput} object, or {@code null} if not available.
*/
@Nullable
default LlamaGuard38b getLlamaGuard38b() {
return Optional.ofNullable(getFilterDetails().get("llama_guard_3_8b"))
.map(obj -> MAPPER.convertValue(obj, LlamaGuard38b.class))
.orElse(null);
}

/** Interface for input filters that can be included in an orchestration error response. */
interface Input extends Filter {
/**
* Retrieves the Azure Content Safety input filter details, if available.
*
* @return the {@link AzureContentSafetyInput} object, or {@code null} if not available.
*/
@Nullable
default AzureContentSafetyInput getAzureContentSafetyInput() {
return Optional.ofNullable(getFilterDetails().get("azure_content_safety"))
.map(obj -> MAPPER.convertValue(obj, AzureContentSafetyInput.class))
.orElse(null);
}
}

/** Interface for output filters that can be included in an orchestration error response. */
interface Output extends Filter {
/**
* Retrieves the Azure Content Safety output filter details, if available.
*
* @return the {@link AzureContentSafetyOutput} object, or {@code null} if not available.
*/
@Nullable
default AzureContentSafetyOutput getAzureContentSafetyOutput() {
return Optional.ofNullable(getFilterDetails().get("azure_content_safety"))
.map(obj -> MAPPER.convertValue(obj, AzureContentSafetyOutput.class))
.orElse(null);
}
}
return null;
}

/**
* Retrieves the HTTP status code from the original error response, if available.
*
* @return the HTTP status code, or {@code null} if not available
* @since 1.10.0
*/
@Beta
@Override
@Nullable
public Integer getStatusCode() {
return Optional.ofNullable(getErrorResponse())
.map(ErrorResponse::getError)
.map(Error::getCode)
.orElse(null);
public OrchestrationError getClientError() {
return (OrchestrationError) super.getClientError();
}
}
Loading