From cd7a3a61b9c1a0a677f7b99e881f69dad3c9b15c Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Tue, 13 Oct 2026 17:16:26 +0200 Subject: [PATCH 01/26] add anthropic foundation models support and tests --- foundation-models/anthropic/pom.xml | 118 ++++++++ .../AnthropicChatCompletionDelta.java | 96 ++++++ .../AnthropicChatCompletionRequest.java | 114 +++++++ .../AnthropicChatCompletionResponse.java | 82 +++++ .../anthropic/AnthropicChatMessage.java | 66 +++++ .../anthropic/AnthropicClient.java | 166 +++++++++++ .../anthropic/AnthropicClientException.java | 17 ++ .../anthropic/AnthropicContentBlock.java | 38 +++ .../anthropic/AnthropicError.java | 25 ++ .../anthropic/AnthropicModel.java | 61 ++++ .../anthropic/AnthropicClientTest.java | 280 ++++++++++++++++++ .../resources/__files/invokeResponse.json | 18 ++ .../__files/invokeStreamResponse.txt | 21 ++ .../resources/mappings/invokeResponse.json | 30 ++ pom.xml | 6 + sample-code/spring-app/pom.xml | 4 + .../app/controllers/AnthropicController.java | 54 ++++ .../ai/sdk/app/services/AnthropicService.java | 45 +++ .../main/resources/static/Anthropic-Logo.svg | 1 + .../src/main/resources/static/index.html | 29 ++ .../ai/sdk/app/controllers/AnthropicTest.java | 33 +++ 21 files changed, 1304 insertions(+) create mode 100644 foundation-models/anthropic/pom.xml create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicModel.java create mode 100644 foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientTest.java create mode 100644 foundation-models/anthropic/src/test/resources/__files/invokeResponse.json create mode 100644 foundation-models/anthropic/src/test/resources/__files/invokeStreamResponse.txt create mode 100644 foundation-models/anthropic/src/test/resources/mappings/invokeResponse.json create mode 100644 sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java create mode 100644 sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java create mode 100644 sample-code/spring-app/src/main/resources/static/Anthropic-Logo.svg create mode 100644 sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java diff --git a/foundation-models/anthropic/pom.xml b/foundation-models/anthropic/pom.xml new file mode 100644 index 000000000..31073633f --- /dev/null +++ b/foundation-models/anthropic/pom.xml @@ -0,0 +1,118 @@ + + + 4.0.0 + + com.sap.ai.sdk + sdk-parent + 1.26.0-SNAPSHOT + ../../pom.xml + + com.sap.ai.sdk.foundationmodels + anthropic + Anthropic Claude client + SAP Cloud SDK for AI is the official Software Development Kit (SDK) for SAP AI Core, SAP Generative AI Hub, and Orchestration Service. This is the client for consuming Anthropic Claude models via the SAP AI Core inference gateway. + + https://github.com/SAP/ai-sdk-java?tab=readme-ov-file#documentation + + SAP SE + https://www.sap.com + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + SAP + ai-core-sdk@sap.com + SAP SE + https://www.sap.com + + + + scm:git:git://github.com/SAP/ai-sdk-java.git + scm:git:ssh://github.com:SAP/ai-sdk-java.git + https://github.com/SAP/ai-sdk-java/tree/main + + + ${project.basedir}/../../ + 75% + 75% + 75% + 85% + 80% + 100% + + + + + com.sap.ai.sdk + core + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-connectivity + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient5 + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.apache.httpcomponents.core5 + httpcore5 + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + com.google.code.findbugs + jsr305 + + + org.slf4j + slf4j-api + + + + org.projectlombok + lombok + provided + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.wiremock + wiremock + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java new file mode 100644 index 000000000..14f07ef18 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java @@ -0,0 +1,96 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.sap.ai.sdk.core.common.StreamedDelta; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * A single streamed event from the Anthropic {@code invoke-with-response-stream} endpoint. + * + *

The stream returns server-sent events containing Claude streaming delta events. Each event + * carries one of: {@code message_start}, {@code content_block_start}, {@code content_block_delta}, + * {@code content_block_stop}, {@code message_delta}, or {@code message_stop}. + * + * @since 1.26.0 + */ +@Data +@Accessors(chain = true) +public class AnthropicChatCompletionDelta implements StreamedDelta { + + @Nullable + @JsonProperty("type") + private String type; + + @Nullable + @JsonProperty("index") + private Integer index; + + @Nullable + @JsonProperty("delta") + private Delta delta; + + @Nullable + @JsonProperty("usage") + private AnthropicChatCompletionResponse.AnthropicUsage usage; + + /** + * Get the streamed text delta content from this event. + * + * @return the text delta, or an empty string if this event does not carry text content. + */ + @Nonnull + @Override + public String getDeltaContent() { + if (delta == null) { + return ""; + } + final var text = delta.getText(); + return text != null ? text : ""; + } + + /** + * Get the stop reason from a {@code message_delta} event, mapped to the finish reason. + * + * @return the stop/finish reason, or {@code null} if not present. + */ + @Nullable + @Override + public String getFinishReason() { + return getStopReason(); + } + + /** + * Get the stop reason from a {@code message_delta} event. + * + * @return the stop reason, or {@code null} if not present. + */ + @Nullable + public String getStopReason() { + return delta != null ? delta.getStopReason() : null; + } + + /** The delta payload in a {@code content_block_delta} or {@code message_delta} event. */ + @Data + @Accessors(chain = true) + public static class Delta { + + @Nullable + @JsonProperty("type") + private String type; + + @Nullable + @JsonProperty("text") + private String text; + + @Nullable + @JsonProperty("stop_reason") + private String stopReason; + + @Nullable + @JsonProperty("stop_sequence") + private String stopSequence; + } +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java new file mode 100644 index 000000000..9898b0a71 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java @@ -0,0 +1,114 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * A request to Claude via the Anthropic Messages API. + * + * @since 1.26.0 + */ +@Data +@Accessors(chain = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicChatCompletionRequest { + + /** + * The Anthropic API version to use. Required by the Claude model. + * + * @since 1.26.0 + */ + @Nonnull + @JsonProperty("anthropic_version") + private String anthropicVersion = "bedrock-2023-05-31"; + + @Nonnull + @JsonProperty("messages") + private List messages = new ArrayList<>(); + + @Nullable + @JsonProperty("system") + private String system; + + @Nullable + @JsonProperty("max_tokens") + private Integer maxTokens; + + @Nullable + @JsonProperty("temperature") + private Double temperature; + + @Nullable + @JsonProperty("top_p") + private Double topP; + + @Nullable + @JsonProperty("stop_sequences") + private List stopSequences; + + /** + * Add a user message to this request. + * + * @param text the text content. + * @return this request. + */ + @Nonnull + public AnthropicChatCompletionRequest withUserMessage(@Nonnull final String text) { + messages.add(AnthropicChatMessage.ofUser(text)); + return this; + } + + /** + * Add an assistant message to this request (for multi-turn conversations). + * + * @param text the text content. + * @return this request. + */ + @Nonnull + public AnthropicChatCompletionRequest withAssistantMessage(@Nonnull final String text) { + messages.add(AnthropicChatMessage.ofAssistant(text)); + return this; + } + + /** + * Set the system prompt for this request. + * + * @param systemPrompt the system prompt text. + * @return this request. + */ + @Nonnull + public AnthropicChatCompletionRequest withSystemPrompt(@Nonnull final String systemPrompt) { + system = systemPrompt; + return this; + } + + /** + * Set the maximum number of output tokens. Required by Claude models. + * + * @param maxTokens the maximum token count. + * @return this request. + */ + @Nonnull + public AnthropicChatCompletionRequest withMaxTokens(final int maxTokens) { + this.maxTokens = maxTokens; + return this; + } + + /** + * Set the sampling temperature. + * + * @param temperature the temperature value (0.0 to 1.0). + * @return this request. + */ + @Nonnull + public AnthropicChatCompletionRequest withTemperature(final double temperature) { + this.temperature = temperature; + return this; + } +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java new file mode 100644 index 000000000..83c54cb73 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java @@ -0,0 +1,82 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * A response from the Anthropic Messages API. + * + * @since 1.26.0 + */ +@Data +@Accessors(chain = true) +public class AnthropicChatCompletionResponse { + + @Nullable + @JsonProperty("id") + private String id; + + @Nullable + @JsonProperty("type") + private String type; + + @Nullable + @JsonProperty("role") + private String role; + + @Nullable + @JsonProperty("content") + private List content; + + @Nullable + @JsonProperty("model") + private String model; + + @Nullable + @JsonProperty("stop_reason") + private String stopReason; + + @Nullable + @JsonProperty("stop_sequence") + private String stopSequence; + + @Nullable + @JsonProperty("usage") + private AnthropicUsage usage; + + /** + * Get the first text content from the response. + * + * @return the text content, or an empty string if not present. + */ + @Nonnull + public String getContent() { + if (content == null || content.isEmpty()) { + return ""; + } + final var text = content.get(0).getText(); + return text != null ? text : ""; + } + + /** + * Token usage information from the Anthropic Messages API response. + * + * @since 1.26.0 + */ + @Data + @Accessors(chain = true) + public static class AnthropicUsage { + + @Nullable + @JsonProperty("input_tokens") + private Integer inputTokens; + + @Nullable + @JsonProperty("output_tokens") + private Integer outputTokens; + } +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java new file mode 100644 index 000000000..a45c4aa73 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java @@ -0,0 +1,66 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * A message in the Anthropic Messages API format. + * + * @since 1.26.0 + */ +@Data +@Accessors(chain = true) +public class AnthropicChatMessage { + + /** + * The role of the message sender. + * + * @since 1.26.0 + */ + public enum Role { + /** The user role. */ + @JsonProperty("user") + USER, + /** The assistant role. */ + @JsonProperty("assistant") + ASSISTANT + } + + @Nullable + @JsonProperty("role") + private Role role; + + @Nullable + @JsonProperty("content") + private List content; + + /** + * Create a user message with the given text. + * + * @param text the text content. + * @return a user message. + */ + @Nonnull + public static AnthropicChatMessage ofUser(@Nonnull final String text) { + return new AnthropicChatMessage() + .setRole(Role.USER) + .setContent(List.of(AnthropicContentBlock.ofText(text))); + } + + /** + * Create an assistant message with the given text. + * + * @param text the text content. + * @return an assistant message. + */ + @Nonnull + public static AnthropicChatMessage ofAssistant(@Nonnull final String text) { + return new AnthropicChatMessage() + .setRole(Role.ASSISTANT) + .setContent(List.of(AnthropicContentBlock.ofText(text))); + } +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java new file mode 100644 index 000000000..2c318f2d3 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java @@ -0,0 +1,166 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import static com.sap.ai.sdk.foundationmodels.anthropic.AnthropicClientException.FACTORY; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.DeploymentResolutionException; +import com.sap.ai.sdk.core.JacksonConfiguration; +import com.sap.ai.sdk.core.common.ClientResponseHandler; +import com.sap.ai.sdk.core.common.ClientStreamingHandler; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import java.io.IOException; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.StringEntity; + +/** + * Client for interacting with Anthropic Claude models via SAP AI Core. + * + *

Exposes synchronous and streaming chat completion using the invoke and + * invoke-with-response-stream endpoints, routed through the SAP AI Core inference gateway with SAP + * destination-based authorization. + * + * @since 1.26.0 + */ +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class AnthropicClient { + + private static final String INVOKE_PATH = "invoke"; + private static final String INVOKE_STREAM_PATH = "invoke-with-response-stream"; + private static final ObjectMapper JACKSON = JacksonConfiguration.getDefaultObjectMapper(); + + @Nonnull private final Destination destination; + + /** + * Create a new Anthropic client for the given model using the default resource group. + * + * @param model the Anthropic model to use. + * @return a configured Anthropic client. + * @throws DeploymentResolutionException if no running deployment is found for the model. + */ + @Nonnull + public static AnthropicClient forModel(@Nonnull final AnthropicModel model) + throws DeploymentResolutionException { + final var destination = new AiCoreService().getInferenceDestination().forModel(model); + return forDestination(destination); + } + + /** + * Create a new Anthropic client for the given model in a specific resource group. + * + * @param model the Anthropic model to use. + * @param resourceGroup the resource group containing the deployment. + * @return a configured Anthropic client. + * @throws DeploymentResolutionException if no running deployment is found for the model. + */ + @Nonnull + public static AnthropicClient forModel( + @Nonnull final AnthropicModel model, @Nonnull final String resourceGroup) + throws DeploymentResolutionException { + final var destination = + new AiCoreService().getInferenceDestination(resourceGroup).forModel(model); + return forDestination(destination); + } + + /** + * Create a new Anthropic client using a custom destination. + * + * @param destination the destination pointing to a Claude model deployment. + * @return a configured Anthropic client. + */ + @Nonnull + public static AnthropicClient forDestination(@Nonnull final Destination destination) { + return new AnthropicClient(destination); + } + + /** + * Perform a synchronous chat completion using the {@code invoke} endpoint. + * + * @param request the chat completion request. + * @return the completion response. + * @throws AnthropicClientException if the request fails. + */ + @Nonnull + public AnthropicChatCompletionResponse chatCompletion( + @Nonnull final AnthropicChatCompletionRequest request) throws AnthropicClientException { + return execute(INVOKE_PATH, request, AnthropicChatCompletionResponse.class); + } + + /** + * Perform a streaming chat completion using the {@code invoke-with-response-stream} endpoint. + * + *

Returns a lazily-populated stream of delta events. The stream must be consumed in a + * try-with-resources block to ensure the underlying HTTP connection is released. + * + *

Example: + * + *

{@code
+   * try (var stream = client.streamChatCompletion(request)) {
+   *   stream.map(AnthropicChatCompletionDelta::getDeltaContent).forEach(System.out::print);
+   * }
+   * }
+ * + * @param request the chat completion request. + * @return a stream of delta chunks. + * @throws AnthropicClientException if the request fails. + */ + @Nonnull + public Stream streamChatCompletion( + @Nonnull final AnthropicChatCompletionRequest request) throws AnthropicClientException { + return executeStream(INVOKE_STREAM_PATH, request, AnthropicChatCompletionDelta.class); + } + + @Nonnull + private T execute( + @Nonnull final String path, + @Nonnull final Object payload, + @Nonnull final Class responseType) { + final var httpRequest = buildHttpPost(path, payload); + try { + final var client = ApacheHttpClient5Accessor.getHttpClient(destination); + return client.execute( + httpRequest, new ClientResponseHandler<>(responseType, AnthropicError.class, FACTORY)); + } catch (final IOException e) { + throw new AnthropicClientException("Request to Anthropic model failed", e) + .setHttpRequest(httpRequest); + } + } + + @Nonnull + private Stream executeStream( + @Nonnull final String path, + @Nonnull final Object payload, + @Nonnull final Class deltaType) { + final var httpRequest = buildHttpPost(path, payload); + try { + final var client = ApacheHttpClient5Accessor.getHttpClient(destination); + return new ClientStreamingHandler<>(deltaType, AnthropicError.class, FACTORY) + .handleStreamingResponse(client.executeOpen(null, httpRequest, null)); + } catch (final IOException e) { + throw new AnthropicClientException("Streaming request to Anthropic model failed", e) + .setHttpRequest(httpRequest); + } + } + + @Nonnull + private static HttpPost buildHttpPost(@Nonnull final String path, @Nonnull final Object payload) { + final var httpRequest = new HttpPost(path); + try { + final var json = JACKSON.writeValueAsString(payload); + httpRequest.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON)); + } catch (final JsonProcessingException e) { + throw new AnthropicClientException("Failed to serialize request", e) + .setHttpRequest(httpRequest); + } + return httpRequest; + } +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java new file mode 100644 index 000000000..7b261f843 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java @@ -0,0 +1,17 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.sap.ai.sdk.core.common.ClientException; +import com.sap.ai.sdk.core.common.ClientExceptionFactory; +import lombok.experimental.StandardException; + +/** + * Exception thrown when an Anthropic API call fails. + * + * @since 1.26.0 + */ +@StandardException +public class AnthropicClientException extends ClientException { + + static final ClientExceptionFactory FACTORY = + (message, error, cause) -> new AnthropicClientException(message, cause).setClientError(error); +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java new file mode 100644 index 000000000..242e633ed --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java @@ -0,0 +1,38 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * A content block in an Anthropic message. + * + * @since 1.26.0 + */ +@Data +@Accessors(chain = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicContentBlock { + + @Nullable + @JsonProperty("type") + private String type; + + @Nullable + @JsonProperty("text") + private String text; + + /** + * Create a text content block. + * + * @param text the text. + * @return a text content block. + */ + @Nonnull + public static AnthropicContentBlock ofText(@Nonnull final String text) { + return new AnthropicContentBlock().setType("text").setText(text); + } +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java new file mode 100644 index 000000000..aa97c9cfe --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java @@ -0,0 +1,25 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.sap.ai.sdk.core.common.ClientError; +import javax.annotation.Nullable; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * Represents an error response from the Anthropic Messages API. + * + * @since 1.26.0 + */ +@Data +@Accessors(chain = true) +public class AnthropicError implements ClientError { + + @Nullable + @JsonProperty("message") + private String message; + + @Nullable + @JsonProperty("type") + private String type; +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicModel.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicModel.java new file mode 100644 index 000000000..1f6e28eb4 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicModel.java @@ -0,0 +1,61 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.sap.ai.sdk.core.AiModel; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Anthropic Claude models available via SAP AI Core. + * + *

Please note that the list of models provided here might be outdated. Refer to the SAP AI Core + * documentation for the latest model availability. + * + * @param name the name of the model. + * @param version the version of the model (optional). + * @since 1.26.0 + */ +public record AnthropicModel(@Nonnull String name, @Nullable String version) implements AiModel { + + /** Anthropic Claude 3 Haiku. */ + public static final AnthropicModel CLAUDE_3_HAIKU = + new AnthropicModel("anthropic--claude-3-haiku", null); + + /** Anthropic Claude 4 Sonnet. */ + public static final AnthropicModel CLAUDE_4_SONNET = + new AnthropicModel("anthropic--claude-4-sonnet", null); + + /** Anthropic Claude 4.5 Haiku. */ + public static final AnthropicModel CLAUDE_45_HAIKU = + new AnthropicModel("anthropic--claude-4.5-haiku", null); + + /** Anthropic Claude 4.5 Sonnet. */ + public static final AnthropicModel CLAUDE_45_SONNET = + new AnthropicModel("anthropic--claude-4.5-sonnet", null); + + /** Anthropic Claude 4.5 Opus. */ + public static final AnthropicModel CLAUDE_45_OPUS = + new AnthropicModel("anthropic--claude-4.5-opus", null); + + /** Anthropic Claude 4.6 Sonnet. */ + public static final AnthropicModel CLAUDE_46_SONNET = + new AnthropicModel("anthropic--claude-4.6-sonnet", null); + + /** Anthropic Claude 4.6 Opus. */ + public static final AnthropicModel CLAUDE_46_OPUS = + new AnthropicModel("anthropic--claude-4.6-opus", null); + + /** Anthropic Claude 4.8 Opus. */ + public static final AnthropicModel CLAUDE_48_OPUS = + new AnthropicModel("anthropic--claude-4.8-opus", null); + + /** + * Create a new instance of AnthropicModel with the provided version. + * + * @param version the version of the model. + * @return the new instance of AnthropicModel. + */ + @Nonnull + public AnthropicModel withVersion(@Nonnull final String version) { + return new AnthropicModel(name, version); + } +} diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientTest.java new file mode 100644 index 000000000..d681722f3 --- /dev/null +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientTest.java @@ -0,0 +1,280 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.badRequest; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.serverError; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Cache; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@WireMockTest +class AnthropicClientTest { + + private AnthropicClient client; + + @BeforeEach + void setup(final WireMockRuntimeInfo server) { + final var destination = DefaultHttpDestination.builder(server.getHttpBaseUrl()).build(); + client = AnthropicClient.forDestination(destination); + ApacheHttpClient5Accessor.setHttpClientCache(ApacheHttpClient5Cache.DISABLED); + } + + @AfterEach + void reset() { + ApacheHttpClient5Accessor.setHttpClientCache(null); + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } + + @Test + void testAnthropicModels() { + assertThat(AnthropicModel.CLAUDE_3_HAIKU.name()).isEqualTo("anthropic--claude-3-haiku"); + assertThat(AnthropicModel.CLAUDE_3_HAIKU.version()).isNull(); + + assertThat(AnthropicModel.CLAUDE_45_SONNET.name()).isEqualTo("anthropic--claude-4.5-sonnet"); + + final var modelWithVersion = AnthropicModel.CLAUDE_3_HAIKU.withVersion("v1"); + assertThat(modelWithVersion.name()).isEqualTo("anthropic--claude-3-haiku"); + assertThat(modelWithVersion.version()).isEqualTo("v1"); + } + + @Test + void testChatCompletion(final WireMockRuntimeInfo server) { + stubFor( + post(urlPathEqualTo("/invoke")) + .willReturn( + aResponse() + .withBodyFile("invokeResponse.json") + .withHeader("Content-Type", "application/json"))); + + final var request = + new AnthropicChatCompletionRequest().withUserMessage("Hello!").withMaxTokens(256); + + final var response = client.chatCompletion(request); + + assertThat(response).isNotNull(); + assertThat(response.getId()).isEqualTo("msg_01XFDUDYJgAACzvnptvVoYEL"); + assertThat(response.getRole()).isEqualTo("assistant"); + assertThat(response.getStopReason()).isEqualTo("end_turn"); + assertThat(response.getContent()).isEqualTo("Hello! How can I assist you today?"); + assertThat(response.getUsage()).isNotNull(); + assertThat(response.getUsage().getInputTokens()).isEqualTo(10); + assertThat(response.getUsage().getOutputTokens()).isEqualTo(9); + } + + @Test + void testChatCompletionRequestFormat(final WireMockRuntimeInfo server) { + stubFor( + post(urlPathEqualTo("/invoke")) + .willReturn( + aResponse() + .withBodyFile("invokeResponse.json") + .withHeader("Content-Type", "application/json"))); + + final var request = + new AnthropicChatCompletionRequest() + .withSystemPrompt("You are a helpful assistant.") + .withUserMessage("What is 2+2?") + .withMaxTokens(100) + .withTemperature(0.7); + + client.chatCompletion(request); + + verify( + postRequestedFor(urlPathEqualTo("/invoke")) + .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withRequestBody( + com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.anthropic_version", equalTo("bedrock-2023-05-31"))) + .withRequestBody( + com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.messages[0].role", equalTo("user"))) + .withRequestBody( + com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.system", equalTo("You are a helpful assistant.")))); + } + + @Test + void testChatCompletionMultiTurn(final WireMockRuntimeInfo server) { + stubFor( + post(urlPathEqualTo("/invoke")) + .willReturn( + aResponse() + .withBodyFile("invokeResponse.json") + .withHeader("Content-Type", "application/json"))); + + final var request = + new AnthropicChatCompletionRequest() + .withUserMessage("Hello!") + .withAssistantMessage("Hi there! How can I help?") + .withUserMessage("What is the capital of France?") + .withMaxTokens(256); + + final var response = client.chatCompletion(request); + + assertThat(response).isNotNull(); + assertThat(request.getMessages()).hasSize(3); + assertThat(request.getMessages().get(0).getRole()).isEqualTo(AnthropicChatMessage.Role.USER); + assertThat(request.getMessages().get(1).getRole()) + .isEqualTo(AnthropicChatMessage.Role.ASSISTANT); + assertThat(request.getMessages().get(2).getRole()).isEqualTo(AnthropicChatMessage.Role.USER); + } + + @Test + void testStreamChatCompletion() throws IOException { + final var inputStream = + spy( + Objects.requireNonNull( + getClass() + .getClassLoader() + .getResourceAsStream("__files/invokeStreamResponse.txt"))); + + final var httpClient = mock(HttpClient.class); + ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); + + final var mockResponse = new BasicClassicHttpResponse(200, "OK"); + final var entity = new InputStreamEntity(inputStream, ContentType.TEXT_EVENT_STREAM); + mockResponse.setEntity(entity); + mockResponse.setHeader("Content-Type", "text/event-stream"); + + doReturn(mockResponse).when(httpClient).executeOpen(any(), any(), any()); + + final var request = + new AnthropicChatCompletionRequest().withUserMessage("Hello!").withMaxTokens(256); + + try (var stream = client.streamChatCompletion(request)) { + final var chunks = + stream + .map(AnthropicChatCompletionDelta::getDeltaContent) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + assertThat(chunks).containsExactly("Hello", "!", " How can I", " assist", " you today?"); + } + } + + @Test + void testStreamChatCompletionReturnsStopReason() throws IOException { + final var inputStream = + spy( + Objects.requireNonNull( + getClass() + .getClassLoader() + .getResourceAsStream("__files/invokeStreamResponse.txt"))); + + final var httpClient = mock(HttpClient.class); + ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); + + final var mockResponse = new BasicClassicHttpResponse(200, "OK"); + final var entity = new InputStreamEntity(inputStream, ContentType.TEXT_EVENT_STREAM); + mockResponse.setEntity(entity); + mockResponse.setHeader("Content-Type", "text/event-stream"); + + doReturn(mockResponse).when(httpClient).executeOpen(any(), any(), any()); + + final var request = + new AnthropicChatCompletionRequest().withUserMessage("Hello!").withMaxTokens(256); + + try (var stream = client.streamChatCompletion(request)) { + final var allDeltas = stream.collect(Collectors.toList()); + final var stopDelta = allDeltas.stream().filter(d -> d.getStopReason() != null).findFirst(); + + assertThat(stopDelta).isPresent(); + assertThat(stopDelta.get().getStopReason()).isEqualTo("end_turn"); + } + } + + @Test + void testErrorHandling() { + final var errorJson = + """ + {"type":"error","error":{"type":"invalid_request_error","message":"max_tokens is required"}} + """; + stubFor( + post(anyUrl()) + .willReturn( + badRequest().withBody(errorJson).withHeader("Content-Type", "application/json"))); + + final var request = new AnthropicChatCompletionRequest().withUserMessage("Hello!"); + + assertThatThrownBy(() -> client.chatCompletion(request)) + .isInstanceOf(AnthropicClientException.class) + .hasMessageContaining("400"); + } + + @Test + void testServerError() { + stubFor(post(anyUrl()).willReturn(serverError())); + + final var request = new AnthropicChatCompletionRequest().withUserMessage("Hello!"); + + assertThatThrownBy(() -> client.chatCompletion(request)) + .isInstanceOf(AnthropicClientException.class) + .hasMessageContaining("500"); + } + + @Test + void testAnthropicMessage() { + final var userMsg = AnthropicChatMessage.ofUser("Hello"); + assertThat(userMsg.getRole()).isEqualTo(AnthropicChatMessage.Role.USER); + assertThat(userMsg.getContent()).hasSize(1); + assertThat(userMsg.getContent().get(0).getText()).isEqualTo("Hello"); + + final var assistantMsg = AnthropicChatMessage.ofAssistant("Hi there"); + assertThat(assistantMsg.getRole()).isEqualTo(AnthropicChatMessage.Role.ASSISTANT); + assertThat(assistantMsg.getContent().get(0).getText()).isEqualTo("Hi there"); + } + + @Test + void testAnthropicDeltaGetContent() { + final var delta = new AnthropicChatCompletionDelta(); + assertThat(delta.getDeltaContent()).isEmpty(); + assertThat(delta.getStopReason()).isNull(); + + final var innerDelta = new AnthropicChatCompletionDelta.Delta().setText("Hello"); + delta.setDelta(innerDelta); + assertThat(delta.getDeltaContent()).isEqualTo("Hello"); + } + + @Test + void testEmptyContentResponse() { + final var response = new AnthropicChatCompletionResponse(); + assertThat(response.getContent()).isEmpty(); + + final var responseWithEmptyList = new AnthropicChatCompletionResponse(); + responseWithEmptyList.setContent(List.of()); + assertThat(responseWithEmptyList.getContent()).isEmpty(); + } + + @Test + void testRequestAnthropicVersionDefault() { + final var request = new AnthropicChatCompletionRequest(); + assertThat(request.getAnthropicVersion()).isEqualTo("bedrock-2023-05-31"); + } +} diff --git a/foundation-models/anthropic/src/test/resources/__files/invokeResponse.json b/foundation-models/anthropic/src/test/resources/__files/invokeResponse.json new file mode 100644 index 000000000..a975b1fdb --- /dev/null +++ b/foundation-models/anthropic/src/test/resources/__files/invokeResponse.json @@ -0,0 +1,18 @@ +{ + "id": "msg_01XFDUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello! How can I assist you today?" + } + ], + "model": "claude-3-haiku-20240307", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 10, + "output_tokens": 9 + } +} diff --git a/foundation-models/anthropic/src/test/resources/__files/invokeStreamResponse.txt b/foundation-models/anthropic/src/test/resources/__files/invokeStreamResponse.txt new file mode 100644 index 000000000..2b27d9aef --- /dev/null +++ b/foundation-models/anthropic/src/test/resources/__files/invokeStreamResponse.txt @@ -0,0 +1,21 @@ +data: {"type":"message_start","message":{"id":"msg_01XFDUDYJgAACzvnptvVoYEL","type":"message","role":"assistant","content":[],"model":"claude-3-haiku-20240307","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}} + +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" How can I"}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" assist"}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" you today?"}} + +data: {"type":"content_block_stop","index":0} + +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":9}} + +data: {"type":"message_stop"} + +data: [DONE] diff --git a/foundation-models/anthropic/src/test/resources/mappings/invokeResponse.json b/foundation-models/anthropic/src/test/resources/mappings/invokeResponse.json new file mode 100644 index 000000000..8072f220b --- /dev/null +++ b/foundation-models/anthropic/src/test/resources/mappings/invokeResponse.json @@ -0,0 +1,30 @@ +{ + "request": { + "method": "POST", + "url": "/invoke" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "id": "msg_01XFDUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello! How can I assist you today?" + } + ], + "model": "claude-3-haiku-20240307", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 10, + "output_tokens": 9 + } + } + } +} diff --git a/pom.xml b/pom.xml index b69da6c27..d84c2582f 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ core-services/prompt-registry foundation-models/openai foundation-models/sap-rpt + foundation-models/anthropic scm:git:git://github.com/SAP/ai-sdk-java.git @@ -346,6 +347,11 @@ openai ${project.version} + + com.sap.ai.sdk.foundationmodels + anthropic + ${project.version} + com.sap.ai.sdk.foundationmodels sap-rpt diff --git a/sample-code/spring-app/pom.xml b/sample-code/spring-app/pom.xml index 507d66d8e..79c377e7f 100644 --- a/sample-code/spring-app/pom.xml +++ b/sample-code/spring-app/pom.xml @@ -131,6 +131,10 @@ com.sap.ai.sdk.foundationmodels sap-rpt + + com.sap.ai.sdk.foundationmodels + anthropic + com.sap.cloud.sdk.cloudplatform cloudplatform-core diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java new file mode 100644 index 000000000..d1ea3d120 --- /dev/null +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -0,0 +1,54 @@ +package com.sap.ai.sdk.app.controllers; + +import com.sap.ai.sdk.app.services.AnthropicService; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; + +/** Endpoints for Anthropic Claude operations. */ +@Slf4j +@RestController +@SuppressWarnings("unused") +public class AnthropicController { + + @Autowired private AnthropicService service; + + /** Chat completion request to Anthropic Claude. */ + @GetMapping("/anthropic/chatCompletion") + @Nonnull + String chatCompletion() { + return service.chatCompletion("What is the capital of France?").getContent(); + } + + /** Streaming chat completion request to Anthropic Claude. */ + @GetMapping("/anthropic/streamChatCompletion") + @Nonnull + ResponseBodyEmitter streamChatCompletion() { + final var emitter = new ResponseBodyEmitter(); + final var stream = service.streamChatCompletion("What is the capital of France?"); + new Thread( + () -> { + try { + stream.forEach( + delta -> { + final var content = delta.getDeltaContent(); + if (!content.isEmpty()) { + try { + emitter.send(content); + } catch (final Exception e) { + emitter.completeWithError(e); + } + } + }); + emitter.complete(); + } catch (final Exception e) { + emitter.completeWithError(e); + } + }) + .start(); + return emitter; + } +} diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java new file mode 100644 index 000000000..88cd598d4 --- /dev/null +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java @@ -0,0 +1,45 @@ +package com.sap.ai.sdk.app.services; + +import static com.sap.ai.sdk.foundationmodels.anthropic.AnthropicModel.CLAUDE_45_HAIKU; + +import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicChatCompletionDelta; +import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicChatCompletionRequest; +import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicChatCompletionResponse; +import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicClient; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import org.springframework.stereotype.Service; + +/** Service to interact with the Anthropic Claude model. */ +@Service +public class AnthropicService { + + static final AnthropicClient client = AnthropicClient.forModel(CLAUDE_45_HAIKU); + + /** + * Performs a synchronous chat completion request. + * + * @param userMessage the user's message + * @return the chat completion response + */ + @Nonnull + public AnthropicChatCompletionResponse chatCompletion(@Nonnull final String userMessage) { + final var request = + new AnthropicChatCompletionRequest().withUserMessage(userMessage).withMaxTokens(1000); + return client.chatCompletion(request); + } + + /** + * Performs a streaming chat completion request. + * + * @param userMessage the user's message + * @return a stream of chat completion deltas + */ + @Nonnull + public Stream streamChatCompletion( + @Nonnull final String userMessage) { + final var request = + new AnthropicChatCompletionRequest().withUserMessage(userMessage).withMaxTokens(1000); + return client.streamChatCompletion(request); + } +} diff --git a/sample-code/spring-app/src/main/resources/static/Anthropic-Logo.svg b/sample-code/spring-app/src/main/resources/static/Anthropic-Logo.svg new file mode 100644 index 000000000..cbcc39ea9 --- /dev/null +++ b/sample-code/spring-app/src/main/resources/static/Anthropic-Logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sample-code/spring-app/src/main/resources/static/index.html b/sample-code/spring-app/src/main/resources/static/index.html index 8c8c9cdcf..f42d7d245 100644 --- a/sample-code/spring-app/src/main/resources/static/index.html +++ b/sample-code/spring-app/src/main/resources/static/index.html @@ -845,6 +845,35 @@

πŸ§‘β€πŸ”¬ Foundation Models

href="https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/consume-generative-ai-models-using-sap-ai-core" target="_blank">Foundation Models documentation. +
+
+ Anthropic Logo +
+
    +
  • +
    + +
    + Chat request to Anthropic Claude. +
    +
    +
  • +
  • +
    + +
    + Asynchronous stream of an Anthropic Claude chat request. +
    +
    +
  • +
+
OpenAI Logo diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java new file mode 100644 index 000000000..2a29a5dc3 --- /dev/null +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java @@ -0,0 +1,33 @@ +package com.sap.ai.sdk.app.controllers; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.ai.sdk.app.services.AnthropicService; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AnthropicTest { + final AnthropicService service = new AnthropicService(); + + @Test + void chatCompletion() { + final var response = service.chatCompletion("What is the capital of France?"); + + assertThat(response).isNotNull(); + assertThat(response.getContent()).containsIgnoringCase("paris"); + assertThat(response.getStopReason()).isEqualTo("end_turn"); + assertThat(response.getUsage()).isNotNull(); + assertThat(response.getUsage().getOutputTokens()).isGreaterThan(0); + } + + @Test + void streamChatCompletion() { + final var fullResponse = new AtomicReference<>(""); + + service + .streamChatCompletion("What is the capital of France?") + .forEach(delta -> fullResponse.accumulateAndGet(delta.getDeltaContent(), String::concat)); + + assertThat(fullResponse.get()).containsIgnoringCase("paris"); + } +} From f428b979805f4ea6bee1af18257098ea0c8b43ac Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Tue, 13 Oct 2026 17:23:24 +0200 Subject: [PATCH 02/26] update release notes --- docs/release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release_notes.md b/docs/release_notes.md index de2ca7866..95b28e9b3 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -13,7 +13,7 @@ ### ✨ New Functionality -- +- [Foundation models] Added Anthropics models support. See sample app in `sample-code/spring-app` for usage and documentation for more information. ### πŸ“ˆ Improvements From 9a721d0ad68cc5eb4ecc9bd4253ea6f429e96d14 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Thu, 15 Oct 2026 15:22:37 +0200 Subject: [PATCH 03/26] rework implementation into a lower code-footprint approach --- foundation-models/anthropic/pom.xml | 42 ++- .../anthropic/AiCoreAnthropicClient.java | 93 ++++++ .../AnthropicChatCompletionDelta.java | 96 ------ .../AnthropicChatCompletionRequest.java | 114 -------- .../AnthropicChatCompletionResponse.java | 82 ------ .../anthropic/AnthropicChatMessage.java | 66 ----- .../anthropic/AnthropicClient.java | 166 ----------- .../anthropic/AnthropicClientException.java | 17 -- .../anthropic/AnthropicContentBlock.java | 38 --- .../anthropic/AnthropicError.java | 25 -- .../anthropic/SapAnthropicHttpClient.java | 276 ++++++++++++++++++ ...st.java => AiCoreAnthropicClientTest.java} | 218 +++++++------- .../anthropic/SapAnthropicHttpClientTest.java | 207 +++++++++++++ pom.xml | 12 + sample-code/spring-app/pom.xml | 4 + .../app/controllers/AnthropicController.java | 32 +- .../ai/sdk/app/services/AnthropicService.java | 45 +-- .../ai/sdk/app/controllers/AnthropicTest.java | 31 +- 18 files changed, 795 insertions(+), 769 deletions(-) create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java delete mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java create mode 100644 foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java rename foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/{AnthropicClientTest.java => AiCoreAnthropicClientTest.java} (51%) create mode 100644 foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java diff --git a/foundation-models/anthropic/pom.xml b/foundation-models/anthropic/pom.xml index 31073633f..58931b022 100644 --- a/foundation-models/anthropic/pom.xml +++ b/foundation-models/anthropic/pom.xml @@ -46,6 +46,37 @@ 100% + + + + + org.jetbrains.kotlin + kotlin-reflect + 1.9.25 + + + org.jetbrains.kotlin + kotlin-stdlib + 1.9.25 + + + org.jetbrains.kotlin + kotlin-stdlib-jdk7 + 1.9.25 + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + 1.9.25 + + + org.jetbrains.kotlin + kotlin-stdlib-common + 1.9.25 + + + + com.sap.ai.sdk @@ -68,16 +99,17 @@ httpcore5 - com.fasterxml.jackson.core - jackson-databind + com.anthropic + anthropic-java-core + ${anthropic-java.version} com.fasterxml.jackson.core - jackson-core + jackson-databind - com.fasterxml.jackson.core - jackson-annotations + com.sap.cloud.sdk.cloudplatform + cloudplatform-core com.google.code.findbugs diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java new file mode 100644 index 000000000..c2b444c07 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java @@ -0,0 +1,93 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.AnthropicClientImpl; +import com.anthropic.core.ClientOptions; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.DeploymentResolutionException; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import javax.annotation.Nonnull; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * Factory for creating official Anthropic SDK clients configured for SAP AI Core deployments. + * + *

Returns a fully configured {@link AnthropicClient} that routes all HTTP traffic through the + * SAP Cloud SDK's Apache HttpClient, providing automatic OAuth token refresh and destination + * management. Requests are automatically adapted from the native Anthropic Messages API format to + * the SAP AI Core Bedrock-style invoke endpoint. + * + *

Usage: + * + *

{@code
+ * var client = AiCoreAnthropicClient.forModel(AnthropicModel.CLAUDE_45_SONNET).client();
+ * var response = client.messages().create(
+ *     MessageCreateParams.builder()
+ *         .model(Model.CLAUDE_CLAUDE_4_5_SONNET)
+ *         .maxTokens(1024)
+ *         .addUserMessage("What is the capital of France?")
+ *         .build());
+ * }
+ * + * @since 1.26.0 + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class AiCoreAnthropicClient { + + private static final String DEFAULT_RESOURCE_GROUP = "default"; + + private final HttpDestination destination; + + /** + * Create a client for a deployment serving the specified model using the default resource group. + * + * @param model the Anthropic model to target. + * @return a configured factory instance. + * @throws DeploymentResolutionException if no running deployment is found for the model. + */ + @Nonnull + public static AiCoreAnthropicClient forModel(@Nonnull final AnthropicModel model) { + return forModel(model, DEFAULT_RESOURCE_GROUP); + } + + /** + * Create a client for a deployment serving the specified model in the given resource group. + * + * @param model the Anthropic model to target. + * @param resourceGroup the resource group containing the deployment. + * @return a configured factory instance. + * @throws DeploymentResolutionException if no running deployment is found for the model. + */ + @Nonnull + public static AiCoreAnthropicClient forModel( + @Nonnull final AnthropicModel model, @Nonnull final String resourceGroup) { + final var destination = + new AiCoreService().getInferenceDestination(resourceGroup).forModel(model); + return forDestination(destination); + } + + @Nonnull + static AiCoreAnthropicClient forDestination(@Nonnull final HttpDestination destination) { + return new AiCoreAnthropicClient(destination); + } + + /** + * Get a configured {@link AnthropicClient} for the deployment. + * + *

The client uses SAP Cloud SDK's Apache HttpClient for all HTTP communication. Anthropic API + * key authentication is disabled since the SAP AI Core gateway handles OAuth token-based + * authorization. + * + * @return a ready-to-use Anthropic client. + */ + @Nonnull + public AnthropicClient client() { + final var options = + ClientOptions.builder() + .httpClient(new SapAnthropicHttpClient(destination)) + .baseUrl(destination.getUri().toString()) + .build(); + return new AnthropicClientImpl(options); + } +} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java deleted file mode 100644 index 14f07ef18..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionDelta.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.sap.ai.sdk.core.common.StreamedDelta; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import lombok.Data; -import lombok.experimental.Accessors; - -/** - * A single streamed event from the Anthropic {@code invoke-with-response-stream} endpoint. - * - *

The stream returns server-sent events containing Claude streaming delta events. Each event - * carries one of: {@code message_start}, {@code content_block_start}, {@code content_block_delta}, - * {@code content_block_stop}, {@code message_delta}, or {@code message_stop}. - * - * @since 1.26.0 - */ -@Data -@Accessors(chain = true) -public class AnthropicChatCompletionDelta implements StreamedDelta { - - @Nullable - @JsonProperty("type") - private String type; - - @Nullable - @JsonProperty("index") - private Integer index; - - @Nullable - @JsonProperty("delta") - private Delta delta; - - @Nullable - @JsonProperty("usage") - private AnthropicChatCompletionResponse.AnthropicUsage usage; - - /** - * Get the streamed text delta content from this event. - * - * @return the text delta, or an empty string if this event does not carry text content. - */ - @Nonnull - @Override - public String getDeltaContent() { - if (delta == null) { - return ""; - } - final var text = delta.getText(); - return text != null ? text : ""; - } - - /** - * Get the stop reason from a {@code message_delta} event, mapped to the finish reason. - * - * @return the stop/finish reason, or {@code null} if not present. - */ - @Nullable - @Override - public String getFinishReason() { - return getStopReason(); - } - - /** - * Get the stop reason from a {@code message_delta} event. - * - * @return the stop reason, or {@code null} if not present. - */ - @Nullable - public String getStopReason() { - return delta != null ? delta.getStopReason() : null; - } - - /** The delta payload in a {@code content_block_delta} or {@code message_delta} event. */ - @Data - @Accessors(chain = true) - public static class Delta { - - @Nullable - @JsonProperty("type") - private String type; - - @Nullable - @JsonProperty("text") - private String text; - - @Nullable - @JsonProperty("stop_reason") - private String stopReason; - - @Nullable - @JsonProperty("stop_sequence") - private String stopSequence; - } -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java deleted file mode 100644 index 9898b0a71..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionRequest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.ArrayList; -import java.util.List; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import lombok.Data; -import lombok.experimental.Accessors; - -/** - * A request to Claude via the Anthropic Messages API. - * - * @since 1.26.0 - */ -@Data -@Accessors(chain = true) -@JsonInclude(JsonInclude.Include.NON_NULL) -public class AnthropicChatCompletionRequest { - - /** - * The Anthropic API version to use. Required by the Claude model. - * - * @since 1.26.0 - */ - @Nonnull - @JsonProperty("anthropic_version") - private String anthropicVersion = "bedrock-2023-05-31"; - - @Nonnull - @JsonProperty("messages") - private List messages = new ArrayList<>(); - - @Nullable - @JsonProperty("system") - private String system; - - @Nullable - @JsonProperty("max_tokens") - private Integer maxTokens; - - @Nullable - @JsonProperty("temperature") - private Double temperature; - - @Nullable - @JsonProperty("top_p") - private Double topP; - - @Nullable - @JsonProperty("stop_sequences") - private List stopSequences; - - /** - * Add a user message to this request. - * - * @param text the text content. - * @return this request. - */ - @Nonnull - public AnthropicChatCompletionRequest withUserMessage(@Nonnull final String text) { - messages.add(AnthropicChatMessage.ofUser(text)); - return this; - } - - /** - * Add an assistant message to this request (for multi-turn conversations). - * - * @param text the text content. - * @return this request. - */ - @Nonnull - public AnthropicChatCompletionRequest withAssistantMessage(@Nonnull final String text) { - messages.add(AnthropicChatMessage.ofAssistant(text)); - return this; - } - - /** - * Set the system prompt for this request. - * - * @param systemPrompt the system prompt text. - * @return this request. - */ - @Nonnull - public AnthropicChatCompletionRequest withSystemPrompt(@Nonnull final String systemPrompt) { - system = systemPrompt; - return this; - } - - /** - * Set the maximum number of output tokens. Required by Claude models. - * - * @param maxTokens the maximum token count. - * @return this request. - */ - @Nonnull - public AnthropicChatCompletionRequest withMaxTokens(final int maxTokens) { - this.maxTokens = maxTokens; - return this; - } - - /** - * Set the sampling temperature. - * - * @param temperature the temperature value (0.0 to 1.0). - * @return this request. - */ - @Nonnull - public AnthropicChatCompletionRequest withTemperature(final double temperature) { - this.temperature = temperature; - return this; - } -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java deleted file mode 100644 index 83c54cb73..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatCompletionResponse.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import lombok.Data; -import lombok.experimental.Accessors; - -/** - * A response from the Anthropic Messages API. - * - * @since 1.26.0 - */ -@Data -@Accessors(chain = true) -public class AnthropicChatCompletionResponse { - - @Nullable - @JsonProperty("id") - private String id; - - @Nullable - @JsonProperty("type") - private String type; - - @Nullable - @JsonProperty("role") - private String role; - - @Nullable - @JsonProperty("content") - private List content; - - @Nullable - @JsonProperty("model") - private String model; - - @Nullable - @JsonProperty("stop_reason") - private String stopReason; - - @Nullable - @JsonProperty("stop_sequence") - private String stopSequence; - - @Nullable - @JsonProperty("usage") - private AnthropicUsage usage; - - /** - * Get the first text content from the response. - * - * @return the text content, or an empty string if not present. - */ - @Nonnull - public String getContent() { - if (content == null || content.isEmpty()) { - return ""; - } - final var text = content.get(0).getText(); - return text != null ? text : ""; - } - - /** - * Token usage information from the Anthropic Messages API response. - * - * @since 1.26.0 - */ - @Data - @Accessors(chain = true) - public static class AnthropicUsage { - - @Nullable - @JsonProperty("input_tokens") - private Integer inputTokens; - - @Nullable - @JsonProperty("output_tokens") - private Integer outputTokens; - } -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java deleted file mode 100644 index a45c4aa73..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicChatMessage.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import lombok.Data; -import lombok.experimental.Accessors; - -/** - * A message in the Anthropic Messages API format. - * - * @since 1.26.0 - */ -@Data -@Accessors(chain = true) -public class AnthropicChatMessage { - - /** - * The role of the message sender. - * - * @since 1.26.0 - */ - public enum Role { - /** The user role. */ - @JsonProperty("user") - USER, - /** The assistant role. */ - @JsonProperty("assistant") - ASSISTANT - } - - @Nullable - @JsonProperty("role") - private Role role; - - @Nullable - @JsonProperty("content") - private List content; - - /** - * Create a user message with the given text. - * - * @param text the text content. - * @return a user message. - */ - @Nonnull - public static AnthropicChatMessage ofUser(@Nonnull final String text) { - return new AnthropicChatMessage() - .setRole(Role.USER) - .setContent(List.of(AnthropicContentBlock.ofText(text))); - } - - /** - * Create an assistant message with the given text. - * - * @param text the text content. - * @return an assistant message. - */ - @Nonnull - public static AnthropicChatMessage ofAssistant(@Nonnull final String text) { - return new AnthropicChatMessage() - .setRole(Role.ASSISTANT) - .setContent(List.of(AnthropicContentBlock.ofText(text))); - } -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java deleted file mode 100644 index 2c318f2d3..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClient.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import static com.sap.ai.sdk.foundationmodels.anthropic.AnthropicClientException.FACTORY; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.core.DeploymentResolutionException; -import com.sap.ai.sdk.core.JacksonConfiguration; -import com.sap.ai.sdk.core.common.ClientResponseHandler; -import com.sap.ai.sdk.core.common.ClientStreamingHandler; -import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; -import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; -import java.io.IOException; -import java.util.stream.Stream; -import javax.annotation.Nonnull; -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.hc.client5.http.classic.methods.HttpPost; -import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.io.entity.StringEntity; - -/** - * Client for interacting with Anthropic Claude models via SAP AI Core. - * - *

Exposes synchronous and streaming chat completion using the invoke and - * invoke-with-response-stream endpoints, routed through the SAP AI Core inference gateway with SAP - * destination-based authorization. - * - * @since 1.26.0 - */ -@Slf4j -@RequiredArgsConstructor(access = AccessLevel.PRIVATE) -public class AnthropicClient { - - private static final String INVOKE_PATH = "invoke"; - private static final String INVOKE_STREAM_PATH = "invoke-with-response-stream"; - private static final ObjectMapper JACKSON = JacksonConfiguration.getDefaultObjectMapper(); - - @Nonnull private final Destination destination; - - /** - * Create a new Anthropic client for the given model using the default resource group. - * - * @param model the Anthropic model to use. - * @return a configured Anthropic client. - * @throws DeploymentResolutionException if no running deployment is found for the model. - */ - @Nonnull - public static AnthropicClient forModel(@Nonnull final AnthropicModel model) - throws DeploymentResolutionException { - final var destination = new AiCoreService().getInferenceDestination().forModel(model); - return forDestination(destination); - } - - /** - * Create a new Anthropic client for the given model in a specific resource group. - * - * @param model the Anthropic model to use. - * @param resourceGroup the resource group containing the deployment. - * @return a configured Anthropic client. - * @throws DeploymentResolutionException if no running deployment is found for the model. - */ - @Nonnull - public static AnthropicClient forModel( - @Nonnull final AnthropicModel model, @Nonnull final String resourceGroup) - throws DeploymentResolutionException { - final var destination = - new AiCoreService().getInferenceDestination(resourceGroup).forModel(model); - return forDestination(destination); - } - - /** - * Create a new Anthropic client using a custom destination. - * - * @param destination the destination pointing to a Claude model deployment. - * @return a configured Anthropic client. - */ - @Nonnull - public static AnthropicClient forDestination(@Nonnull final Destination destination) { - return new AnthropicClient(destination); - } - - /** - * Perform a synchronous chat completion using the {@code invoke} endpoint. - * - * @param request the chat completion request. - * @return the completion response. - * @throws AnthropicClientException if the request fails. - */ - @Nonnull - public AnthropicChatCompletionResponse chatCompletion( - @Nonnull final AnthropicChatCompletionRequest request) throws AnthropicClientException { - return execute(INVOKE_PATH, request, AnthropicChatCompletionResponse.class); - } - - /** - * Perform a streaming chat completion using the {@code invoke-with-response-stream} endpoint. - * - *

Returns a lazily-populated stream of delta events. The stream must be consumed in a - * try-with-resources block to ensure the underlying HTTP connection is released. - * - *

Example: - * - *

{@code
-   * try (var stream = client.streamChatCompletion(request)) {
-   *   stream.map(AnthropicChatCompletionDelta::getDeltaContent).forEach(System.out::print);
-   * }
-   * }
- * - * @param request the chat completion request. - * @return a stream of delta chunks. - * @throws AnthropicClientException if the request fails. - */ - @Nonnull - public Stream streamChatCompletion( - @Nonnull final AnthropicChatCompletionRequest request) throws AnthropicClientException { - return executeStream(INVOKE_STREAM_PATH, request, AnthropicChatCompletionDelta.class); - } - - @Nonnull - private T execute( - @Nonnull final String path, - @Nonnull final Object payload, - @Nonnull final Class responseType) { - final var httpRequest = buildHttpPost(path, payload); - try { - final var client = ApacheHttpClient5Accessor.getHttpClient(destination); - return client.execute( - httpRequest, new ClientResponseHandler<>(responseType, AnthropicError.class, FACTORY)); - } catch (final IOException e) { - throw new AnthropicClientException("Request to Anthropic model failed", e) - .setHttpRequest(httpRequest); - } - } - - @Nonnull - private Stream executeStream( - @Nonnull final String path, - @Nonnull final Object payload, - @Nonnull final Class deltaType) { - final var httpRequest = buildHttpPost(path, payload); - try { - final var client = ApacheHttpClient5Accessor.getHttpClient(destination); - return new ClientStreamingHandler<>(deltaType, AnthropicError.class, FACTORY) - .handleStreamingResponse(client.executeOpen(null, httpRequest, null)); - } catch (final IOException e) { - throw new AnthropicClientException("Streaming request to Anthropic model failed", e) - .setHttpRequest(httpRequest); - } - } - - @Nonnull - private static HttpPost buildHttpPost(@Nonnull final String path, @Nonnull final Object payload) { - final var httpRequest = new HttpPost(path); - try { - final var json = JACKSON.writeValueAsString(payload); - httpRequest.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON)); - } catch (final JsonProcessingException e) { - throw new AnthropicClientException("Failed to serialize request", e) - .setHttpRequest(httpRequest); - } - return httpRequest; - } -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java deleted file mode 100644 index 7b261f843..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientException.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import com.sap.ai.sdk.core.common.ClientException; -import com.sap.ai.sdk.core.common.ClientExceptionFactory; -import lombok.experimental.StandardException; - -/** - * Exception thrown when an Anthropic API call fails. - * - * @since 1.26.0 - */ -@StandardException -public class AnthropicClientException extends ClientException { - - static final ClientExceptionFactory FACTORY = - (message, error, cause) -> new AnthropicClientException(message, cause).setClientError(error); -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java deleted file mode 100644 index 242e633ed..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicContentBlock.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import lombok.Data; -import lombok.experimental.Accessors; - -/** - * A content block in an Anthropic message. - * - * @since 1.26.0 - */ -@Data -@Accessors(chain = true) -@JsonInclude(JsonInclude.Include.NON_NULL) -public class AnthropicContentBlock { - - @Nullable - @JsonProperty("type") - private String type; - - @Nullable - @JsonProperty("text") - private String text; - - /** - * Create a text content block. - * - * @param text the text. - * @return a text content block. - */ - @Nonnull - public static AnthropicContentBlock ofText(@Nonnull final String text) { - return new AnthropicContentBlock().setType("text").setText(text); - } -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java deleted file mode 100644 index aa97c9cfe..000000000 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicError.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.anthropic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.sap.ai.sdk.core.common.ClientError; -import javax.annotation.Nullable; -import lombok.Data; -import lombok.experimental.Accessors; - -/** - * Represents an error response from the Anthropic Messages API. - * - * @since 1.26.0 - */ -@Data -@Accessors(chain = true) -public class AnthropicError implements ClientError { - - @Nullable - @JsonProperty("message") - private String message; - - @Nullable - @JsonProperty("type") - private String type; -} diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java new file mode 100644 index 000000000..8a2b08e2b --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java @@ -0,0 +1,276 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import com.anthropic.core.RequestOptions; +import com.anthropic.core.http.Headers; +import com.anthropic.core.http.HttpClient; +import com.anthropic.core.http.HttpRequest; +import com.anthropic.core.http.HttpResponse; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sap.ai.sdk.core.JacksonConfiguration; +import com.sap.ai.sdk.core.common.ClientException; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; + +/** + * Bridges the official Anthropic Java SDK's {@link HttpClient} interface to SAP Cloud SDK's Apache + * HttpClient, enabling SAP AI Core OAuth authentication for Anthropic API requests. + * + *

Rewrites the Anthropic API URL ({@code /v1/messages}) to the SAP Bedrock-style endpoints + * ({@code /invoke} or {@code /invoke-with-response-stream}) and transforms the request body to add + * the required {@code anthropic_version} field and remove the {@code model} field. + */ +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) +@SuppressWarnings( + "PMD.CloseResource") // client lifecycle is managed by its user, should be explicitly closed +class SapAnthropicHttpClient implements HttpClient { + + private static final String INVOKE_PATH = "invoke"; + private static final String INVOKE_STREAM_PATH = "invoke-with-response-stream"; + private static final String SSE_MEDIA_TYPE = "text/event-stream"; + private static final String ANTHROPIC_VERSION = "bedrock-2023-05-31"; + + private final HttpDestination destination; + + @Override + @Nonnull + public HttpResponse execute( + @Nonnull final HttpRequest request, @Nonnull final RequestOptions requestOptions) { + final var apacheClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final var isStreaming = isStreaming(request); + final var apacheRequest = toApacheRequest(request, isStreaming); + + try { + if (isStreaming) { + final var apacheResponse = apacheClient.executeOpen(null, apacheRequest, null); + final int statusCode = apacheResponse.getCode(); + if (statusCode >= 200 && statusCode < 300) { + return createStreamingResponse(apacheResponse); + } + return createBufferedResponse(apacheResponse); + } else { + return apacheClient.execute(apacheRequest, SapAnthropicHttpClient::createBufferedResponse); + } + } catch (final IOException e) { + throw new ClientException("Anthropic HTTP request failed", e); + } + } + + @Override + @Nonnull + public CompletableFuture executeAsync( + @Nonnull final HttpRequest request, @Nonnull final RequestOptions requestOptions) { + return CompletableFuture.supplyAsync( + () -> execute(request, requestOptions), ThreadContextExecutors.getExecutor()); + } + + @Override + public void close() { + // Apache HttpClient lifecycle is managed by Cloud SDK's ApacheHttpClient5Cache + } + + @Nonnull + private ClassicHttpRequest toApacheRequest( + @Nonnull final HttpRequest request, final boolean isStreaming) { + final var targetPath = isStreaming ? INVOKE_STREAM_PATH : INVOKE_PATH; + final var apacheRequest = new BasicClassicHttpRequest(request.method().name(), targetPath); + + for (final var name : request.headers().names()) { + if (shouldSkipHeader(name)) { + continue; + } + for (final var value : request.headers().values(name)) { + apacheRequest.addHeader(name, value); + } + } + + try (var requestBody = request.body()) { + if (requestBody != null) { + try (var outputStream = new ByteArrayOutputStream()) { + requestBody.writeTo(outputStream); + final var bodyBytes = transformBody(outputStream.toByteArray()); + final var contentType = + requestBody.contentType() != null + ? ContentType.parse(requestBody.contentType()) + : ContentType.APPLICATION_JSON; + apacheRequest.setEntity(new ByteArrayEntity(bodyBytes, contentType)); + } catch (final IOException e) { + throw new ClientException("Failed to read Anthropic request body", e); + } + } + } + + return apacheRequest; + } + + private static boolean shouldSkipHeader(@Nonnull final String name) { + return "Authorization".equalsIgnoreCase(name) + || "x-api-key".equalsIgnoreCase(name) + || "anthropic-version".equalsIgnoreCase(name); + } + + private static boolean isStreaming(@Nonnull final HttpRequest request) { + return request.headers().values("Accept").stream() + .map(value -> Objects.toString(value, "").toLowerCase(Locale.ROOT)) + .anyMatch(value -> value.contains(SSE_MEDIA_TYPE)); + } + + /** Adds {@code anthropic_version} and removes {@code model} from the JSON body. */ + @Nonnull + private static byte[] transformBody(@Nonnull final byte[] bodyBytes) { + try { + final var mapper = JacksonConfiguration.getDefaultObjectMapper(); + final var node = (ObjectNode) mapper.readTree(bodyBytes); + node.remove("model"); + node.remove("stream"); + node.put("anthropic_version", ANTHROPIC_VERSION); + return mapper.writeValueAsBytes(node); + } catch (final IOException e) { + throw new ClientException("Failed to transform Anthropic request body", e); + } + } + + @Nonnull + private static Headers extractResponseHeaders(@Nonnull final ClassicHttpResponse apacheResponse) { + final var builder = Headers.builder(); + for (final var header : apacheResponse.getHeaders()) { + builder.put(header.getName(), header.getValue()); + } + return builder.build(); + } + + @Nonnull + private static HttpResponse createStreamingResponse( + @Nonnull final ClassicHttpResponse apacheResponse) throws IOException { + final var statusCode = apacheResponse.getCode(); + final var headers = extractResponseHeaders(apacheResponse); + final var rawStream = + apacheResponse.getEntity() != null + ? apacheResponse.getEntity().getContent() + : InputStream.nullInputStream(); + final var rewritten = rewriteBedrockSseToAnthropicSse(rawStream); + return new StreamingHttpResponse(statusCode, headers, rewritten, apacheResponse); + } + + /** + * SAP's Bedrock endpoint emits {@code data:} lines with no {@code event:} field. The Anthropic + * SDK's SSE parser requires {@code event:} lines and drops events without one. This method wraps + * the raw stream and injects {@code event: } before each {@code data:} line, reading the + * type from the JSON payload. + */ + @Nonnull + private static InputStream rewriteBedrockSseToAnthropicSse(@Nonnull final InputStream raw) + throws IOException { + final var piped = new PipedInputStream(8192); + final var out = new PipedOutputStream(piped); + final var writer = new PrintWriter(out, false, StandardCharsets.UTF_8); + final var reader = new BufferedReader(new InputStreamReader(raw, StandardCharsets.UTF_8)); + final var mapper = JacksonConfiguration.getDefaultObjectMapper(); + + ThreadContextExecutors.getExecutor() + .execute( + () -> { + try (raw; + out; + writer; + reader) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("data:")) { + final var data = line.substring(5).stripLeading(); + if (!data.equals("[DONE]")) { + try { + final var node = mapper.readTree(data); + final var type = node.path("type").asText(null); + if (type != null) { + writer.print("event: " + type + "\n"); + } + } catch (final IOException ignored) { + // non-JSON data line β€” emit as-is without event prefix + } + } + } + writer.print(line + "\n"); + } + writer.flush(); + } catch (final IOException e) { + log.debug("Error rewriting Bedrock SSE stream", e); + } + }); + + return piped; + } + + @Nonnull + private static HttpResponse createBufferedResponse( + @Nonnull final ClassicHttpResponse apacheResponse) throws IOException { + try (apacheResponse) { + final int statusCode = apacheResponse.getCode(); + final var headers = extractResponseHeaders(apacheResponse); + final var body = + apacheResponse.getEntity() != null + ? EntityUtils.toByteArray(apacheResponse.getEntity()) + : new byte[0]; + return new BufferedHttpResponse(statusCode, headers, body); + } + } + + record StreamingHttpResponse( + int statusCode, + @Nonnull Headers headers, + @Nonnull InputStream body, + @Nonnull ClassicHttpResponse apacheResponse) + implements HttpResponse { + + @Override + public void close() { + try { + body.close(); + } catch (final IOException e) { + log.debug("Failed to close streaming response body", e); + } + try { + apacheResponse.close(); + } catch (final IOException e) { + log.debug("Failed to close streaming response connection", e); + } + } + } + + record BufferedHttpResponse(int statusCode, @Nonnull Headers headers, @Nonnull byte[] bodyBytes) + implements HttpResponse { + + @Nonnull + @Override + public InputStream body() { + return new ByteArrayInputStream(bodyBytes); + } + + @Override + public void close() {} + } +} diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java similarity index 51% rename from foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientTest.java rename to foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index d681722f3..9efbf6c52 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -15,15 +15,17 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; +import com.anthropic.errors.AnthropicException; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.RawContentBlockDeltaEvent; +import com.anthropic.models.messages.StopReason; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Cache; import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; import java.io.IOException; -import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import org.apache.hc.client5.http.classic.HttpClient; @@ -35,14 +37,14 @@ import org.junit.jupiter.api.Test; @WireMockTest -class AnthropicClientTest { +class AiCoreAnthropicClientTest { - private AnthropicClient client; + private AiCoreAnthropicClient client; @BeforeEach void setup(final WireMockRuntimeInfo server) { final var destination = DefaultHttpDestination.builder(server.getHttpBaseUrl()).build(); - client = AnthropicClient.forDestination(destination); + client = AiCoreAnthropicClient.forDestination(destination); ApacheHttpClient5Accessor.setHttpClientCache(ApacheHttpClient5Cache.DISABLED); } @@ -65,7 +67,7 @@ void testAnthropicModels() { } @Test - void testChatCompletion(final WireMockRuntimeInfo server) { + void testChatCompletion() { stubFor( post(urlPathEqualTo("/invoke")) .willReturn( @@ -73,23 +75,27 @@ void testChatCompletion(final WireMockRuntimeInfo server) { .withBodyFile("invokeResponse.json") .withHeader("Content-Type", "application/json"))); - final var request = - new AnthropicChatCompletionRequest().withUserMessage("Hello!").withMaxTokens(256); + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); - final var response = client.chatCompletion(request); + final var response = client.client().messages().create(params); assertThat(response).isNotNull(); - assertThat(response.getId()).isEqualTo("msg_01XFDUDYJgAACzvnptvVoYEL"); - assertThat(response.getRole()).isEqualTo("assistant"); - assertThat(response.getStopReason()).isEqualTo("end_turn"); - assertThat(response.getContent()).isEqualTo("Hello! How can I assist you today?"); - assertThat(response.getUsage()).isNotNull(); - assertThat(response.getUsage().getInputTokens()).isEqualTo(10); - assertThat(response.getUsage().getOutputTokens()).isEqualTo(9); + assertThat(response.id()).isEqualTo("msg_01XFDUDYJgAACzvnptvVoYEL"); + assertThat(response.stopReason()).hasValue(StopReason.END_TURN); + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).text().map(t -> t.text())) + .hasValue("Hello! How can I assist you today?"); + assertThat(response.usage().inputTokens()).isEqualTo(10); + assertThat(response.usage().outputTokens()).isEqualTo(9); } @Test - void testChatCompletionRequestFormat(final WireMockRuntimeInfo server) { + void testChatCompletionRequestFormat() { stubFor( post(urlPathEqualTo("/invoke")) .willReturn( @@ -97,31 +103,32 @@ void testChatCompletionRequestFormat(final WireMockRuntimeInfo server) { .withBodyFile("invokeResponse.json") .withHeader("Content-Type", "application/json"))); - final var request = - new AnthropicChatCompletionRequest() - .withSystemPrompt("You are a helpful assistant.") - .withUserMessage("What is 2+2?") - .withMaxTokens(100) - .withTemperature(0.7); + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(100) + .addSystemMessage("You are a helpful assistant.") + .addUserMessage("What is 2+2?") + .build(); - client.chatCompletion(request); + client.client().messages().create(params); verify( postRequestedFor(urlPathEqualTo("/invoke")) - .withHeader("Content-Type", equalTo("application/json; charset=UTF-8")) + .withHeader("Content-Type", equalTo("application/json")) .withRequestBody( com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.anthropic_version", equalTo("bedrock-2023-05-31"))) .withRequestBody( com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( - "$.messages[0].role", equalTo("user"))) + "$.messages[1].role", equalTo("user"))) .withRequestBody( com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( - "$.system", equalTo("You are a helpful assistant.")))); + "$.messages[0].content", equalTo("You are a helpful assistant.")))); } @Test - void testChatCompletionMultiTurn(final WireMockRuntimeInfo server) { + void testModelFieldRemovedFromRequest() { stubFor( post(urlPathEqualTo("/invoke")) .willReturn( @@ -129,31 +136,26 @@ void testChatCompletionMultiTurn(final WireMockRuntimeInfo server) { .withBodyFile("invokeResponse.json") .withHeader("Content-Type", "application/json"))); - final var request = - new AnthropicChatCompletionRequest() - .withUserMessage("Hello!") - .withAssistantMessage("Hi there! How can I help?") - .withUserMessage("What is the capital of France?") - .withMaxTokens(256); + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); - final var response = client.chatCompletion(request); + client.client().messages().create(params); - assertThat(response).isNotNull(); - assertThat(request.getMessages()).hasSize(3); - assertThat(request.getMessages().get(0).getRole()).isEqualTo(AnthropicChatMessage.Role.USER); - assertThat(request.getMessages().get(1).getRole()) - .isEqualTo(AnthropicChatMessage.Role.ASSISTANT); - assertThat(request.getMessages().get(2).getRole()).isEqualTo(AnthropicChatMessage.Role.USER); + verify( + postRequestedFor(urlPathEqualTo("/invoke")) + .withRequestBody( + com.github.tomakehurst.wiremock.client.WireMock.notMatching(".*\"model\".*"))); } @Test void testStreamChatCompletion() throws IOException { final var inputStream = - spy( - Objects.requireNonNull( - getClass() - .getClassLoader() - .getResourceAsStream("__files/invokeStreamResponse.txt"))); + Objects.requireNonNull( + getClass().getClassLoader().getResourceAsStream("__files/invokeStreamResponse.txt")); final var httpClient = mock(HttpClient.class); ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); @@ -165,14 +167,21 @@ void testStreamChatCompletion() throws IOException { doReturn(mockResponse).when(httpClient).executeOpen(any(), any(), any()); - final var request = - new AnthropicChatCompletionRequest().withUserMessage("Hello!").withMaxTokens(256); + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); - try (var stream = client.streamChatCompletion(request)) { + try (var stream = client.client().messages().createStreaming(params)) { final var chunks = - stream - .map(AnthropicChatCompletionDelta::getDeltaContent) - .filter(s -> !s.isEmpty()) + stream.stream() + .filter(e -> e.isContentBlockDelta()) + .map(e -> e.asContentBlockDelta()) + .map(RawContentBlockDeltaEvent::delta) + .filter(d -> d.isText()) + .map(d -> d.asText().text()) .collect(Collectors.toList()); assertThat(chunks).containsExactly("Hello", "!", " How can I", " assist", " you today?"); @@ -182,11 +191,8 @@ void testStreamChatCompletion() throws IOException { @Test void testStreamChatCompletionReturnsStopReason() throws IOException { final var inputStream = - spy( - Objects.requireNonNull( - getClass() - .getClassLoader() - .getResourceAsStream("__files/invokeStreamResponse.txt"))); + Objects.requireNonNull( + getClass().getClassLoader().getResourceAsStream("__files/invokeStreamResponse.txt")); final var httpClient = mock(HttpClient.class); ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); @@ -198,33 +204,47 @@ void testStreamChatCompletionReturnsStopReason() throws IOException { doReturn(mockResponse).when(httpClient).executeOpen(any(), any(), any()); - final var request = - new AnthropicChatCompletionRequest().withUserMessage("Hello!").withMaxTokens(256); - - try (var stream = client.streamChatCompletion(request)) { - final var allDeltas = stream.collect(Collectors.toList()); - final var stopDelta = allDeltas.stream().filter(d -> d.getStopReason() != null).findFirst(); - - assertThat(stopDelta).isPresent(); - assertThat(stopDelta.get().getStopReason()).isEqualTo("end_turn"); + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); + + try (var stream = client.client().messages().createStreaming(params)) { + final var stopReason = + stream.stream() + .filter(e -> e.isMessageDelta()) + .map(e -> e.asMessageDelta().delta().stopReason()) + .filter(r -> r.isPresent()) + .map(r -> r.get()) + .findFirst(); + + assertThat(stopReason).hasValue(StopReason.END_TURN); } } @Test void testErrorHandling() { - final var errorJson = - """ - {"type":"error","error":{"type":"invalid_request_error","message":"max_tokens is required"}} - """; stubFor( post(anyUrl()) .willReturn( - badRequest().withBody(errorJson).withHeader("Content-Type", "application/json"))); + badRequest() + .withBody( + """ + {"type":"error","error":{"type":"invalid_request_error","message":"max_tokens is required"}} + """) + .withHeader("Content-Type", "application/json"))); - final var request = new AnthropicChatCompletionRequest().withUserMessage("Hello!"); + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); - assertThatThrownBy(() -> client.chatCompletion(request)) - .isInstanceOf(AnthropicClientException.class) + assertThatThrownBy(() -> client.client().messages().create(params)) + .isInstanceOf(AnthropicException.class) .hasMessageContaining("400"); } @@ -232,49 +252,15 @@ void testErrorHandling() { void testServerError() { stubFor(post(anyUrl()).willReturn(serverError())); - final var request = new AnthropicChatCompletionRequest().withUserMessage("Hello!"); + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); - assertThatThrownBy(() -> client.chatCompletion(request)) - .isInstanceOf(AnthropicClientException.class) + assertThatThrownBy(() -> client.client().messages().create(params)) + .isInstanceOf(AnthropicException.class) .hasMessageContaining("500"); } - - @Test - void testAnthropicMessage() { - final var userMsg = AnthropicChatMessage.ofUser("Hello"); - assertThat(userMsg.getRole()).isEqualTo(AnthropicChatMessage.Role.USER); - assertThat(userMsg.getContent()).hasSize(1); - assertThat(userMsg.getContent().get(0).getText()).isEqualTo("Hello"); - - final var assistantMsg = AnthropicChatMessage.ofAssistant("Hi there"); - assertThat(assistantMsg.getRole()).isEqualTo(AnthropicChatMessage.Role.ASSISTANT); - assertThat(assistantMsg.getContent().get(0).getText()).isEqualTo("Hi there"); - } - - @Test - void testAnthropicDeltaGetContent() { - final var delta = new AnthropicChatCompletionDelta(); - assertThat(delta.getDeltaContent()).isEmpty(); - assertThat(delta.getStopReason()).isNull(); - - final var innerDelta = new AnthropicChatCompletionDelta.Delta().setText("Hello"); - delta.setDelta(innerDelta); - assertThat(delta.getDeltaContent()).isEqualTo("Hello"); - } - - @Test - void testEmptyContentResponse() { - final var response = new AnthropicChatCompletionResponse(); - assertThat(response.getContent()).isEmpty(); - - final var responseWithEmptyList = new AnthropicChatCompletionResponse(); - responseWithEmptyList.setContent(List.of()); - assertThat(responseWithEmptyList.getContent()).isEmpty(); - } - - @Test - void testRequestAnthropicVersionDefault() { - final var request = new AnthropicChatCompletionRequest(); - assertThat(request.getAnthropicVersion()).isEqualTo("bedrock-2023-05-31"); - } } diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java new file mode 100644 index 000000000..836f4b4f5 --- /dev/null +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java @@ -0,0 +1,207 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import com.anthropic.core.RequestOptions; +import com.anthropic.core.http.HttpMethod; +import com.anthropic.core.http.HttpRequest; +import com.sap.ai.sdk.core.common.ClientException; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Cache; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SapAnthropicHttpClientTest { + + private SapAnthropicHttpClient sapClient; + private HttpClient apacheClient; + + @BeforeEach + void setup() { + final var destination = DefaultHttpDestination.builder("http://localhost").build(); + sapClient = new SapAnthropicHttpClient(destination); + apacheClient = mock(HttpClient.class); + ApacheHttpClient5Accessor.setHttpClientFactory(dest -> apacheClient); + ApacheHttpClient5Accessor.setHttpClientCache(ApacheHttpClient5Cache.DISABLED); + } + + @AfterEach + void reset() { + ApacheHttpClient5Accessor.setHttpClientCache(null); + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } + + /** + * Stubs the (ClassicHttpRequest, HttpClientResponseHandler) overload used by our non-streaming + * path. + */ + @SuppressWarnings("unchecked") + private void stubExecute(final ClassicHttpResponse response) throws IOException { + doAnswer( + inv -> { + final var handler = (HttpClientResponseHandler) inv.getArgument(1); + return handler.handleResponse(response); + }) + .when(apacheClient) + .execute(any(), any(HttpClientResponseHandler.class)); + } + + private static HttpRequest nonStreamingRequest() { + return HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl("http://localhost") + .addPathSegment("v1/messages") + .putHeader("Content-Type", "application/json") + .build(); + } + + private static HttpRequest streamingRequest() { + return HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl("http://localhost") + .addPathSegment("v1/messages") + .putHeader("Accept", "text/event-stream") + .build(); + } + + @Test + void testSkipsAuthorizationAndApiKeyHeaders() throws IOException { + final var mockResponse = new BasicClassicHttpResponse(200, "OK"); + mockResponse.setEntity(new StringEntity("{}", ContentType.APPLICATION_JSON)); + stubExecute(mockResponse); + + final var request = + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl("http://localhost") + .addPathSegment("v1/messages") + .putHeader("Authorization", "Bearer secret") + .putHeader("x-api-key", "key123") + .putHeader("anthropic-version", "2023-06-01") + .putHeader("Content-Type", "application/json") + .build(); + + final var response = sapClient.execute(request, RequestOptions.none()); + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void testExecuteAsyncDelegatesToExecute() throws IOException { + final var mockResponse = new BasicClassicHttpResponse(200, "OK"); + mockResponse.setEntity(new StringEntity("{}", ContentType.APPLICATION_JSON)); + stubExecute(mockResponse); + + final var response = + sapClient.executeAsync(nonStreamingRequest(), RequestOptions.none()).join(); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void testStreamingErrorResponseBuffered() throws IOException { + final var errorBody = "{\"type\":\"error\",\"error\":{\"message\":\"bad\"}}"; + final var mockResponse = new BasicClassicHttpResponse(400, "Bad Request"); + mockResponse.setEntity(new StringEntity(errorBody, ContentType.APPLICATION_JSON)); + doReturn(mockResponse).when(apacheClient).executeOpen(any(), any(), any()); + + final var response = sapClient.execute(streamingRequest(), RequestOptions.none()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(new String(response.body().readAllBytes(), StandardCharsets.UTF_8)).contains("bad"); + } + + @Test + void testStreamingNullEntityReturnsEmptyStream() throws IOException { + final var mockResponse = new BasicClassicHttpResponse(200, "OK"); + doReturn(mockResponse).when(apacheClient).executeOpen(any(), any(), any()); + + try (var response = sapClient.execute(streamingRequest(), RequestOptions.none())) { + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body().read()).isEqualTo(-1); + } + } + + @Test + void testBufferedNullEntityReturnsEmptyBody() throws IOException { + stubExecute(new BasicClassicHttpResponse(200, "OK")); + + final var response = sapClient.execute(nonStreamingRequest(), RequestOptions.none()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body().readAllBytes()).isEmpty(); + } + + @Test + void testExecuteThrowsOnIoException() throws IOException { + doThrow(new IOException("connection refused")) + .when(apacheClient) + .execute(any(), any(HttpClientResponseHandler.class)); + + assertThatThrownBy(() -> sapClient.execute(nonStreamingRequest(), RequestOptions.none())) + .isInstanceOf(ClientException.class) + .hasMessageContaining("Anthropic HTTP request failed"); + } + + @Test + void testSseRewriterHandlesNonJsonDataLine() throws IOException { + final var sseBytes = + "data: not-json\n\ndata: {\"type\":\"message_stop\"}\n\n".getBytes(StandardCharsets.UTF_8); + final var mockResponse = new BasicClassicHttpResponse(200, "OK"); + mockResponse.setEntity( + new InputStreamEntity(new ByteArrayInputStream(sseBytes), ContentType.TEXT_PLAIN)); + doReturn(mockResponse).when(apacheClient).executeOpen(any(), any(), any()); + + try (var response = sapClient.execute(streamingRequest(), RequestOptions.none())) { + final var output = new String(response.body().readAllBytes(), StandardCharsets.UTF_8); + assertThat(output).contains("data: not-json"); + assertThat(output).contains("event: message_stop"); + } + } + + @Test + void testStreamingResponseCloseSwallowsIoException() throws IOException { + final var brokenStream = + spy( + Objects.requireNonNull( + getClass() + .getClassLoader() + .getResourceAsStream("__files/invokeStreamResponse.txt"))); + + final var mockApacheResponse = mock(ClassicHttpResponse.class); + doReturn(200).when(mockApacheResponse).getCode(); + doReturn(new org.apache.hc.core5.http.message.HeaderGroup().getHeaders()) + .when(mockApacheResponse) + .getHeaders(); + doReturn(new InputStreamEntity(brokenStream, ContentType.TEXT_EVENT_STREAM)) + .when(mockApacheResponse) + .getEntity(); + doReturn(mockApacheResponse).when(apacheClient).executeOpen(any(), any(), any()); + + final var response = sapClient.execute(streamingRequest(), RequestOptions.none()); + doThrow(new IOException("broken pipe")).when(brokenStream).close(); + doThrow(new IOException("already closed")).when(mockApacheResponse).close(); + + // Should not throw even when both close() calls fail + response.close(); + } +} diff --git a/pom.xml b/pom.xml index d84c2582f..50e6e12cf 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,7 @@ 1.22.1 0.26.3 4.76.0 + 2.72.0 5.4.4 5.6.4 @@ -372,6 +373,17 @@ openai-java-core ${openai-java.version} + + com.anthropic + anthropic-java-core + ${anthropic-java.version} + + + + io.swagger.core.v3 + swagger-annotations + 2.2.50 + diff --git a/sample-code/spring-app/pom.xml b/sample-code/spring-app/pom.xml index 79c377e7f..09884a859 100644 --- a/sample-code/spring-app/pom.xml +++ b/sample-code/spring-app/pom.xml @@ -135,6 +135,10 @@ com.sap.ai.sdk.foundationmodels anthropic + + com.anthropic + anthropic-java-core + com.sap.cloud.sdk.cloudplatform cloudplatform-core diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java index d1ea3d120..e33ecfab1 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -20,7 +20,11 @@ public class AnthropicController { @GetMapping("/anthropic/chatCompletion") @Nonnull String chatCompletion() { - return service.chatCompletion("What is the capital of France?").getContent(); + final var message = service.chatCompletion("What is the capital of France?"); + return message.content().stream() + .filter(block -> block.isText()) + .map(block -> block.text().map(t -> t.text()).orElse("")) + .reduce("", String::concat); } /** Streaming chat completion request to Anthropic Claude. */ @@ -31,18 +35,20 @@ ResponseBodyEmitter streamChatCompletion() { final var stream = service.streamChatCompletion("What is the capital of France?"); new Thread( () -> { - try { - stream.forEach( - delta -> { - final var content = delta.getDeltaContent(); - if (!content.isEmpty()) { - try { - emitter.send(content); - } catch (final Exception e) { - emitter.completeWithError(e); - } - } - }); + try (stream) { + stream.stream() + .filter(event -> event.isContentBlockDelta()) + .map(event -> event.asContentBlockDelta().delta()) + .filter(delta -> delta.isText()) + .map(delta -> delta.asText().text()) + .forEach( + text -> { + try { + emitter.send(text); + } catch (final Exception e) { + emitter.completeWithError(e); + } + }); emitter.complete(); } catch (final Exception e) { emitter.completeWithError(e); diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java index 88cd598d4..047ff4147 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java @@ -2,44 +2,47 @@ import static com.sap.ai.sdk.foundationmodels.anthropic.AnthropicModel.CLAUDE_45_HAIKU; -import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicChatCompletionDelta; -import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicChatCompletionRequest; -import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicChatCompletionResponse; -import com.sap.ai.sdk.foundationmodels.anthropic.AnthropicClient; -import java.util.stream.Stream; +import com.anthropic.core.http.StreamResponse; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.RawMessageStreamEvent; +import com.sap.ai.sdk.foundationmodels.anthropic.AiCoreAnthropicClient; import javax.annotation.Nonnull; import org.springframework.stereotype.Service; -/** Service to interact with the Anthropic Claude model. */ +/** Service class for Anthropic service using latest convenience api. */ @Service public class AnthropicService { - static final AnthropicClient client = AnthropicClient.forModel(CLAUDE_45_HAIKU); + private static final MessageCreateParams.Builder BASE_PARAMS = + MessageCreateParams.builder().model(CLAUDE_45_HAIKU.name()).maxTokens(1000); /** - * Performs a synchronous chat completion request. + * Chat request to Anthropic. * - * @param userMessage the user's message - * @return the chat completion response + * @param userMessage the prompt to send to the assistant. + * @return the assistant message response. */ @Nonnull - public AnthropicChatCompletionResponse chatCompletion(@Nonnull final String userMessage) { - final var request = - new AnthropicChatCompletionRequest().withUserMessage(userMessage).withMaxTokens(1000); - return client.chatCompletion(request); + public Message chatCompletion(@Nonnull final String userMessage) { + return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .create(BASE_PARAMS.addUserMessage(userMessage).build()); } /** - * Performs a streaming chat completion request. + * Asynchronous stream of an OpenAI chat request. * - * @param userMessage the user's message - * @return a stream of chat completion deltas + * @param userMessage the message to send to the assistant. + * @return the emitter that streams the assistant message response. */ @Nonnull - public Stream streamChatCompletion( + public StreamResponse streamChatCompletion( @Nonnull final String userMessage) { - final var request = - new AnthropicChatCompletionRequest().withUserMessage(userMessage).withMaxTokens(1000); - return client.streamChatCompletion(request); + return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .createStreaming(BASE_PARAMS.addUserMessage(userMessage).build()); } } diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java index 2a29a5dc3..356c78e94 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.anthropic.models.messages.StopReason; import com.sap.ai.sdk.app.services.AnthropicService; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -11,22 +12,32 @@ class AnthropicTest { @Test void chatCompletion() { - final var response = service.chatCompletion("What is the capital of France?"); - - assertThat(response).isNotNull(); - assertThat(response.getContent()).containsIgnoringCase("paris"); - assertThat(response.getStopReason()).isEqualTo("end_turn"); - assertThat(response.getUsage()).isNotNull(); - assertThat(response.getUsage().getOutputTokens()).isGreaterThan(0); + final var message = service.chatCompletion("What is the capital of France?"); + + assertThat(message).isNotNull(); + assertThat(message.content()).isNotEmpty(); + final var text = + message.content().stream() + .filter(b -> b.isText()) + .map(b -> b.text().map(t -> t.text()).orElse("")) + .reduce("", String::concat); + assertThat(text).containsIgnoringCase("paris"); + assertThat(message.stopReason()).hasValue(StopReason.END_TURN); + assertThat(message.usage().outputTokens()).isGreaterThan(0); } @Test void streamChatCompletion() { final var fullResponse = new AtomicReference<>(""); - service - .streamChatCompletion("What is the capital of France?") - .forEach(delta -> fullResponse.accumulateAndGet(delta.getDeltaContent(), String::concat)); + try (var stream = service.streamChatCompletion("What is the capital of France?")) { + stream.stream() + .filter(event -> event.isContentBlockDelta()) + .map(event -> event.asContentBlockDelta().delta()) + .filter(delta -> delta.isText()) + .map(delta -> delta.asText().text()) + .forEach(text -> fullResponse.accumulateAndGet(text, String::concat)); + } assertThat(fullResponse.get()).containsIgnoringCase("paris"); } From 9b12a07129e8a8eab51336eb2659ccdacca1f86e Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Thu, 22 Oct 2026 14:58:34 +0200 Subject: [PATCH 04/26] fix upstream merge issues --- foundation-models/anthropic/pom.xml | 11 +++++------ pom.xml | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/foundation-models/anthropic/pom.xml b/foundation-models/anthropic/pom.xml index 58931b022..2b7cf9a97 100644 --- a/foundation-models/anthropic/pom.xml +++ b/foundation-models/anthropic/pom.xml @@ -4,7 +4,7 @@ com.sap.ai.sdk sdk-parent - 1.26.0-SNAPSHOT + 2.1.0-SNAPSHOT ../../pom.xml com.sap.ai.sdk.foundationmodels @@ -94,14 +94,13 @@ org.apache.httpcomponents.client5 httpclient5 - - org.apache.httpcomponents.core5 - httpcore5 - com.anthropic anthropic-java-core - ${anthropic-java.version} + + + org.apache.httpcomponents.core5 + httpcore5 com.fasterxml.jackson.core diff --git a/pom.xml b/pom.xml index 593855d2e..aa959b17d 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,7 @@ + 2.72.0 ${project.basedir} 17 17 @@ -80,7 +81,6 @@ 1.6.5 1.23.0 0.26.3 - 2.72.0 4.80.0 5.4.4 From 08f25f58e8ac45e3d7fcd7fd7b9cc8b26a2e7992 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Mon, 26 Oct 2026 17:15:03 +0100 Subject: [PATCH 05/26] add decisions doc --- ...-anthropic-foundation-model-integration.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/adrs/008-anthropic-foundation-model-integration.md diff --git a/docs/adrs/008-anthropic-foundation-model-integration.md b/docs/adrs/008-anthropic-foundation-model-integration.md new file mode 100644 index 000000000..83acf37dd --- /dev/null +++ b/docs/adrs/008-anthropic-foundation-model-integration.md @@ -0,0 +1,97 @@ +# Anthropic Foundation Model Integration + +Status: Decided. + +## Background + +SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. +The SDK needs to provide Java users with access to these models. +Three design dimensions were evaluated independently: the overall integration approach, which sub-APIs to expose, and how to handle client configuration. + +--- + +## Decision 1: Integration approach + +How should the SDK expose Anthropic Claude models to users? + +### Options considered + +| Option | Description | +|---|---| +| **Convenience classes** | Hand-rolled request/response POJOs and a custom HTTP client, similar to the pre-existing custom implementation | +| **Raw Bedrock invoke API** | Thin wrapper around the SAP AI Core `/invoke` endpoint, leaving all serialization to the user | +| **Official Anthropic Java SDK (chosen)** | Use `com.anthropic:anthropic-java-core` as the typed API layer; replace its HTTP transport with SAP's authenticated Apache HTTP client | + +### Arguments + +| | Convenience classes | Raw Bedrock invoke | Official Anthropic SDK | +|---|---|---|---| +| Maintenance effort | High β€” every API change requires SDK updates | Near zero β€” no types owned | Low β€” SDK absorbs upstream changes | +| Code footprint | Large | Minimal | Small (bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient`) | +| User value | Moderate β€” familiar but limited API | Near zero β€” equivalent to handing the user an HTTP client | High β€” official, fully-typed, well-documented API | +| Stability | Fragile β€” SAP owns the contract | N/A | Stable β€” Anthropic owns and maintains the contract | +| Backend replaceability | Hard β€” POJOs are tightly coupled to Bedrock wire format | N/A | Easy β€” transport is swappable without breaking user code | + +### Decision + +**Use the official Anthropic Java SDK** (`com.anthropic:anthropic-java-core`) configured with a custom HTTP client that routes requests through SAP AI Core. +The `SapAnthropicHttpClient` bridges between the SDK's `com.anthropic.core.http.HttpClient` interface and SAP's `ApacheHttpClient5Accessor`, handling URL rewriting (Anthropic `/v1/messages` β†’ SAP `/invoke`) and Bedrock-style SSE rewriting transparently. +Users interact exclusively with the official Anthropic SDK types (`AnthropicClient`, `MessageCreateParams`, `Message`, `StreamResponse`, etc.). + +--- + +## Decision 2: Sub-APIs exposed + +The official Anthropic Java SDK exposes multiple sub-APIs (messages, completions, batches, files, models, …). +SAP AI Core's router currently only supports two of these: **messages** and **completions**. + +### Options considered + +| Option | Description | +|---|---| +| **Expose everything** | Return the full `AnthropicClient` interface unchanged | +| **Expose only supported sub-APIs (chosen)** | Return the full `AnthropicClient` but document that only `messages()` and `completions()` are supported | + +### Arguments + +| | Expose everything | Expose only supported sub-APIs | +|---|---|---| +| Available functionality | Full SDK surface, most unsupported at runtime | Same actual functionality β€” unsupported sub-APIs would fail at runtime either way | +| User experience | May lead to confusion when unsupported calls fail | Clearly documented scope; failures are expected and documented | +| Maintenance effort | Zero additional code | Zero additional code; small documentation update needed when SAP router adds sub-APIs | +| Code footprint | None | None | + +### Decision + +**Expose only the supported sub-APIs** (`messages()` and `completions()`) by returning the standard `AnthropicClient` and documenting the supported scope. +When SAP AI Core's router extends support to additional sub-APIs, the SDK change is limited to a documentation update β€” no code modification required. + +--- + +## Decision 3: Client URL and configuration + +The official Anthropic SDK allows users to override the base URL, custom headers, timeouts, and other transport settings via `ClientOptions`. +The question is whether the SDK should restrict or validate these overrides. + +### Options considered + +| Option | Description | +|---|---| +| **Restrict / redefine the interface** | Override or wrap `messages()` and related services to prevent users from changing internal settings | +| **Do nothing (chosen)** | Expose the standard `AnthropicClient` as-is; users may override settings at their own risk | + +### Arguments + +| | Restrict interface | Do nothing | +|---|---|---| +| Maintenance effort | High β€” requires reimplementing or wrapping SDK interfaces; breaks on every upstream SDK update | Near zero | +| Code footprint | Large | None | +| User flexibility | Reduced β€” legitimate advanced use cases blocked | Full β€” power users can reconfigure if needed | +| Risk of misconfiguration | Eliminated | Accepted β€” consistent with SDK's general philosophy: bad input does not guarantee good output | +| Alignment with SDK principles | Against minimalistic design goal | Consistent | + +### Decision + +**Do nothing.** The `AnthropicClient` is returned as-is (supported sub-clients). +Users who override base URLs, headers, or other transport internals do so at their own risk. +This is consistent with the SDK's general design principle of minimal footprint and minimal maintenance overhead. From 65909bb4769f136e9148e0f3b5e1d583f9b837b2 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Tue, 27 Oct 2026 11:45:26 +0100 Subject: [PATCH 06/26] implement doc review feedback changes --- ...-anthropic-foundation-model-integration.md | 97 ----------- .../008-anthropic-integration-approach.md | 164 ++++++++++++++++++ ...009-anthropic-integration-client-design.md | 69 ++++++++ 3 files changed, 233 insertions(+), 97 deletions(-) delete mode 100644 docs/adrs/008-anthropic-foundation-model-integration.md create mode 100644 docs/adrs/008-anthropic-integration-approach.md create mode 100644 docs/adrs/009-anthropic-integration-client-design.md diff --git a/docs/adrs/008-anthropic-foundation-model-integration.md b/docs/adrs/008-anthropic-foundation-model-integration.md deleted file mode 100644 index 83acf37dd..000000000 --- a/docs/adrs/008-anthropic-foundation-model-integration.md +++ /dev/null @@ -1,97 +0,0 @@ -# Anthropic Foundation Model Integration - -Status: Decided. - -## Background - -SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. -The SDK needs to provide Java users with access to these models. -Three design dimensions were evaluated independently: the overall integration approach, which sub-APIs to expose, and how to handle client configuration. - ---- - -## Decision 1: Integration approach - -How should the SDK expose Anthropic Claude models to users? - -### Options considered - -| Option | Description | -|---|---| -| **Convenience classes** | Hand-rolled request/response POJOs and a custom HTTP client, similar to the pre-existing custom implementation | -| **Raw Bedrock invoke API** | Thin wrapper around the SAP AI Core `/invoke` endpoint, leaving all serialization to the user | -| **Official Anthropic Java SDK (chosen)** | Use `com.anthropic:anthropic-java-core` as the typed API layer; replace its HTTP transport with SAP's authenticated Apache HTTP client | - -### Arguments - -| | Convenience classes | Raw Bedrock invoke | Official Anthropic SDK | -|---|---|---|---| -| Maintenance effort | High β€” every API change requires SDK updates | Near zero β€” no types owned | Low β€” SDK absorbs upstream changes | -| Code footprint | Large | Minimal | Small (bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient`) | -| User value | Moderate β€” familiar but limited API | Near zero β€” equivalent to handing the user an HTTP client | High β€” official, fully-typed, well-documented API | -| Stability | Fragile β€” SAP owns the contract | N/A | Stable β€” Anthropic owns and maintains the contract | -| Backend replaceability | Hard β€” POJOs are tightly coupled to Bedrock wire format | N/A | Easy β€” transport is swappable without breaking user code | - -### Decision - -**Use the official Anthropic Java SDK** (`com.anthropic:anthropic-java-core`) configured with a custom HTTP client that routes requests through SAP AI Core. -The `SapAnthropicHttpClient` bridges between the SDK's `com.anthropic.core.http.HttpClient` interface and SAP's `ApacheHttpClient5Accessor`, handling URL rewriting (Anthropic `/v1/messages` β†’ SAP `/invoke`) and Bedrock-style SSE rewriting transparently. -Users interact exclusively with the official Anthropic SDK types (`AnthropicClient`, `MessageCreateParams`, `Message`, `StreamResponse`, etc.). - ---- - -## Decision 2: Sub-APIs exposed - -The official Anthropic Java SDK exposes multiple sub-APIs (messages, completions, batches, files, models, …). -SAP AI Core's router currently only supports two of these: **messages** and **completions**. - -### Options considered - -| Option | Description | -|---|---| -| **Expose everything** | Return the full `AnthropicClient` interface unchanged | -| **Expose only supported sub-APIs (chosen)** | Return the full `AnthropicClient` but document that only `messages()` and `completions()` are supported | - -### Arguments - -| | Expose everything | Expose only supported sub-APIs | -|---|---|---| -| Available functionality | Full SDK surface, most unsupported at runtime | Same actual functionality β€” unsupported sub-APIs would fail at runtime either way | -| User experience | May lead to confusion when unsupported calls fail | Clearly documented scope; failures are expected and documented | -| Maintenance effort | Zero additional code | Zero additional code; small documentation update needed when SAP router adds sub-APIs | -| Code footprint | None | None | - -### Decision - -**Expose only the supported sub-APIs** (`messages()` and `completions()`) by returning the standard `AnthropicClient` and documenting the supported scope. -When SAP AI Core's router extends support to additional sub-APIs, the SDK change is limited to a documentation update β€” no code modification required. - ---- - -## Decision 3: Client URL and configuration - -The official Anthropic SDK allows users to override the base URL, custom headers, timeouts, and other transport settings via `ClientOptions`. -The question is whether the SDK should restrict or validate these overrides. - -### Options considered - -| Option | Description | -|---|---| -| **Restrict / redefine the interface** | Override or wrap `messages()` and related services to prevent users from changing internal settings | -| **Do nothing (chosen)** | Expose the standard `AnthropicClient` as-is; users may override settings at their own risk | - -### Arguments - -| | Restrict interface | Do nothing | -|---|---|---| -| Maintenance effort | High β€” requires reimplementing or wrapping SDK interfaces; breaks on every upstream SDK update | Near zero | -| Code footprint | Large | None | -| User flexibility | Reduced β€” legitimate advanced use cases blocked | Full β€” power users can reconfigure if needed | -| Risk of misconfiguration | Eliminated | Accepted β€” consistent with SDK's general philosophy: bad input does not guarantee good output | -| Alignment with SDK principles | Against minimalistic design goal | Consistent | - -### Decision - -**Do nothing.** The `AnthropicClient` is returned as-is (supported sub-clients). -Users who override base URLs, headers, or other transport internals do so at their own risk. -This is consistent with the SDK's general design principle of minimal footprint and minimal maintenance overhead. diff --git a/docs/adrs/008-anthropic-integration-approach.md b/docs/adrs/008-anthropic-integration-approach.md new file mode 100644 index 000000000..12caf8bf9 --- /dev/null +++ b/docs/adrs/008-anthropic-integration-approach.md @@ -0,0 +1,164 @@ +# Anthropic Integration: Approach + +Status: Decided (2026-10-27). + +## Background + +SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. +The SDK needs to provide Java users with access to these models. + +This document covers the fundamental integration approach β€” how the SDK wraps or exposes the Anthropic API. +Downstream decisions about which sub-APIs to expose and how to handle client configuration are covered in +[009-anthropic-integration-client-design.md](009-anthropic-integration-client-design.md) +and depend on the outcome of this decision. + +### Reference: how the OpenAI integration works + +The existing `foundation-models/openai` module uses the **convenience classes** approach: +hand-rolled request/response POJOs (`OpenAiChatCompletionRequest`, `OpenAiChatCompletionResponse`, etc.) +and a custom `OpenAiClient` that wraps SAP's Apache HTTP client directly. + +```java +// OpenAI β€” convenience classes approach (current implementation) +OpenAiChatCompletionResponse response = + OpenAiClient.forModel(GPT_4O) + .chatCompletion(new OpenAiChatCompletionRequest("What is the capital of France?")); + +Stream stream = + OpenAiClient.forModel(GPT_4O) + .streamChatCompletionDeltas(new OpenAiChatCompletionRequest("Tell me a story")); +``` + +This gives full control over the API surface but requires ongoing maintenance as the OpenAI API evolves. +The Anthropic integration was an opportunity to evaluate whether this is the right pattern going forward. + +--- + +## Options considered + +### Option A: Convenience classes + +Hand-rolled request/response POJOs and a custom HTTP client β€” the same pattern used by the OpenAI integration. + +```java +// Hypothetical convenience classes approach +AnthropicChatCompletionResponse response = + AnthropicClient.forModel(CLAUDE_45_HAIKU) + .chatCompletion(new AnthropicChatCompletionRequest("What is the capital of France?")); + +Stream stream = + AnthropicClient.forModel(CLAUDE_45_HAIKU) + .streamChatCompletionDeltas(new AnthropicChatCompletionRequest("Tell me a story")); +``` + +### Option B: Raw Bedrock invoke API + +Expose the AWS Bedrock SDK `BedrockRuntimeClient` configured for SAP AI Core, with all serialization and response parsing left to the user. + +```java +// Chat completion β€” user owns request serialization and response parsing +BedrockRuntimeClient bedrockClient = BedrockRuntimeClient.builder() + .httpClient(SapBedrockHttpClient.forModel(CLAUDE_45_HAIKU)) + .build(); + +String requestBody = """ + { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "What is the capital of France?"}] + } + """; + +InvokeModelResponse response = bedrockClient.invokeModel( + InvokeModelRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .body(SdkBytes.fromUtf8String(requestBody)) + .build()); + +String responseBody = response.body().asUtf8String(); +// user parses responseBody JSON manually + +// Streaming +bedrockClient.invokeModelWithResponseStream( + InvokeModelWithResponseStreamRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .body(SdkBytes.fromUtf8String(requestBody)) + .build(), + InvokeModelWithResponseStreamResponseHandler.builder() + .onEventStream(stream -> stream + .filter(e -> e instanceof PayloadPart) + .map(e -> ((PayloadPart) e).bytes().asUtf8String()) + .forEach(chunk -> { /* user parses each chunk JSON manually */ })) + .build()); +``` + +### Option C: Official Anthropic Java SDK (proposed) + +Use `com.anthropic:anthropic-java-core` as the typed API layer. +Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. +The bridge (`SapAnthropicHttpClient`) handles URL rewriting and Bedrock-style SSE format conversion transparently. + +**Technical details:** +The SDK exposes a subset of the official `AnthropicClient` sub-clients (currently `messages()` and `completions()` β€” the only two supported by SAP AI Core's router). +Internally, `SapAnthropicHttpClient` intercepts every outgoing request, rewrites the Anthropic API URL (`/v1/messages`) to the SAP AI Core Bedrock invoke endpoint (`/invoke` or `/invoke-with-response-stream`), strips Anthropic-specific auth headers, and injects the `anthropic_version` field required by the Bedrock wire format. +Responses are rewritten in the opposite direction: Bedrock-style SSE (no `event:` field) is converted back to native Anthropic SSE before being handed to the SDK's event parser. +OAuth authentication is handled transparently by `ApacheHttpClient5Accessor` via SAP destination configuration β€” no Anthropic API key is needed. + +**Reasons:** +- Simple on the user level API, allows for flexible and advanced configuration inside the SDK +- Utilizes available `invoke` API of the routing layer +- Works with available `AWS Bedrock` provider +- While official Anthropic API is exposed, backend can be flexibly replaced without breaking changes for AI SDK users + +```java +// Chat completion +Message response = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .create( + MessageCreateParams.builder() + .model(CLAUDE_45_HAIKU.name()) + .maxTokens(1000) + .addUserMessage("What is the capital of France?") + .build()); + +String text = response.content().stream() + .filter(ContentBlock::isText) + .map(b -> b.asText().text()) + .collect(joining()); + +// Streaming +try (var stream = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .createStreaming(params)) { + stream.stream() + .filter(RawMessageStreamEvent::isContentBlockDelta) + .map(e -> e.asContentBlockDelta().delta().asTextDelta().text()) + .forEach(System.out::print); +} +``` + +--- + +## Arguments + +| | Option A: Convenience classes | Option B: Raw Bedrock invoke | Option C: Official Anthropic SDK | +|---|---|---|---| +| Maintenance effort | High β€” every upstream API change requires SDK updates (see OpenAI module history) | Near zero β€” no types owned | Low β€” SDK absorbs upstream changes | +| Code footprint | Large | Minimal | Small β€” bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient` | +| User value | Moderate β€” familiar but limited, SAP-specific API | Low β€” user owns JSON serialization, response parsing, and SSE chunk handling | High β€” official, fully-typed, well-documented Anthropic API | +| API stability | Fragile β€” SAP owns the contract | Stable at transport level; unstable at payload level β€” user owns the wire format | Stable β€” Anthropic owns and maintains the contract | +| Backend replaceability | Hard β€” POJOs tightly coupled to Bedrock wire format | Hard β€” user code is tightly coupled to Bedrock wire format | Easy β€” transport is swappable without breaking user code | + +--- + +## Decision + +**Option C β€” use the official Anthropic Java SDK.** + +The `SapAnthropicHttpClient` bridges between `com.anthropic.core.http.HttpClient` and SAP's `ApacheHttpClient5Accessor`, +handling URL rewriting (`/v1/messages` β†’ `/invoke`) and Bedrock SSE format conversion transparently. +Users interact exclusively with official Anthropic SDK types (`AnthropicClient`, `MessageCreateParams`, `Message`, `StreamResponse`, etc.). diff --git a/docs/adrs/009-anthropic-integration-client-design.md b/docs/adrs/009-anthropic-integration-client-design.md new file mode 100644 index 000000000..c92ca73eb --- /dev/null +++ b/docs/adrs/009-anthropic-integration-client-design.md @@ -0,0 +1,69 @@ +# Anthropic Integration: Client Design + +Status: Decided (2026-10-27). + +## Background + +SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. +This document covers two design decisions about how `AiCoreAnthropicClient` exposes the official Anthropic Java SDK to users. + +Both decisions assume **Option C (official Anthropic Java SDK)** from +[008-anthropic-integration-approach.md](008-anthropic-integration-approach.md). + +--- + +## Decision 1: Sub-APIs exposed + +The official Anthropic Java SDK exposes multiple sub-APIs (`messages()`, `completions()`, `batches()`, `files()`, `models()`, …). +SAP AI Core's router currently only supports two of these: **messages** and **completions**. + +### Options considered + +| Option | Description | +|---|---| +| **Expose everything** | Return the full `AnthropicClient` interface with no restrictions or documentation caveats | +| **Expose only supported sub-APIs (chosen)** | Return the full `AnthropicClient` but document that only `messages()` and `completions()` are supported by SAP AI Core | + +### Arguments + +| | Expose everything | Expose only supported sub-APIs | +|---|---|---| +| Available functionality | Same β€” unsupported sub-APIs fail at runtime either way | Same | +| User experience | May cause confusion when unsupported calls fail with opaque errors | Clearly scoped; failures on unsupported APIs are expected and documented | +| Maintenance when SAP router adds sub-APIs | None | Documentation update only β€” no code change needed | +| Code footprint | None | None | + +### Decision + +**Expose only the supported sub-APIs** (`messages()` and `completions()`) by documenting the supported scope on `AiCoreAnthropicClient.client()`. +When SAP AI Core's router adds support for further sub-APIs, the SDK change is a documentation update only. + +--- + +## Decision 2: Client URL and configuration + +The official Anthropic SDK allows users to further configure the returned `AnthropicClient` β€” overriding the base URL, adding custom headers, changing timeouts, etc. +The question is whether the SDK should prevent or validate such overrides. + +### Options considered + +| Option | Description | +|---|---| +| **Restrict / redefine the interface** | Wrap or re-implement `messages()` and related services to prevent users from altering internal transport settings | +| **Do nothing (chosen)** | Return the standard `AnthropicClient` as-is; document that overriding internals is unsupported | + +### Arguments + +| | Restrict interface | Do nothing | +|---|---|---| +| Maintenance effort | High β€” requires re-implementing SDK interfaces; breaks on every upstream SDK update | Near zero | +| Code footprint | Large | None | +| User flexibility | Reduced β€” legitimate advanced use cases blocked | Full β€” power users can reconfigure if needed | +| Risk of misconfiguration | Accepted β€” consistent with SDK philosophy: bad input does not guarantee good output | Eliminated | +| Alignment with SDK principles | Against minimalistic design goal | Consistent | + +### Decision + +**Do nothing.** The `AnthropicClient` is returned as-is. +Users who override base URLs, headers, or other transport internals do so at their own risk. +This is consistent with the SDK's general design principle of minimal footprint and minimal maintenance overhead. From a761b5e1be7a290944de530e77e7eb80a5c31feb Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Tue, 27 Oct 2026 11:48:59 +0100 Subject: [PATCH 07/26] correct decisions status --- docs/adrs/008-anthropic-integration-approach.md | 2 +- docs/adrs/009-anthropic-integration-client-design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adrs/008-anthropic-integration-approach.md b/docs/adrs/008-anthropic-integration-approach.md index 12caf8bf9..2d44b99be 100644 --- a/docs/adrs/008-anthropic-integration-approach.md +++ b/docs/adrs/008-anthropic-integration-approach.md @@ -1,6 +1,6 @@ # Anthropic Integration: Approach -Status: Decided (2026-10-27). +Status: Proposed (2026-10-27). ## Background diff --git a/docs/adrs/009-anthropic-integration-client-design.md b/docs/adrs/009-anthropic-integration-client-design.md index c92ca73eb..52f10621e 100644 --- a/docs/adrs/009-anthropic-integration-client-design.md +++ b/docs/adrs/009-anthropic-integration-client-design.md @@ -1,6 +1,6 @@ # Anthropic Integration: Client Design -Status: Decided (2026-10-27). +Status: Proposed (2026-10-27). ## Background From 0541a54218391fc1a4d6c4a59440b9e7f2369af4 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Fri, 30 Oct 2026 13:40:44 +0100 Subject: [PATCH 08/26] redo adr, add options --- docs/adrs/008-anthropic-foundation-models.md | 273 ++++++++++++++++++ .../008-anthropic-integration-approach.md | 164 ----------- ...esign.md => 009-anthropic-client-style.md} | 2 +- .../anthropic/AiCoreAnthropicClient.java | 13 +- .../anthropic/AiCoreAnthropicClientTest.java | 14 +- .../ai/sdk/app/services/AnthropicService.java | 2 - 6 files changed, 293 insertions(+), 175 deletions(-) create mode 100644 docs/adrs/008-anthropic-foundation-models.md delete mode 100644 docs/adrs/008-anthropic-integration-approach.md rename docs/adrs/{009-anthropic-integration-client-design.md => 009-anthropic-client-style.md} (97%) diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md new file mode 100644 index 000000000..aec9a49ca --- /dev/null +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -0,0 +1,273 @@ +# Anthropics foundation models + +## Status + +- Status: `proposed` (2026-10-27) +- Contributors: Vladimir Aseev +- Reviewers: Zhongpin Wang +- Next expected status change date: 02.11.2026 + +## Context +AI SDK needs to provide users with direct access to Anthropic models. This document enumerates different implementation +approaches with pros and cons for each option. + +### Business Requirements +This ADR is required in the context of the backlog items: +- https://github.com/SAP/ai-sdk-java-backlog/issues/449 + +### Problem Description +AI SDK needs to provide users with direct access to Anthropic models. Currently, the corresponding solution does +not exist. Few solution options exist, the optimal option is to be identified. + +### Solution Options +#### Option A: Convenience classes + +Client, configuration and operation payload classes (requests, responses and their parts) are written by +hands for every functionality we want to support: + +```java +// Hypothetical convenience classes approach +AnthropicChatCompletionResponse response = + AnthropicClient.forModel(CLAUDE_45_HAIKU) + .chatCompletion(new AnthropicChatCompletionRequest("What is the capital of France?")); + +Stream stream = + AnthropicClient.forModel(CLAUDE_45_HAIKU) + .streamChatCompletionDeltas(new AnthropicChatCompletionRequest("Tell me a story")); +``` + +| Aspect | Value | +|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Maintenance effort | High β€” every upstream API change requires SDK updates | +| Code footprint | Large. In order to provide typed API many classes will have to be written, covered with tests and documented | +| User value | Moderate. All new functionality from Antropics won't be supported immediately (lag). Most/much of functionality won't be supported at all given the development capacity constraints. | +| API stability | Moderade to high β€” SAP owns the contract but on upstream API changes we may have to introduce breaking changes to our API | +| Backend replaceability | Hard β€” POJOs tightly coupled to Bedrock wire format, almost entire codebase will have to be re-written | + + +#### Option B: Raw Bedrock invoke API + +Expose the AWS Bedrock SDK `BedrockRuntimeClient` configured for SAP AI Core, with all serialization and response parsing left to the user. + +```java +// Chat completion β€” user owns request serialization and response parsing +BedrockRuntimeClient bedrockClient = BedrockRuntimeClient.builder() + .httpClient(SapBedrockHttpClient.forModel(CLAUDE_45_HAIKU)) + .build(); + +String requestBody = """ + { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "What is the capital of France?"}] + } + """; + +InvokeModelResponse response = bedrockClient.invokeModel( + InvokeModelRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .body(SdkBytes.fromUtf8String(requestBody)) + .build()); + +String responseBody = response.body().asUtf8String(); +// user parses responseBody JSON manually + +// Streaming +bedrockClient.invokeModelWithResponseStream( + InvokeModelWithResponseStreamRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .body(SdkBytes.fromUtf8String(requestBody)) + .build(), + InvokeModelWithResponseStreamResponseHandler.builder() + .onEventStream(stream -> stream + .filter(e -> e instanceof PayloadPart) + .map(e -> ((PayloadPart) e).bytes().asUtf8String()) + .forEach(chunk -> { /* user parses each chunk JSON manually */ })) + .build()); +``` + +| Aspect | Value | +|-----------------|----------------------------------------------------------------------------------| +| Maintenance effort | Near zero β€” no types owned | +| Code footprint | Minimal | +| User value | Low β€” user owns JSON serialization, response parsing, and SSE chunk handling | +| API stability | Stable at transport level; unstable at payload level β€” user owns the wire format | +| Backend replaceability | Virtually impossible β€” user code is tightly coupled to Bedrock wire format| + +### Option C: Official Anthropic Java SDK with Invoke AWS Bedrock Backend + +Use `com.anthropic:anthropic-java-core` as the typed API layer. +Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. +Expose parts of official anthropic api we support (sub-clients implemented by anthropic) + +```java +// Chat completion +Message response = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .create( + MessageCreateParams.builder() + .model(CLAUDE_45_HAIKU.name()) + .maxTokens(1000) + .addUserMessage("What is the capital of France?") + .build()); + +String text = response.content().stream() + .filter(ContentBlock::isText) + .map(b -> b.asText().text()) + .collect(joining()); + +// Streaming +try (var stream = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .createStreaming(params)) { + stream.stream() + .filter(RawMessageStreamEvent::isContentBlockDelta) + .map(e -> e.asContentBlockDelta().delta().asTextDelta().text()) + .forEach(System.out::print); +} +``` + +| Aspect | Value | +|------------------------|---------------------------------------------------------------------------------| +| Maintenance effort | Low β€” SDK absorbs upstream changes | +| Code footprint | Small β€” bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient` | +| User value | High β€” official, fully-typed, well-documented Anthropic API | +| API stability | Stable β€” Anthropic owns and maintains the contract | +| Backend replaceability | Easy β€” transport is swappable without breaking user code | + + +### Option D: Official Anthropic Java SDK with Converse AWS Bedrock Backend + +Use `com.anthropic:anthropic-java-core` as the typed API layer. +Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. +Expose parts of official anthropic api we support (sub-clients implemented by anthropic) + +```java +// Chat completion +Message response = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .create( + MessageCreateParams.builder() + .model(CLAUDE_45_HAIKU.name()) + .maxTokens(1000) + .addUserMessage("What is the capital of France?") + .build()); + +String text = response.content().stream() + .filter(ContentBlock::isText) + .map(b -> b.asText().text()) + .collect(joining()); + +// Streaming +try (var stream = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .client() + .messages() + .createStreaming(params)) { + stream.stream() + .filter(RawMessageStreamEvent::isContentBlockDelta) + .map(e -> e.asContentBlockDelta().delta().asTextDelta().text()) + .forEach(System.out::print); +} +``` + +| Aspect | Value | +|------------------------|---------------------------------------------------------------------------------------------------------------------------| +| Maintenance effort | Very high - implementation depends not only on Anthropic API changes but also on Converse AWS API changes | +| Code footprint | Large β€” high level mapping between the APIs is needed. some features might be impossible to map | +| User value | Medium β€” official, fully-typed, well-documented Anthropic API but scope is limited by not all operations supported/mapped | +| API stability | Low β€” glue (mapping) between Anthropics API and Converse API is brittle | +| Backend replaceability | Difficult β€” implementation will have to be redone | + + +### Option E: Official Converse AWS Bedrock Java API with Anthropic models via SAP gateway + +Use `software.amazon.awssdk:bedrockruntime` as the typed API layer. +Configure `BedrockRuntimeClient` with SAP's authenticated HTTP credentials and endpoint override pointing to SAP AI Core's gateway. +The Converse API provides a model-agnostic typed interface β€” all request/response types (`ConverseRequest`, `ConverseResponse`, `Message`, `ContentBlock`, etc.) are owned by AWS. + +```java +// Build SAP-authenticated BedrockRuntimeClient +BedrockRuntimeClient bedrockClient = + BedrockRuntimeClient.builder() + .endpointOverride(URI.create(AiCoreService.getInferenceDestination(CLAUDE_45_HAIKU).getUri())) + .credentialsProvider(() -> AwsBasicCredentials.create("sap", "sap")) // auth handled by destination + .httpClient(SapBedrockHttpClient.forModel(CLAUDE_45_HAIKU)) + .region(Region.US_EAST_1) // required by SDK builder, overridden by endpoint + .build(); + +// Chat completion via Converse API +ConverseResponse response = bedrockClient.converse( + ConverseRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .messages( + Message.builder() + .role(ConversationRole.USER) + .content(ContentBlock.fromText("What is the capital of France?")) + .build()) + .build()); + +String text = response.output() + .message() + .content() + .stream() + .filter(ContentBlock::hasText) + .map(ContentBlock::text) + .collect(joining()); + +// Streaming via ConverseStream +bedrockClient.converseStream( + ConverseStreamRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .messages( + Message.builder() + .role(ConversationRole.USER) + .content(ContentBlock.fromText("Tell me a story")) + .build()) + .build(), + ConverseStreamResponseHandler.builder() + .onEventStream(stream -> stream + .filter(e -> e instanceof ContentBlockDeltaEvent) + .map(e -> ((ContentBlockDeltaEvent) e).delta().text()) + .forEach(System.out::print)) + .build()); +``` + +| Aspect | Value | +|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Maintenance effort | Low for AWS SDK changes β€” typed classes are SDK-owned; but Anthropic-specific features (extended thinking, tool use variants) may lag behind the native Anthropic API | +| Code footprint | Small β€” bridge only: SAP endpoint/credential wiring; no request/response mapping needed | +| User value | Medium β€” familiar AWS SDK patterns for teams already using Bedrock; but Anthropic-specific parameters (e.g. `betas`, prompt caching) are not exposed (against the purpose of "foundation models") | +| API stability | Moderate β€” AWS Converse API is stable and model-agnostic; Anthropic-specific capabilities limited to what Converse supports | +| Backend replaceability | Moderate β€” switching to a non-Bedrock backend requires replacing the AWS SDK dependency; Converse API types are AWS-specific | + + +## Decision +Official Anthropic Java SDK with Invoke AWS Bedrock Backend (Option C) is proposed. + +## Rationale + + +## Consequences, New Dependencies, Open Points, Next Steps + + +## Self Check + +The ADR... +- [ ] in `Status`, specifiies the correct ADR status, contributors, and reviewers requried +- [ ] in `Context`, refers to a Jira item describing the business requirement / backlog item and describes the context for AI Core architects and POs that are not deep experts for the ADR's topic +- [ ] in `Context`, specifies non-functional requirements that need to be fulfilled; in the solution options these non-functional requirements are considered +- [ ] in `Context`, presents more than 1 solution option; each with pros and cons +- [ ] in `Decision`, captures the clear single architecture decision at this point in time +- [ ] in `Consequences`, points out all identified open points, plans to proceed with them, and where input is required from reviewers +- [ ] in `Consequences`, enumerates all new introduced dependencies to external services +- [ ] considers aspects that are sometimes forgotten; discussion with the experts on these in early stages have been conducted (only if applicable): + - AI Launchpad + - SDKs + - Content as a service \ No newline at end of file diff --git a/docs/adrs/008-anthropic-integration-approach.md b/docs/adrs/008-anthropic-integration-approach.md deleted file mode 100644 index 2d44b99be..000000000 --- a/docs/adrs/008-anthropic-integration-approach.md +++ /dev/null @@ -1,164 +0,0 @@ -# Anthropic Integration: Approach - -Status: Proposed (2026-10-27). - -## Background - -SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. -The SDK needs to provide Java users with access to these models. - -This document covers the fundamental integration approach β€” how the SDK wraps or exposes the Anthropic API. -Downstream decisions about which sub-APIs to expose and how to handle client configuration are covered in -[009-anthropic-integration-client-design.md](009-anthropic-integration-client-design.md) -and depend on the outcome of this decision. - -### Reference: how the OpenAI integration works - -The existing `foundation-models/openai` module uses the **convenience classes** approach: -hand-rolled request/response POJOs (`OpenAiChatCompletionRequest`, `OpenAiChatCompletionResponse`, etc.) -and a custom `OpenAiClient` that wraps SAP's Apache HTTP client directly. - -```java -// OpenAI β€” convenience classes approach (current implementation) -OpenAiChatCompletionResponse response = - OpenAiClient.forModel(GPT_4O) - .chatCompletion(new OpenAiChatCompletionRequest("What is the capital of France?")); - -Stream stream = - OpenAiClient.forModel(GPT_4O) - .streamChatCompletionDeltas(new OpenAiChatCompletionRequest("Tell me a story")); -``` - -This gives full control over the API surface but requires ongoing maintenance as the OpenAI API evolves. -The Anthropic integration was an opportunity to evaluate whether this is the right pattern going forward. - ---- - -## Options considered - -### Option A: Convenience classes - -Hand-rolled request/response POJOs and a custom HTTP client β€” the same pattern used by the OpenAI integration. - -```java -// Hypothetical convenience classes approach -AnthropicChatCompletionResponse response = - AnthropicClient.forModel(CLAUDE_45_HAIKU) - .chatCompletion(new AnthropicChatCompletionRequest("What is the capital of France?")); - -Stream stream = - AnthropicClient.forModel(CLAUDE_45_HAIKU) - .streamChatCompletionDeltas(new AnthropicChatCompletionRequest("Tell me a story")); -``` - -### Option B: Raw Bedrock invoke API - -Expose the AWS Bedrock SDK `BedrockRuntimeClient` configured for SAP AI Core, with all serialization and response parsing left to the user. - -```java -// Chat completion β€” user owns request serialization and response parsing -BedrockRuntimeClient bedrockClient = BedrockRuntimeClient.builder() - .httpClient(SapBedrockHttpClient.forModel(CLAUDE_45_HAIKU)) - .build(); - -String requestBody = """ - { - "anthropic_version": "bedrock-2023-05-31", - "max_tokens": 1000, - "messages": [{"role": "user", "content": "What is the capital of France?"}] - } - """; - -InvokeModelResponse response = bedrockClient.invokeModel( - InvokeModelRequest.builder() - .modelId(CLAUDE_45_HAIKU.id()) - .body(SdkBytes.fromUtf8String(requestBody)) - .build()); - -String responseBody = response.body().asUtf8String(); -// user parses responseBody JSON manually - -// Streaming -bedrockClient.invokeModelWithResponseStream( - InvokeModelWithResponseStreamRequest.builder() - .modelId(CLAUDE_45_HAIKU.id()) - .body(SdkBytes.fromUtf8String(requestBody)) - .build(), - InvokeModelWithResponseStreamResponseHandler.builder() - .onEventStream(stream -> stream - .filter(e -> e instanceof PayloadPart) - .map(e -> ((PayloadPart) e).bytes().asUtf8String()) - .forEach(chunk -> { /* user parses each chunk JSON manually */ })) - .build()); -``` - -### Option C: Official Anthropic Java SDK (proposed) - -Use `com.anthropic:anthropic-java-core` as the typed API layer. -Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. -The bridge (`SapAnthropicHttpClient`) handles URL rewriting and Bedrock-style SSE format conversion transparently. - -**Technical details:** -The SDK exposes a subset of the official `AnthropicClient` sub-clients (currently `messages()` and `completions()` β€” the only two supported by SAP AI Core's router). -Internally, `SapAnthropicHttpClient` intercepts every outgoing request, rewrites the Anthropic API URL (`/v1/messages`) to the SAP AI Core Bedrock invoke endpoint (`/invoke` or `/invoke-with-response-stream`), strips Anthropic-specific auth headers, and injects the `anthropic_version` field required by the Bedrock wire format. -Responses are rewritten in the opposite direction: Bedrock-style SSE (no `event:` field) is converted back to native Anthropic SSE before being handed to the SDK's event parser. -OAuth authentication is handled transparently by `ApacheHttpClient5Accessor` via SAP destination configuration β€” no Anthropic API key is needed. - -**Reasons:** -- Simple on the user level API, allows for flexible and advanced configuration inside the SDK -- Utilizes available `invoke` API of the routing layer -- Works with available `AWS Bedrock` provider -- While official Anthropic API is exposed, backend can be flexibly replaced without breaking changes for AI SDK users - -```java -// Chat completion -Message response = - AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() - .messages() - .create( - MessageCreateParams.builder() - .model(CLAUDE_45_HAIKU.name()) - .maxTokens(1000) - .addUserMessage("What is the capital of France?") - .build()); - -String text = response.content().stream() - .filter(ContentBlock::isText) - .map(b -> b.asText().text()) - .collect(joining()); - -// Streaming -try (var stream = - AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() - .messages() - .createStreaming(params)) { - stream.stream() - .filter(RawMessageStreamEvent::isContentBlockDelta) - .map(e -> e.asContentBlockDelta().delta().asTextDelta().text()) - .forEach(System.out::print); -} -``` - ---- - -## Arguments - -| | Option A: Convenience classes | Option B: Raw Bedrock invoke | Option C: Official Anthropic SDK | -|---|---|---|---| -| Maintenance effort | High β€” every upstream API change requires SDK updates (see OpenAI module history) | Near zero β€” no types owned | Low β€” SDK absorbs upstream changes | -| Code footprint | Large | Minimal | Small β€” bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient` | -| User value | Moderate β€” familiar but limited, SAP-specific API | Low β€” user owns JSON serialization, response parsing, and SSE chunk handling | High β€” official, fully-typed, well-documented Anthropic API | -| API stability | Fragile β€” SAP owns the contract | Stable at transport level; unstable at payload level β€” user owns the wire format | Stable β€” Anthropic owns and maintains the contract | -| Backend replaceability | Hard β€” POJOs tightly coupled to Bedrock wire format | Hard β€” user code is tightly coupled to Bedrock wire format | Easy β€” transport is swappable without breaking user code | - ---- - -## Decision - -**Option C β€” use the official Anthropic Java SDK.** - -The `SapAnthropicHttpClient` bridges between `com.anthropic.core.http.HttpClient` and SAP's `ApacheHttpClient5Accessor`, -handling URL rewriting (`/v1/messages` β†’ `/invoke`) and Bedrock SSE format conversion transparently. -Users interact exclusively with official Anthropic SDK types (`AnthropicClient`, `MessageCreateParams`, `Message`, `StreamResponse`, etc.). diff --git a/docs/adrs/009-anthropic-integration-client-design.md b/docs/adrs/009-anthropic-client-style.md similarity index 97% rename from docs/adrs/009-anthropic-integration-client-design.md rename to docs/adrs/009-anthropic-client-style.md index 52f10621e..9c6de6821 100644 --- a/docs/adrs/009-anthropic-integration-client-design.md +++ b/docs/adrs/009-anthropic-client-style.md @@ -8,7 +8,7 @@ SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. This document covers two design decisions about how `AiCoreAnthropicClient` exposes the official Anthropic Java SDK to users. Both decisions assume **Option C (official Anthropic Java SDK)** from -[008-anthropic-integration-approach.md](008-anthropic-integration-approach.md). +[008-anthropic-integration-approach.md](008-anthropic-foundation-models.md). --- diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java index c2b444c07..44c27606c 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java @@ -3,6 +3,7 @@ import com.anthropic.client.AnthropicClient; import com.anthropic.client.AnthropicClientImpl; import com.anthropic.core.ClientOptions; +import com.anthropic.services.blocking.MessageService; import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.core.DeploymentResolutionException; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; @@ -72,6 +73,16 @@ static AiCoreAnthropicClient forDestination(@Nonnull final HttpDestination desti return new AiCoreAnthropicClient(destination); } + /** + * Returns configured client for Anthropic messages API. + * + * @return client implementation + */ + @Nonnull + public MessageService messages() { + return forDestination(destination).client().messages(); + } + /** * Get a configured {@link AnthropicClient} for the deployment. * @@ -82,7 +93,7 @@ static AiCoreAnthropicClient forDestination(@Nonnull final HttpDestination desti * @return a ready-to-use Anthropic client. */ @Nonnull - public AnthropicClient client() { + private AnthropicClient client() { final var options = ClientOptions.builder() .httpClient(new SapAnthropicHttpClient(destination)) diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index 9efbf6c52..b233da088 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -82,7 +82,7 @@ void testChatCompletion() { .addUserMessage("Hello!") .build(); - final var response = client.client().messages().create(params); + final var response = client.messages().create(params); assertThat(response).isNotNull(); assertThat(response.id()).isEqualTo("msg_01XFDUDYJgAACzvnptvVoYEL"); @@ -111,7 +111,7 @@ void testChatCompletionRequestFormat() { .addUserMessage("What is 2+2?") .build(); - client.client().messages().create(params); + client.messages().create(params); verify( postRequestedFor(urlPathEqualTo("/invoke")) @@ -143,7 +143,7 @@ void testModelFieldRemovedFromRequest() { .addUserMessage("Hello!") .build(); - client.client().messages().create(params); + client.messages().create(params); verify( postRequestedFor(urlPathEqualTo("/invoke")) @@ -174,7 +174,7 @@ void testStreamChatCompletion() throws IOException { .addUserMessage("Hello!") .build(); - try (var stream = client.client().messages().createStreaming(params)) { + try (var stream = client.messages().createStreaming(params)) { final var chunks = stream.stream() .filter(e -> e.isContentBlockDelta()) @@ -211,7 +211,7 @@ void testStreamChatCompletionReturnsStopReason() throws IOException { .addUserMessage("Hello!") .build(); - try (var stream = client.client().messages().createStreaming(params)) { + try (var stream = client.messages().createStreaming(params)) { final var stopReason = stream.stream() .filter(e -> e.isMessageDelta()) @@ -243,7 +243,7 @@ void testErrorHandling() { .addUserMessage("Hello!") .build(); - assertThatThrownBy(() -> client.client().messages().create(params)) + assertThatThrownBy(() -> client.messages().create(params)) .isInstanceOf(AnthropicException.class) .hasMessageContaining("400"); } @@ -259,7 +259,7 @@ void testServerError() { .addUserMessage("Hello!") .build(); - assertThatThrownBy(() -> client.client().messages().create(params)) + assertThatThrownBy(() -> client.messages().create(params)) .isInstanceOf(AnthropicException.class) .hasMessageContaining("500"); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java index 047ff4147..fbb0259a7 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java @@ -26,7 +26,6 @@ public class AnthropicService { @Nonnull public Message chatCompletion(@Nonnull final String userMessage) { return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() .messages() .create(BASE_PARAMS.addUserMessage(userMessage).build()); } @@ -41,7 +40,6 @@ public Message chatCompletion(@Nonnull final String userMessage) { public StreamResponse streamChatCompletion( @Nonnull final String userMessage) { return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() .messages() .createStreaming(BASE_PARAMS.addUserMessage(userMessage).build()); } From b2163ab2bfedad46f82f2f16b544d49586df332d Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Fri, 30 Oct 2026 15:22:05 +0100 Subject: [PATCH 09/26] add arch diagrams --- docs/adrs/008-anthropic-foundation-models.md | 40 +++++++++++--------- docs/adrs/images/option_a.drawio.svg | 4 ++ docs/adrs/images/option_b.drawio.svg | 4 ++ docs/adrs/images/option_c.drawio.svg | 4 ++ docs/adrs/images/option_d.drawio.svg | 4 ++ docs/adrs/images/option_e.drawio.svg | 4 ++ 6 files changed, 43 insertions(+), 17 deletions(-) create mode 100644 docs/adrs/images/option_a.drawio.svg create mode 100644 docs/adrs/images/option_b.drawio.svg create mode 100644 docs/adrs/images/option_c.drawio.svg create mode 100644 docs/adrs/images/option_d.drawio.svg create mode 100644 docs/adrs/images/option_e.drawio.svg diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md index aec9a49ca..5683dfafc 100644 --- a/docs/adrs/008-anthropic-foundation-models.md +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -25,6 +25,8 @@ not exist. Few solution options exist, the optimal option is to be identified. Client, configuration and operation payload classes (requests, responses and their parts) are written by hands for every functionality we want to support: +![](images/option_a.drawio.svg) + ```java // Hypothetical convenience classes approach AnthropicChatCompletionResponse response = @@ -49,6 +51,8 @@ Stream stream = Expose the AWS Bedrock SDK `BedrockRuntimeClient` configured for SAP AI Core, with all serialization and response parsing left to the user. +![](images/option_b.drawio.svg) + ```java // Chat completion β€” user owns request serialization and response parsing BedrockRuntimeClient bedrockClient = BedrockRuntimeClient.builder() @@ -100,6 +104,8 @@ Use `com.anthropic:anthropic-java-core` as the typed API layer. Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. Expose parts of official anthropic api we support (sub-clients implemented by anthropic) +![](images/option_c.drawio.svg) + ```java // Chat completion Message response = @@ -146,6 +152,8 @@ Use `com.anthropic:anthropic-java-core` as the typed API layer. Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. Expose parts of official anthropic api we support (sub-clients implemented by anthropic) +![](images/option_d.drawio.svg) + ```java // Chat completion Message response = @@ -192,6 +200,8 @@ Use `software.amazon.awssdk:bedrockruntime` as the typed API layer. Configure `BedrockRuntimeClient` with SAP's authenticated HTTP credentials and endpoint override pointing to SAP AI Core's gateway. The Converse API provides a model-agnostic typed interface β€” all request/response types (`ConverseRequest`, `ConverseResponse`, `Message`, `ContentBlock`, etc.) are owned by AWS. +![](images/option_e.drawio.svg) + ```java // Build SAP-authenticated BedrockRuntimeClient BedrockRuntimeClient bedrockClient = @@ -244,7 +254,7 @@ bedrockClient.converseStream( | Maintenance effort | Low for AWS SDK changes β€” typed classes are SDK-owned; but Anthropic-specific features (extended thinking, tool use variants) may lag behind the native Anthropic API | | Code footprint | Small β€” bridge only: SAP endpoint/credential wiring; no request/response mapping needed | | User value | Medium β€” familiar AWS SDK patterns for teams already using Bedrock; but Anthropic-specific parameters (e.g. `betas`, prompt caching) are not exposed (against the purpose of "foundation models") | -| API stability | Moderate β€” AWS Converse API is stable and model-agnostic; Anthropic-specific capabilities limited to what Converse supports | +| API stability | High β€” AWS Converse API is stable and model-agnostic; Anthropic-specific capabilities limited to what Converse supports | | Backend replaceability | Moderate β€” switching to a non-Bedrock backend requires replacing the AWS SDK dependency; Converse API types are AWS-specific | @@ -253,21 +263,17 @@ Official Anthropic Java SDK with Invoke AWS Bedrock Backend (Option C) is propos ## Rationale +Every aspect value is scored between 1 and 5 points +Every option receives result as a sum of its aspect values. + +| Aspect | A: Convenience classes | B: Raw Bedrock Invoke | __C: Anthropic SDK + Invoke__ | D: Anthropic SDK + Converse | E: AWS Converse SDK | +|------------------------|------------------------|-----------------------|-------------------------------|-----------------------------|---------------------| +| Maintenance effort | High (2) | Near zero (5) | __Low (4)__ | Very high (1) | Low (4) | +| Code footprint | Large (2) | Minimal (5) | __Small (4)__ | Large (1) | Small (4) | +| User value | Moderate (3) | Low (1) | __High (5)__ | Moderate (3) | Moderate (3) | +| API stability | Moderate–high (3.5) | Low (payload) (1) | __High (5)__ | Low (1) | Moderate (3) | +| Backend replaceability | Hard (1) | Impossible (1) | __Easy (5)__ | Difficult (2) | High (4) | +| Score | 12.5 | 13 | __23__ | 8 | 18 | ## Consequences, New Dependencies, Open Points, Next Steps - - -## Self Check - -The ADR... -- [ ] in `Status`, specifiies the correct ADR status, contributors, and reviewers requried -- [ ] in `Context`, refers to a Jira item describing the business requirement / backlog item and describes the context for AI Core architects and POs that are not deep experts for the ADR's topic -- [ ] in `Context`, specifies non-functional requirements that need to be fulfilled; in the solution options these non-functional requirements are considered -- [ ] in `Context`, presents more than 1 solution option; each with pros and cons -- [ ] in `Decision`, captures the clear single architecture decision at this point in time -- [ ] in `Consequences`, points out all identified open points, plans to proceed with them, and where input is required from reviewers -- [ ] in `Consequences`, enumerates all new introduced dependencies to external services -- [ ] considers aspects that are sometimes forgotten; discussion with the experts on these in early stages have been conducted (only if applicable): - - AI Launchpad - - SDKs - - Content as a service \ No newline at end of file + \ No newline at end of file diff --git a/docs/adrs/images/option_a.drawio.svg b/docs/adrs/images/option_a.drawio.svg new file mode 100644 index 000000000..b6872548b --- /dev/null +++ b/docs/adrs/images/option_a.drawio.svg @@ -0,0 +1,4 @@ + + + +
SAP AI SDK Anthropic Client
SAP AI Java SDK
SAP AI SDK high level convenience API
Exposes
Configures
Invokes (/invoke)
SAP developed component
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file diff --git a/docs/adrs/images/option_b.drawio.svg b/docs/adrs/images/option_b.drawio.svg new file mode 100644 index 000000000..30bd56c10 --- /dev/null +++ b/docs/adrs/images/option_b.drawio.svg @@ -0,0 +1,4 @@ + + + +
AWS Bedrock /invoke client
SAP AI Java SDK
Raw /invoke (json text) API
Exposes
Configures
Invokes (/invoke)
Intercepts/tweaks
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file diff --git a/docs/adrs/images/option_c.drawio.svg b/docs/adrs/images/option_c.drawio.svg new file mode 100644 index 000000000..90ea072cd --- /dev/null +++ b/docs/adrs/images/option_c.drawio.svg @@ -0,0 +1,4 @@ + + + +
Anthropic Official Java ClientΒ 
Anthropic Official AWS Bedrock Client
SAP AI Java SDK
Subset of Official Anthropic Client API
Exposes
Configures
Invokes (/invoke)
Intercepts / Tweaks
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file diff --git a/docs/adrs/images/option_d.drawio.svg b/docs/adrs/images/option_d.drawio.svg new file mode 100644 index 000000000..d4fcc6e7f --- /dev/null +++ b/docs/adrs/images/option_d.drawio.svg @@ -0,0 +1,4 @@ + + + +
AWS Bedrock Converse Java ClientΒ 
SAP AI Java SDK
AWS Converse API
Exposes
Configures
Invokes (/converse)
Intercepts / Tweaks
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file diff --git a/docs/adrs/images/option_e.drawio.svg b/docs/adrs/images/option_e.drawio.svg new file mode 100644 index 000000000..c8566805e --- /dev/null +++ b/docs/adrs/images/option_e.drawio.svg @@ -0,0 +1,4 @@ + + + +
AWS Bedrock /converse client
SAP AI Java SDK
AWS Bedrock Converse API
Exposes
Configures
Invokes (/converse)
Intercepts/tweaks
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file From 1848e8520f73a68ab51becb8d2e126bc12f67d99 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Mon, 2 Nov 2026 15:54:40 +0100 Subject: [PATCH 10/26] fix option d scheme --- docs/adrs/008-anthropic-foundation-models.md | 13 ++----------- docs/adrs/images/option_d.drawio.svg | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md index 5683dfafc..01c3c4fd4 100644 --- a/docs/adrs/008-anthropic-foundation-models.md +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -55,9 +55,7 @@ Expose the AWS Bedrock SDK `BedrockRuntimeClient` configured for SAP AI Core, wi ```java // Chat completion β€” user owns request serialization and response parsing -BedrockRuntimeClient bedrockClient = BedrockRuntimeClient.builder() - .httpClient(SapBedrockHttpClient.forModel(CLAUDE_45_HAIKU)) - .build(); +BedrockRuntimeClient bedrockClient = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU); String requestBody = """ { @@ -110,7 +108,6 @@ Expose parts of official anthropic api we support (sub-clients implemented by an // Chat completion Message response = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() .messages() .create( MessageCreateParams.builder() @@ -125,11 +122,7 @@ String text = response.content().stream() .collect(joining()); // Streaming -try (var stream = - AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() - .messages() - .createStreaming(params)) { +try (var stream = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU).messages().createStreaming(params)) { stream.stream() .filter(RawMessageStreamEvent::isContentBlockDelta) .map(e -> e.asContentBlockDelta().delta().asTextDelta().text()) @@ -158,7 +151,6 @@ Expose parts of official anthropic api we support (sub-clients implemented by an // Chat completion Message response = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() .messages() .create( MessageCreateParams.builder() @@ -175,7 +167,6 @@ String text = response.content().stream() // Streaming try (var stream = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .client() .messages() .createStreaming(params)) { stream.stream() diff --git a/docs/adrs/images/option_d.drawio.svg b/docs/adrs/images/option_d.drawio.svg index d4fcc6e7f..4283164e7 100644 --- a/docs/adrs/images/option_d.drawio.svg +++ b/docs/adrs/images/option_d.drawio.svg @@ -1,4 +1,4 @@ -
AWS Bedrock Converse Java ClientΒ 
SAP AI Java SDK
AWS Converse API
Exposes
Configures
Invokes (/converse)
Intercepts / Tweaks
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file +
AWS Bedrock Converse Java ClientΒ 
SAP AI Java SDK
Subset of Official Anthropic Client API
Exposes
Configures
Invokes (/converse)
Intercepts / Tweaks
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file From 8fef6d332e89f3d8b110c1c785e5ba4b21f4a7b8 Mon Sep 17 00:00:00 2001 From: vladimir-a-sap Date: Mon, 2 Nov 2026 15:56:21 +0100 Subject: [PATCH 11/26] Apply suggestion from @CharlesDuboisSAP Co-authored-by: Charles Dubois <103174266+CharlesDuboisSAP@users.noreply.github.com> --- docs/release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release_notes.md b/docs/release_notes.md index dd41b4207..640352893 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -12,7 +12,7 @@ ### ✨ New Functionality -- [Foundation models] Added Anthropics models support. See sample app in `sample-code/spring-app` for usage and documentation for more information. +- [Anthropic] Added `AiCoreAnthropicClient` for `messages()` and `completions()`. ### πŸ“ˆ Improvements From 2923bc0be193b80da2bb98e79817acdef6dd42cb Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Tue, 3 Nov 2026 17:00:41 +0100 Subject: [PATCH 12/26] address pr feedback, extract exposed api options into a separate document --- docs/adrs/008-anthropic-foundation-models.md | 4 +- docs/adrs/009-anthropic-client-api-subset.md | 84 ++++++++++++++++++ ... => 010-anthropic-client-configuration.md} | 11 ++- foundation-models/anthropic/pom.xml | 34 +------- .../anthropic/AiCoreAnthropicClient.java | 17 ++++ .../anthropic/AiCoreAnthropicClientTest.java | 17 +++- pom.xml | 25 +++--- .../app/controllers/AnthropicController.java | 86 ++++++++++++------- .../ai/sdk/app/services/AnthropicService.java | 6 +- .../src/main/resources/static/index.html | 12 +-- .../ai/sdk/app/controllers/AnthropicTest.java | 4 +- 11 files changed, 208 insertions(+), 92 deletions(-) create mode 100644 docs/adrs/009-anthropic-client-api-subset.md rename docs/adrs/{009-anthropic-client-style.md => 010-anthropic-client-configuration.md} (97%) diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md index 01c3c4fd4..b4d7cd935 100644 --- a/docs/adrs/008-anthropic-foundation-models.md +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -8,7 +8,7 @@ - Next expected status change date: 02.11.2026 ## Context -AI SDK needs to provide users with direct access to Anthropic models. This document enumerates different implementation +AI SDK needs to provide users with direct access to Anthropic model classes. This document enumerates different implementation approaches with pros and cons for each option. ### Business Requirements @@ -16,7 +16,7 @@ This ADR is required in the context of the backlog items: - https://github.com/SAP/ai-sdk-java-backlog/issues/449 ### Problem Description -AI SDK needs to provide users with direct access to Anthropic models. Currently, the corresponding solution does +AI SDK needs to provide users with direct access to Anthropic model classes. Currently, the corresponding solution does not exist. Few solution options exist, the optimal option is to be identified. ### Solution Options diff --git a/docs/adrs/009-anthropic-client-api-subset.md b/docs/adrs/009-anthropic-client-api-subset.md new file mode 100644 index 000000000..2a73f4535 --- /dev/null +++ b/docs/adrs/009-anthropic-client-api-subset.md @@ -0,0 +1,84 @@ +# Anthropic client API subset + +## Status + +- Status: `proposed` +- Contributors: Vladimir Aseev +- Reviewers: Zhongpin Wang +- Next expected status change date: 06.11.2026 + +## Context +Anthropic foundation models client will be implemented according to [008-anthropic-integration-approach.md](008-anthropic-foundation-models.md). +Exposed by the client subset of API offered by Anthropic is to be decided. + +### Business Requirements +This ADR is required in the context of the backlog items: +- https://github.com/SAP/ai-sdk-java-backlog/issues/449 + +### Problem Description +Anthropic offers a broad range of APIs. Some functions might be already deprecated, some might be not supported by +SAP gateway or low demand and not worth supporting for set of reasons. + +This document compares different options with their tradeoffs, attempts to identify and propose the optimal solution. + +### Solution Options +### Option A: Expose entire Anthropic API client + +We expose official Anthropics client (configured to use SAP gateway) "as is": + +```java +AnthropicClient client = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU); +CompletionService completionsApi = client.completions(); +MessagesService messagesApi = client.messages(); +ModelService modelsApi = client.models(); +FileService filesApi = client.files(); +SkillService skillsApi = client.skills(); +OrganizationService organizationApi = client.organization(); +AnthropicClientAsync asyncClient = client.async(); +// ... +``` + +| Aspect | Value | +|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| Anthropic API completeness | Maximum - high (potentially everything that is supported by Anthropics and SAP gateway) | +| Maintenance effort | Maximum, given our committment to track API availability and defend our users from breaking changes many tests are to be maintained. | +| Implementation effort | Maximum - many tests are to be written | +| User value | Maximum - users can use virtually any relevant Anthropics APIs available via SAP gateway | +| Flexibility | Minimum - if we expose entire official Anthropics client "as is" we barely have any control on the operations level unless we reimplement it ourselves | + +### Option B: Expose `messages` sub-api, proceed with other APIs on demand + +We implement a small wrapper, which only exposes (delegates) sub-APIs of official Anthropic client; We start with `messages`: + +```java +AiCoreAnthropicClient client = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU); +MessagesService messagesApi = client.messages(); +// ... +``` + +| Aspect | Value | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Anthropic API completeness | Minimum - only `messages` are initially exposed, other sub-apis can be supported on demand relatively easily | +| Maintenance effort | Minimum, we maintain tests and API only explicitly exposed (monitored API avaiability, absence of breaking changes) | +| Implementation effort | Minimum - few tests are to be written, only to cover explicitly exposed APIs | +| User value | Medium - we can prioritise features/ sub-APIs according to ROI/ROTI and/or Pareto principles. Low demand sub-apis can be postponed, while high demand prioritized | +| Flexibility | Maximum - if we expose entire official Anthropics client "as is" we barely have any control on the operations level unless we reimplement it ourselves | + +## Decision +Expose `messages` sub-api, proceed with other APIs on demand (Option B) is proposed. + +## Rationale +Every aspect value is scored between 1 and 5 points +Every option receives result as a sum of its aspect values. + +| Aspect | A: Full client | __B: Sub-APIs__ | +|----------------------------|----------------|-----------------| +| Anthropic API completeness | Maximum (5) | __Minimum (1)__ | +| Maintenance effort | Maximum (1) | __Minimum (5)__ | +| Implementation effort | Maximum (1) | __Minimum (5)__ | +| User value | Maximum (5) | __Medium (3)__ | +| Flexibility | Minimum (1) | __Maximum (5)__ | +| Score | 13 | __19__ | + +## Consequences, New Dependencies, Open Points, Next Steps + \ No newline at end of file diff --git a/docs/adrs/009-anthropic-client-style.md b/docs/adrs/010-anthropic-client-configuration.md similarity index 97% rename from docs/adrs/009-anthropic-client-style.md rename to docs/adrs/010-anthropic-client-configuration.md index 9c6de6821..c021a3866 100644 --- a/docs/adrs/009-anthropic-client-style.md +++ b/docs/adrs/010-anthropic-client-configuration.md @@ -1,3 +1,12 @@ + +::::::TODO:::: + + +WIP +WIP +WIP + + # Anthropic Integration: Client Design Status: Proposed (2026-10-27). @@ -7,7 +16,7 @@ Status: Proposed (2026-10-27). SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. This document covers two design decisions about how `AiCoreAnthropicClient` exposes the official Anthropic Java SDK to users. -Both decisions assume **Option C (official Anthropic Java SDK)** from +Decision assumes **Option C (official Anthropic Java SDK)** from [008-anthropic-integration-approach.md](008-anthropic-foundation-models.md). --- diff --git a/foundation-models/anthropic/pom.xml b/foundation-models/anthropic/pom.xml index 2b7cf9a97..7ecc9ad62 100644 --- a/foundation-models/anthropic/pom.xml +++ b/foundation-models/anthropic/pom.xml @@ -10,7 +10,7 @@ com.sap.ai.sdk.foundationmodels anthropic Anthropic Claude client - SAP Cloud SDK for AI is the official Software Development Kit (SDK) for SAP AI Core, SAP Generative AI Hub, and Orchestration Service. This is the client for consuming Anthropic Claude models via the SAP AI Core inference gateway. + SAP Cloud SDK for AI is the official Software Development Kit (SDK) for SAP AI Core, SAP Generative AI Hub, and Orchestration Service. This is the Anthropic Claude client via the SAP AI Core inference gateway. https://github.com/SAP/ai-sdk-java?tab=readme-ov-file#documentation @@ -45,38 +45,6 @@ 80% 100%
- - - - - - org.jetbrains.kotlin - kotlin-reflect - 1.9.25 - - - org.jetbrains.kotlin - kotlin-stdlib - 1.9.25 - - - org.jetbrains.kotlin - kotlin-stdlib-jdk7 - 1.9.25 - - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 - 1.9.25 - - - org.jetbrains.kotlin - kotlin-stdlib-common - 1.9.25 - - - - com.sap.ai.sdk diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java index 44c27606c..71b4b9593 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java @@ -1,9 +1,15 @@ package com.sap.ai.sdk.foundationmodels.anthropic; import com.anthropic.client.AnthropicClient; +import com.anthropic.client.AnthropicClientAsync; import com.anthropic.client.AnthropicClientImpl; import com.anthropic.core.ClientOptions; +import com.anthropic.services.blocking.CompletionService; +import com.anthropic.services.blocking.FileService; import com.anthropic.services.blocking.MessageService; +import com.anthropic.services.blocking.ModelService; +import com.anthropic.services.blocking.OrganizationService; +import com.anthropic.services.blocking.SkillService; import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.core.DeploymentResolutionException; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; @@ -101,4 +107,15 @@ private AnthropicClient client() { .build(); return new AnthropicClientImpl(options); } + + private void ff() { + var client = client(); + CompletionService completionsApi = client.completions(); + ModelService modelsApi = client.models(); + FileService filesApi = client.files(); + SkillService skillsApi = client.skills(); + OrganizationService organizationApi = client.organization(); + AnthropicClientAsync asyncClient = client.async(); + // ... + } } diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index b233da088..70aff8f58 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -224,6 +224,18 @@ void testStreamChatCompletionReturnsStopReason() throws IOException { } } + @Test + void testBuilderErrorHandling() { + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .addUserMessage("Hello!"); + + assertThatThrownBy(params::build) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("`maxTokens` is required"); + } + @Test void testErrorHandling() { stubFor( @@ -232,7 +244,7 @@ void testErrorHandling() { badRequest() .withBody( """ - {"type":"error","error":{"type":"invalid_request_error","message":"max_tokens is required"}} + {"type":"error","error":{"type":"invalid_request_error","message":"field_x is required"}} """) .withHeader("Content-Type", "application/json"))); @@ -245,7 +257,8 @@ void testErrorHandling() { assertThatThrownBy(() -> client.messages().create(params)) .isInstanceOf(AnthropicException.class) - .hasMessageContaining("400"); + .hasMessageContaining("400") + .hasMessageContaining("field_x is required"); } @Test diff --git a/pom.xml b/pom.xml index 870ff05c0..bf7926d78 100644 --- a/pom.xml +++ b/pom.xml @@ -51,7 +51,7 @@ - 2.72.0 + 2.72.0 ${project.basedir} 17 17 @@ -91,6 +91,7 @@ 1.0.1 3.0.8 3.3.1 + 2.2.50 1.86 2.4.21 @@ -143,6 +144,11 @@ import + + com.anthropic + anthropic-java-core + ${anthropic-sdk.version} + io.projectreactor reactor-core @@ -183,6 +189,12 @@ jackson-datatype-jsr310 ${jackson.version} + + + io.swagger.core.v3 + swagger-annotations + ${swagger-annotations.version} + tools.jackson.core jackson-databind @@ -396,17 +408,6 @@ openai-java-core ${openai-java.version} - - com.anthropic - anthropic-java-core - ${anthropic-java.version} - - - - io.swagger.core.v3 - swagger-annotations - 2.2.50 - diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java index e33ecfab1..5da34e14f 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -1,9 +1,15 @@ package com.sap.ai.sdk.app.controllers; +import com.anthropic.models.messages.RawMessageStreamEvent; +import com.anthropic.models.messages.TextDelta; import com.sap.ai.sdk.app.services.AnthropicService; +import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; +import java.io.IOException; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; @@ -16,45 +22,63 @@ public class AnthropicController { @Autowired private AnthropicService service; - /** Chat completion request to Anthropic Claude. */ - @GetMapping("/anthropic/chatCompletion") + /** Initiate new chat by sending message to Anthropic Claude. */ + @GetMapping("/anthropic/sendMessage") @Nonnull - String chatCompletion() { - final var message = service.chatCompletion("What is the capital of France?"); + String sendMessage() { + final var message = service.sendMessage("What is the capital of France?"); return message.content().stream() .filter(block -> block.isText()) .map(block -> block.text().map(t -> t.text()).orElse("")) .reduce("", String::concat); } - /** Streaming chat completion request to Anthropic Claude. */ - @GetMapping("/anthropic/streamChatCompletion") + /** Initiate new chat by sending message to Anthropic Claude. Stream response. */ + @GetMapping("/anthropic/sendMessageStreamResponse") @Nonnull - ResponseBodyEmitter streamChatCompletion() { + ResponseEntity sendMessageStreamResponse() { + final var message = "Can you give me the first 100 numbers of the Fibonacci sequence?"; + ; final var emitter = new ResponseBodyEmitter(); - final var stream = service.streamChatCompletion("What is the capital of France?"); - new Thread( - () -> { - try (stream) { - stream.stream() - .filter(event -> event.isContentBlockDelta()) - .map(event -> event.asContentBlockDelta().delta()) - .filter(delta -> delta.isText()) - .map(delta -> delta.asText().text()) - .forEach( - text -> { - try { - emitter.send(text); - } catch (final Exception e) { - emitter.completeWithError(e); - } - }); - emitter.complete(); - } catch (final Exception e) { - emitter.completeWithError(e); - } - }) - .start(); - return emitter; + send(emitter, "Model response:\n\n"); + final Runnable consumeStream = + () -> { + try (var stream = service.sendMessageStreamResponse(message)) { + stream.stream() + .filter(RawMessageStreamEvent::isContentBlockDelta) + .forEach( + deltaMessage -> + send( + emitter, + deltaMessage + .asContentBlockDelta() + .delta() + .text() + .orElseGet(() -> TextDelta.of("")) + .text())); + } finally { + emitter.complete(); + } + }; + ThreadContextExecutors.getExecutor().execute(consumeStream); + + // TEXT_EVENT_STREAM allows the browser to display the content as it is streamed + return ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(emitter); + } + + /** + * Send a chunk to the emitter. + * + * @param emitter the emitter to send the chunk to. + * @param chunk the chunk to send. + */ + public static void send(@Nonnull final ResponseBodyEmitter emitter, @Nonnull final String chunk) { + try { + log.warn("sending message to anthropic: {}", chunk); + emitter.send(chunk); + } catch (final IOException e) { + log.error("Failed to send chunk: {}", e.getMessage(), e); + emitter.completeWithError(e); + } } } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java index fbb0259a7..1141d736e 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java @@ -18,13 +18,13 @@ public class AnthropicService { MessageCreateParams.builder().model(CLAUDE_45_HAIKU.name()).maxTokens(1000); /** - * Chat request to Anthropic. + * Send message into a new chat to Anthropic assistant. * * @param userMessage the prompt to send to the assistant. * @return the assistant message response. */ @Nonnull - public Message chatCompletion(@Nonnull final String userMessage) { + public Message sendMessage(@Nonnull final String userMessage) { return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) .messages() .create(BASE_PARAMS.addUserMessage(userMessage).build()); @@ -37,7 +37,7 @@ public Message chatCompletion(@Nonnull final String userMessage) { * @return the emitter that streams the assistant message response. */ @Nonnull - public StreamResponse streamChatCompletion( + public StreamResponse sendMessageStreamResponse( @Nonnull final String userMessage) { return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) .messages() diff --git a/sample-code/spring-app/src/main/resources/static/index.html b/sample-code/spring-app/src/main/resources/static/index.html index f42d7d245..af8cca18d 100644 --- a/sample-code/spring-app/src/main/resources/static/index.html +++ b/sample-code/spring-app/src/main/resources/static/index.html @@ -852,23 +852,23 @@

πŸ§‘β€πŸ”¬ Foundation Models

  • -
    - Chat request to Anthropic Claude. + Initiate new chat by sending message to Anthropic Claude.
  • -
    - Asynchronous stream of an Anthropic Claude chat request. + Initiate new chat by sending message to Anthropic Claude. Stream response.
  • diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java index 356c78e94..59c8a6970 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java @@ -12,7 +12,7 @@ class AnthropicTest { @Test void chatCompletion() { - final var message = service.chatCompletion("What is the capital of France?"); + final var message = service.sendMessage("What is the capital of France?"); assertThat(message).isNotNull(); assertThat(message.content()).isNotEmpty(); @@ -30,7 +30,7 @@ void chatCompletion() { void streamChatCompletion() { final var fullResponse = new AtomicReference<>(""); - try (var stream = service.streamChatCompletion("What is the capital of France?")) { + try (var stream = service.sendMessageStreamResponse("What is the capital of France?")) { stream.stream() .filter(event -> event.isContentBlockDelta()) .map(event -> event.asContentBlockDelta().delta()) From d7bdfd4d1be7db8f3cf59298806f69dd15a3716c Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Wed, 4 Nov 2026 16:48:21 +0100 Subject: [PATCH 13/26] polish documentation --- docs/adrs/008-anthropic-foundation-models.md | 176 +++++++++--------- docs/adrs/009-anthropic-client-api-subset.md | 8 +- .../010-anthropic-client-configuration.md | 160 ++++++++-------- docs/adrs/images/option_a.drawio.svg | 2 +- docs/adrs/images/option_b.drawio.svg | 2 +- docs/adrs/images/option_c.drawio.svg | 2 +- docs/adrs/images/option_d.drawio.svg | 2 +- docs/adrs/images/option_e.drawio.svg | 2 +- .../anthropic/AiCoreAnthropicClient.java | 16 -- 9 files changed, 181 insertions(+), 189 deletions(-) diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md index b4d7cd935..41f8aa21d 100644 --- a/docs/adrs/008-anthropic-foundation-models.md +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -47,63 +47,16 @@ Stream stream = | Backend replaceability | Hard β€” POJOs tightly coupled to Bedrock wire format, almost entire codebase will have to be re-written | -#### Option B: Raw Bedrock invoke API +#### Option B: Anthropic SDK with AWS Converse -Expose the AWS Bedrock SDK `BedrockRuntimeClient` configured for SAP AI Core, with all serialization and response parsing left to the user. +Expose parts of official anthropic api we support (sub-clients implemented by anthropic). +Map operations into AWS Converse API +Use AWS Converse client to perform converse API operations +Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client and +make it call SAP gateway. ![](images/option_b.drawio.svg) -```java -// Chat completion β€” user owns request serialization and response parsing -BedrockRuntimeClient bedrockClient = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU); - -String requestBody = """ - { - "anthropic_version": "bedrock-2023-05-31", - "max_tokens": 1000, - "messages": [{"role": "user", "content": "What is the capital of France?"}] - } - """; - -InvokeModelResponse response = bedrockClient.invokeModel( - InvokeModelRequest.builder() - .modelId(CLAUDE_45_HAIKU.id()) - .body(SdkBytes.fromUtf8String(requestBody)) - .build()); - -String responseBody = response.body().asUtf8String(); -// user parses responseBody JSON manually - -// Streaming -bedrockClient.invokeModelWithResponseStream( - InvokeModelWithResponseStreamRequest.builder() - .modelId(CLAUDE_45_HAIKU.id()) - .body(SdkBytes.fromUtf8String(requestBody)) - .build(), - InvokeModelWithResponseStreamResponseHandler.builder() - .onEventStream(stream -> stream - .filter(e -> e instanceof PayloadPart) - .map(e -> ((PayloadPart) e).bytes().asUtf8String()) - .forEach(chunk -> { /* user parses each chunk JSON manually */ })) - .build()); -``` - -| Aspect | Value | -|-----------------|----------------------------------------------------------------------------------| -| Maintenance effort | Near zero β€” no types owned | -| Code footprint | Minimal | -| User value | Low β€” user owns JSON serialization, response parsing, and SSE chunk handling | -| API stability | Stable at transport level; unstable at payload level β€” user owns the wire format | -| Backend replaceability | Virtually impossible β€” user code is tightly coupled to Bedrock wire format| - -### Option C: Official Anthropic Java SDK with Invoke AWS Bedrock Backend - -Use `com.anthropic:anthropic-java-core` as the typed API layer. -Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. -Expose parts of official anthropic api we support (sub-clients implemented by anthropic) - -![](images/option_c.drawio.svg) - ```java // Chat completion Message response = @@ -122,7 +75,10 @@ String text = response.content().stream() .collect(joining()); // Streaming -try (var stream = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU).messages().createStreaming(params)) { +try (var stream = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .messages() + .createStreaming(params)) { stream.stream() .filter(RawMessageStreamEvent::isContentBlockDelta) .map(e -> e.asContentBlockDelta().delta().asTextDelta().text()) @@ -130,22 +86,21 @@ try (var stream = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU).messages().cre } ``` -| Aspect | Value | -|------------------------|---------------------------------------------------------------------------------| -| Maintenance effort | Low β€” SDK absorbs upstream changes | -| Code footprint | Small β€” bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient` | -| User value | High β€” official, fully-typed, well-documented Anthropic API | -| API stability | Stable β€” Anthropic owns and maintains the contract | -| Backend replaceability | Easy β€” transport is swappable without breaking user code | - +| Aspect | Value | +|------------------------|---------------------------------------------------------------------------------------------------------------------------| +| Maintenance effort | Very high - implementation depends not only on Anthropic API changes but also on Converse AWS API changes | +| Code footprint | Large β€” high level mapping between the APIs is needed. some features might be impossible to map | +| User value | Medium β€” official, fully-typed, well-documented Anthropic API but scope is limited by not all operations supported/mapped | +| API stability | Low β€” glue (mapping) between Anthropics API and Converse API is brittle | +| Backend replaceability | Difficult β€” implementation will have to be redone | -### Option D: Official Anthropic Java SDK with Converse AWS Bedrock Backend +#### Option C: Wrapped Anthropic client with AWS Invoke Use `com.anthropic:anthropic-java-core` as the typed API layer. Replace its HTTP transport (`com.anthropic.core.http.HttpClient`) with SAP's authenticated Apache HTTP client. Expose parts of official anthropic api we support (sub-clients implemented by anthropic) -![](images/option_d.drawio.svg) +![](images/option_c.drawio.svg) ```java // Chat completion @@ -165,10 +120,7 @@ String text = response.content().stream() .collect(joining()); // Streaming -try (var stream = - AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) - .messages() - .createStreaming(params)) { +try (var stream = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU).messages().createStreaming(params)) { stream.stream() .filter(RawMessageStreamEvent::isContentBlockDelta) .map(e -> e.asContentBlockDelta().delta().asTextDelta().text()) @@ -176,20 +128,68 @@ try (var stream = } ``` -| Aspect | Value | -|------------------------|---------------------------------------------------------------------------------------------------------------------------| -| Maintenance effort | Very high - implementation depends not only on Anthropic API changes but also on Converse AWS API changes | -| Code footprint | Large β€” high level mapping between the APIs is needed. some features might be impossible to map | -| User value | Medium β€” official, fully-typed, well-documented Anthropic API but scope is limited by not all operations supported/mapped | -| API stability | Low β€” glue (mapping) between Anthropics API and Converse API is brittle | -| Backend replaceability | Difficult β€” implementation will have to be redone | +| Aspect | Value | +|------------------------|---------------------------------------------------------------------------------| +| Maintenance effort | Low β€” SDK absorbs upstream changes | +| Code footprint | Small β€” bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient` | +| User value | High β€” official, fully-typed, well-documented Anthropic API | +| API stability | Stable β€” Anthropic owns and maintains the contract | +| Backend replaceability | Easy β€” transport is swappable without breaking user code | + +#### Option D: AWS Invoke SDK + +Expose the AWS Bedrock SDK `BedrockRuntimeClient` configured for SAP AI Core, with all serialization and response parsing left to the user. + +![](images/option_d.drawio.svg) +```java +// Chat completion β€” user owns request serialization and response parsing +BedrockRuntimeClient bedrockClient = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU); + +String requestBody = """ + { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "What is the capital of France?"}] + } + """; + +InvokeModelResponse response = bedrockClient.invokeModel( + InvokeModelRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .body(SdkBytes.fromUtf8String(requestBody)) + .build()); + +String responseBody = response.body().asUtf8String(); +// user parses responseBody JSON manually + +// Streaming +bedrockClient.invokeModelWithResponseStream( + InvokeModelWithResponseStreamRequest.builder() + .modelId(CLAUDE_45_HAIKU.id()) + .body(SdkBytes.fromUtf8String(requestBody)) + .build(), + InvokeModelWithResponseStreamResponseHandler.builder() + .onEventStream(stream -> stream + .filter(e -> e instanceof PayloadPart) + .map(e -> ((PayloadPart) e).bytes().asUtf8String()) + .forEach(chunk -> { /* user parses each chunk JSON manually */ })) + .build()); +``` + +| Aspect | Value | +|-----------------|----------------------------------------------------------------------------------| +| Maintenance effort | Near zero β€” no types owned | +| Code footprint | Minimal | +| User value | Low β€” user owns JSON serialization, response parsing, and SSE chunk handling | +| API stability | Stable at transport level; unstable at payload level β€” user owns the wire format | +| Backend replaceability | Virtually impossible β€” user code is tightly coupled to Bedrock wire format| -### Option E: Official Converse AWS Bedrock Java API with Anthropic models via SAP gateway +#### Option E: AWS Converse SDK -Use `software.amazon.awssdk:bedrockruntime` as the typed API layer. +Expose `software.amazon.awssdk:bedrockruntime` classes as our API. Configure `BedrockRuntimeClient` with SAP's authenticated HTTP credentials and endpoint override pointing to SAP AI Core's gateway. -The Converse API provides a model-agnostic typed interface β€” all request/response types (`ConverseRequest`, `ConverseResponse`, `Message`, `ContentBlock`, etc.) are owned by AWS. +AWS Converse API provides a model-agnostic typed interface β€” all request/response types (`ConverseRequest`, `ConverseResponse`, `Message`, `ContentBlock`, etc.) are owned by AWS. ![](images/option_e.drawio.svg) @@ -257,14 +257,16 @@ Official Anthropic Java SDK with Invoke AWS Bedrock Backend (Option C) is propos Every aspect value is scored between 1 and 5 points Every option receives result as a sum of its aspect values. -| Aspect | A: Convenience classes | B: Raw Bedrock Invoke | __C: Anthropic SDK + Invoke__ | D: Anthropic SDK + Converse | E: AWS Converse SDK | -|------------------------|------------------------|-----------------------|-------------------------------|-----------------------------|---------------------| -| Maintenance effort | High (2) | Near zero (5) | __Low (4)__ | Very high (1) | Low (4) | -| Code footprint | Large (2) | Minimal (5) | __Small (4)__ | Large (1) | Small (4) | -| User value | Moderate (3) | Low (1) | __High (5)__ | Moderate (3) | Moderate (3) | -| API stability | Moderate–high (3.5) | Low (payload) (1) | __High (5)__ | Low (1) | Moderate (3) | -| Backend replaceability | Hard (1) | Impossible (1) | __Easy (5)__ | Difficult (2) | High (4) | -| Score | 12.5 | 13 | __23__ | 8 | 18 | +| Aspect | A: Convenience classes | B: Anthropic SDK + Converse | __C: Anthropic SDK + Invoke__ | D: AWS Invoke SDK | E: AWS Converse SDK | +|------------------------|------------------------|-----------------------------|-------------------------------|-------------------|---------------------| +| Maintenance effort | High (2) | Very high (1) | __Low (4)__ | Near zero (5) | Low (4) | +| Code footprint | Large (2) | Large (1) | __Small (4)__ | Minimal (5) | Small (4) | +| User value | Moderate (3) | Moderate (3) | __High (5)__ | Low (1) | Moderate (3) | +| API stability | Moderate–high (3.5) | Low (1) | __High (5)__ | Low (payload) (1) | Moderate (3) | +| Backend replaceability | Hard (1) | Difficult (2) | __Easy (5)__ | Impossible (1) | High (4) | +| Score | 12.5 | 8 | __23__ | 13 | 18 | ## Consequences, New Dependencies, Open Points, Next Steps - \ No newline at end of file +Further related decisions are to be made: +- [Client api subset](./009-anthropic-client-api-subset.md) +- [Client configuration](./010-anthropic-client-configuration.md) \ No newline at end of file diff --git a/docs/adrs/009-anthropic-client-api-subset.md b/docs/adrs/009-anthropic-client-api-subset.md index 2a73f4535..3f3e9431f 100644 --- a/docs/adrs/009-anthropic-client-api-subset.md +++ b/docs/adrs/009-anthropic-client-api-subset.md @@ -22,7 +22,7 @@ SAP gateway or low demand and not worth supporting for set of reasons. This document compares different options with their tradeoffs, attempts to identify and propose the optimal solution. ### Solution Options -### Option A: Expose entire Anthropic API client +#### Option A: Expose entire Anthropic API client We expose official Anthropics client (configured to use SAP gateway) "as is": @@ -46,7 +46,7 @@ AnthropicClientAsync asyncClient = client.async(); | User value | Maximum - users can use virtually any relevant Anthropics APIs available via SAP gateway | | Flexibility | Minimum - if we expose entire official Anthropics client "as is" we barely have any control on the operations level unless we reimplement it ourselves | -### Option B: Expose `messages` sub-api, proceed with other APIs on demand +#### Option B: Expose `messages` sub-api, proceed with other APIs on demand We implement a small wrapper, which only exposes (delegates) sub-APIs of official Anthropic client; We start with `messages`: @@ -81,4 +81,6 @@ Every option receives result as a sum of its aspect values. | Score | 13 | __19__ | ## Consequences, New Dependencies, Open Points, Next Steps - \ No newline at end of file +If starting with just `messages` option is chosen, the following further work is to be done: +- Investigate other sub-apis needed to be exposed +- Scope and plan further work on other needed sub-apis \ No newline at end of file diff --git a/docs/adrs/010-anthropic-client-configuration.md b/docs/adrs/010-anthropic-client-configuration.md index c021a3866..a4e7835b9 100644 --- a/docs/adrs/010-anthropic-client-configuration.md +++ b/docs/adrs/010-anthropic-client-configuration.md @@ -1,78 +1,82 @@ - -::::::TODO:::: - - -WIP -WIP -WIP - - -# Anthropic Integration: Client Design - -Status: Proposed (2026-10-27). - -## Background - -SAP AI Core exposes Anthropic Claude models via a Bedrock-compatible HTTP API. -This document covers two design decisions about how `AiCoreAnthropicClient` exposes the official Anthropic Java SDK to users. - -Decision assumes **Option C (official Anthropic Java SDK)** from -[008-anthropic-integration-approach.md](008-anthropic-foundation-models.md). - ---- - -## Decision 1: Sub-APIs exposed - -The official Anthropic Java SDK exposes multiple sub-APIs (`messages()`, `completions()`, `batches()`, `files()`, `models()`, …). -SAP AI Core's router currently only supports two of these: **messages** and **completions**. - -### Options considered - -| Option | Description | -|---|---| -| **Expose everything** | Return the full `AnthropicClient` interface with no restrictions or documentation caveats | -| **Expose only supported sub-APIs (chosen)** | Return the full `AnthropicClient` but document that only `messages()` and `completions()` are supported by SAP AI Core | - -### Arguments - -| | Expose everything | Expose only supported sub-APIs | -|---|---|---| -| Available functionality | Same β€” unsupported sub-APIs fail at runtime either way | Same | -| User experience | May cause confusion when unsupported calls fail with opaque errors | Clearly scoped; failures on unsupported APIs are expected and documented | -| Maintenance when SAP router adds sub-APIs | None | Documentation update only β€” no code change needed | -| Code footprint | None | None | - -### Decision - -**Expose only the supported sub-APIs** (`messages()` and `completions()`) by documenting the supported scope on `AiCoreAnthropicClient.client()`. -When SAP AI Core's router adds support for further sub-APIs, the SDK change is a documentation update only. - ---- - -## Decision 2: Client URL and configuration - -The official Anthropic SDK allows users to further configure the returned `AnthropicClient` β€” overriding the base URL, adding custom headers, changing timeouts, etc. -The question is whether the SDK should prevent or validate such overrides. - -### Options considered - -| Option | Description | -|---|---| -| **Restrict / redefine the interface** | Wrap or re-implement `messages()` and related services to prevent users from altering internal transport settings | -| **Do nothing (chosen)** | Return the standard `AnthropicClient` as-is; document that overriding internals is unsupported | - -### Arguments - -| | Restrict interface | Do nothing | -|---|---|---| -| Maintenance effort | High β€” requires re-implementing SDK interfaces; breaks on every upstream SDK update | Near zero | -| Code footprint | Large | None | -| User flexibility | Reduced β€” legitimate advanced use cases blocked | Full β€” power users can reconfigure if needed | -| Risk of misconfiguration | Accepted β€” consistent with SDK philosophy: bad input does not guarantee good output | Eliminated | -| Alignment with SDK principles | Against minimalistic design goal | Consistent | - -### Decision - -**Do nothing.** The `AnthropicClient` is returned as-is. -Users who override base URLs, headers, or other transport internals do so at their own risk. -This is consistent with the SDK's general design principle of minimal footprint and minimal maintenance overhead. +# Anthropic client configuration + +## Status + +- Status: `proposed` +- Contributors: Vladimir Aseev +- Reviewers: Zhongpin Wang +- Next expected status change date: 06.11.2026 + +## Context +Anthropic foundation models client will be implemented according to [008-anthropic-integration-approach.md](008-anthropic-foundation-models.md). +Exposed by the client configuration is to be decided. + +### Business Requirements +This ADR is required in the context of the backlog items: +- https://github.com/SAP/ai-sdk-java-backlog/issues/449 + +### Problem Description +Anthropic client and its sub-clients (services) expose ways to configure them: +```java + MessageService messagesClient = AiCoreAnthropicClient.forModel(AnthropicModel.CLAUDE_48_OPUS).messages(); + // messagesClient.withOptions(...) // client can be reconfigured here +``` + +Changing pre-configured client settings may lead to its inability to work with SAP gateway or issues in general. + +Options on how and if at all we address this challenge are listed in this document. + +### Solution Options +#### Option A: Override implementation +For every sub-api we expose (e.g. `messages`) we expose not original Antropic implementation but a wrapped or +extended our implementation which delegates all necessary methods to the original client but does nothing or throws error +on attempt to call `.withOptions(...)`. + +| Aspect | Value | +|-------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| Implementation effort | Maximum - we will need to implement relatively large codebase and tests | +| Maintenance effort | Maximum - all changes in the Anthropics API will have to be manually implemented in the proxy classes | +| Safety provided | Maximum - we can configure and enforce any safety guarantees and configuration we need | +| Flexibility | Minimum - we can cut off many non standard use cases we cannot foresee or verify now (e.g. usage with proxies and internet gateways) | + +#### Option B: Override interface +For every sub-api we expose (e.g. `messages`) we expose not original Anthropic interface but our copy, exposing all +operations exactly as they are in the original interface but not exposing the `.withOptions(...)` method. + +| Aspect | Value | +|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| Implementation effort | Medium - we will need to create one interface per sub-api | +| Maintenance effort | Medium - all changes in the Anthropics API will have to be reflected in the interfaces classes (one per sub-api) | +| Safety provided | Maximum - we can configure and enforce any safety guarantees and configuration we need | +| Flexibility | Minimum - we can cut off many non standard use cases we cannot foresee or verify now (e.g. usage with proxies and internet gateways) | + +#### Option C: Do nothing +We do nothing about configuration. We expose Anthropic sub-api (e.g. `messages`) with its original unaltered interface +and original implementation (just configured correctly to call SAP gateway) + +| Aspect | Value | +|-------------------------|---------------------------------------------------------------------------------------------------| +| Implementation effort | None (nothing to implement) | +| Maintenance effort | Minimum - nothing to maintain beyond the minimum necessary for system functioning | +| Safety provided | Medium - we pre-configure all clients, but users can break things if they re-configure them | +| Flexibility | High - we still pre-configure all clients, but users can tweak configs for non-standard use cases | + +## Decision +Do nothing (option C) + +We pre-configure all clients, but users can break things if they explicitly re-configure them + +## Rationale +Every aspect value is scored between 1 and 5 points +Every option receives result as a sum of its aspect values. + +| Aspect | A: Override implementation | B: Override interface | __C: Do nothing__ | +|-----------------------|----------------------------|-----------------------|-------------------| +| Implementation effort | Maximum (1) | Medium (3) | __None (5)__ | +| Maintenance effort | Maximum (1) | Medium (3) | __Minimum (5)__ | +| Safety provided | Maximum (5) | Maximum (5) | __Medium (3)__ | +| Flexibility | Minimum (1) | Minimum (1) | __High (4)__ | +| Score | 8 | 12 | __17__ | + +## Consequences, New Dependencies, Open Points, Next Steps + diff --git a/docs/adrs/images/option_a.drawio.svg b/docs/adrs/images/option_a.drawio.svg index b6872548b..96105c09d 100644 --- a/docs/adrs/images/option_a.drawio.svg +++ b/docs/adrs/images/option_a.drawio.svg @@ -1,4 +1,4 @@ -
    SAP AI SDK Anthropic Client
    SAP AI Java SDK
    SAP AI SDK high level convenience API
    Exposes
    Configures
    Invokes (/invoke)
    SAP developed component
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file +
    SAP AI SDK Anthropic Client
    SAP AI Java SDK
    SAP AI SDK high level convenience API
    Exposes
    Configures
    calls (/invoke)
    SAP developed component
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file diff --git a/docs/adrs/images/option_b.drawio.svg b/docs/adrs/images/option_b.drawio.svg index 30bd56c10..57fb802a3 100644 --- a/docs/adrs/images/option_b.drawio.svg +++ b/docs/adrs/images/option_b.drawio.svg @@ -1,4 +1,4 @@ -
    AWS Bedrock /invoke client
    SAP AI Java SDK
    Raw /invoke (json text) API
    Exposes
    Configures
    Invokes (/invoke)
    Intercepts/tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file +
    AWS Bedrock Converse Java ClientΒ 
    SAP AI Java SDK
    Subset of Official Anthropic Client API
    Exposes
    Configures
    callsΒ (/converse)
    Intercepts / Tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file diff --git a/docs/adrs/images/option_c.drawio.svg b/docs/adrs/images/option_c.drawio.svg index 90ea072cd..b49e063ac 100644 --- a/docs/adrs/images/option_c.drawio.svg +++ b/docs/adrs/images/option_c.drawio.svg @@ -1,4 +1,4 @@ -
    Anthropic Official Java ClientΒ 
    Anthropic Official AWS Bedrock Client
    SAP AI Java SDK
    Subset of Official Anthropic Client API
    Exposes
    Configures
    Invokes (/invoke)
    Intercepts / Tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file +
    Anthropic Official Java ClientΒ 
    Anthropic Official AWS Bedrock Client
    SAP AI Java SDK
    Subset of Official Anthropic Client API
    Exposes
    Configures
    calls (/invoke)
    Intercepts / Tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file diff --git a/docs/adrs/images/option_d.drawio.svg b/docs/adrs/images/option_d.drawio.svg index 4283164e7..081caeb65 100644 --- a/docs/adrs/images/option_d.drawio.svg +++ b/docs/adrs/images/option_d.drawio.svg @@ -1,4 +1,4 @@ -
    AWS Bedrock Converse Java ClientΒ 
    SAP AI Java SDK
    Subset of Official Anthropic Client API
    Exposes
    Configures
    Invokes (/converse)
    Intercepts / Tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file +
    AWS Bedrock /invoke client
    SAP AI Java SDK
    Raw /invoke (json text) API
    Exposes
    Configures
    calls (/invoke)
    Intercepts/tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file diff --git a/docs/adrs/images/option_e.drawio.svg b/docs/adrs/images/option_e.drawio.svg index c8566805e..8af801427 100644 --- a/docs/adrs/images/option_e.drawio.svg +++ b/docs/adrs/images/option_e.drawio.svg @@ -1,4 +1,4 @@ -
    AWS Bedrock /converse client
    SAP AI Java SDK
    AWS Bedrock Converse API
    Exposes
    Configures
    Invokes (/converse)
    Intercepts/tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file +
    AWS Bedrock /converse client
    SAP AI Java SDK
    AWS Bedrock Converse API
    Exposes
    Configures
    calls (/converse)
    Intercepts/tweaks
    SAP Gateway
    • Authorizes
    • Facilitates
    • Rate limits
    • Routes
    AWS Bedrock
    \ No newline at end of file diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java index 71b4b9593..f7311135b 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java @@ -1,15 +1,9 @@ package com.sap.ai.sdk.foundationmodels.anthropic; import com.anthropic.client.AnthropicClient; -import com.anthropic.client.AnthropicClientAsync; import com.anthropic.client.AnthropicClientImpl; import com.anthropic.core.ClientOptions; -import com.anthropic.services.blocking.CompletionService; -import com.anthropic.services.blocking.FileService; import com.anthropic.services.blocking.MessageService; -import com.anthropic.services.blocking.ModelService; -import com.anthropic.services.blocking.OrganizationService; -import com.anthropic.services.blocking.SkillService; import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.core.DeploymentResolutionException; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; @@ -108,14 +102,4 @@ private AnthropicClient client() { return new AnthropicClientImpl(options); } - private void ff() { - var client = client(); - CompletionService completionsApi = client.completions(); - ModelService modelsApi = client.models(); - FileService filesApi = client.files(); - SkillService skillsApi = client.skills(); - OrganizationService organizationApi = client.organization(); - AnthropicClientAsync asyncClient = client.async(); - // ... - } } From c911941e75cdf0d6afce8f695804f70c3d1dd556 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Wed, 4 Nov 2026 16:50:46 +0100 Subject: [PATCH 14/26] polish code style --- .../foundationmodels/anthropic/AiCoreAnthropicClient.java | 1 - .../anthropic/AiCoreAnthropicClientTest.java | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java index f7311135b..44c27606c 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java @@ -101,5 +101,4 @@ private AnthropicClient client() { .build(); return new AnthropicClientImpl(options); } - } diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index 70aff8f58..432d7ee6c 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -224,12 +224,10 @@ void testStreamChatCompletionReturnsStopReason() throws IOException { } } - @Test + @Test void testBuilderErrorHandling() { final var params = - MessageCreateParams.builder() - .model("claude-3-haiku-20240307") - .addUserMessage("Hello!"); + MessageCreateParams.builder().model("claude-3-haiku-20240307").addUserMessage("Hello!"); assertThatThrownBy(params::build) .isInstanceOf(IllegalStateException.class) From 9cea6461b5d8d6585f89c83a25c16eeaf947dbc7 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Thu, 5 Nov 2026 10:41:50 +0100 Subject: [PATCH 15/26] polish documentation --- docs/adrs/008-anthropic-foundation-models.md | 22 +++++++++---------- .../010-anthropic-client-configuration.md | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md index 41f8aa21d..9cc38991d 100644 --- a/docs/adrs/008-anthropic-foundation-models.md +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -41,7 +41,7 @@ Stream stream = | Aspect | Value | |------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Maintenance effort | High β€” every upstream API change requires SDK updates | -| Code footprint | Large. In order to provide typed API many classes will have to be written, covered with tests and documented | +| Codebase size | Large. In order to provide typed API many classes will have to be written, covered with tests and documented | | User value | Moderate. All new functionality from Antropics won't be supported immediately (lag). Most/much of functionality won't be supported at all given the development capacity constraints. | | API stability | Moderade to high β€” SAP owns the contract but on upstream API changes we may have to introduce breaking changes to our API | | Backend replaceability | Hard β€” POJOs tightly coupled to Bedrock wire format, almost entire codebase will have to be re-written | @@ -89,7 +89,7 @@ try (var stream = | Aspect | Value | |------------------------|---------------------------------------------------------------------------------------------------------------------------| | Maintenance effort | Very high - implementation depends not only on Anthropic API changes but also on Converse AWS API changes | -| Code footprint | Large β€” high level mapping between the APIs is needed. some features might be impossible to map | +| Codebase size | Large β€” high level mapping between the APIs is needed. some features might be impossible to map | | User value | Medium β€” official, fully-typed, well-documented Anthropic API but scope is limited by not all operations supported/mapped | | API stability | Low β€” glue (mapping) between Anthropics API and Converse API is brittle | | Backend replaceability | Difficult β€” implementation will have to be redone | @@ -131,7 +131,7 @@ try (var stream = AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU).messages().cre | Aspect | Value | |------------------------|---------------------------------------------------------------------------------| | Maintenance effort | Low β€” SDK absorbs upstream changes | -| Code footprint | Small β€” bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient` | +| Codebase size | Small β€” bridge only: `AiCoreAnthropicClient`, `SapAnthropicHttpClient` | | User value | High β€” official, fully-typed, well-documented Anthropic API | | API stability | Stable β€” Anthropic owns and maintains the contract | | Backend replaceability | Easy β€” transport is swappable without breaking user code | @@ -177,12 +177,12 @@ bedrockClient.invokeModelWithResponseStream( .build()); ``` -| Aspect | Value | -|-----------------|----------------------------------------------------------------------------------| -| Maintenance effort | Near zero β€” no types owned | -| Code footprint | Minimal | -| User value | Low β€” user owns JSON serialization, response parsing, and SSE chunk handling | -| API stability | Stable at transport level; unstable at payload level β€” user owns the wire format | +| Aspect | Value | +|------------------------|----------------------------------------------------------------------------------| +| Maintenance effort | Near zero β€” no types owned | +| Codebase size | Minimal | +| User value | Low β€” user owns JSON serialization, response parsing, and SSE chunk handling | +| API stability | Stable at transport level; unstable at payload level β€” user owns the wire format | | Backend replaceability | Virtually impossible β€” user code is tightly coupled to Bedrock wire format| #### Option E: AWS Converse SDK @@ -243,7 +243,7 @@ bedrockClient.converseStream( | Aspect | Value | |------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Maintenance effort | Low for AWS SDK changes β€” typed classes are SDK-owned; but Anthropic-specific features (extended thinking, tool use variants) may lag behind the native Anthropic API | -| Code footprint | Small β€” bridge only: SAP endpoint/credential wiring; no request/response mapping needed | +| Codebase size | Small β€” bridge only: SAP endpoint/credential wiring; no request/response mapping needed | | User value | Medium β€” familiar AWS SDK patterns for teams already using Bedrock; but Anthropic-specific parameters (e.g. `betas`, prompt caching) are not exposed (against the purpose of "foundation models") | | API stability | High β€” AWS Converse API is stable and model-agnostic; Anthropic-specific capabilities limited to what Converse supports | | Backend replaceability | Moderate β€” switching to a non-Bedrock backend requires replacing the AWS SDK dependency; Converse API types are AWS-specific | @@ -260,7 +260,7 @@ Every option receives result as a sum of its aspect values. | Aspect | A: Convenience classes | B: Anthropic SDK + Converse | __C: Anthropic SDK + Invoke__ | D: AWS Invoke SDK | E: AWS Converse SDK | |------------------------|------------------------|-----------------------------|-------------------------------|-------------------|---------------------| | Maintenance effort | High (2) | Very high (1) | __Low (4)__ | Near zero (5) | Low (4) | -| Code footprint | Large (2) | Large (1) | __Small (4)__ | Minimal (5) | Small (4) | +| Codebase size | Large (2) | Large (1) | __Small (4)__ | Minimal (5) | Small (4) | | User value | Moderate (3) | Moderate (3) | __High (5)__ | Low (1) | Moderate (3) | | API stability | Moderate–high (3.5) | Low (1) | __High (5)__ | Low (payload) (1) | Moderate (3) | | Backend replaceability | Hard (1) | Difficult (2) | __Easy (5)__ | Impossible (1) | High (4) | diff --git a/docs/adrs/010-anthropic-client-configuration.md b/docs/adrs/010-anthropic-client-configuration.md index a4e7835b9..3f22f6b58 100644 --- a/docs/adrs/010-anthropic-client-configuration.md +++ b/docs/adrs/010-anthropic-client-configuration.md @@ -62,7 +62,7 @@ and original implementation (just configured correctly to call SAP gateway) | Flexibility | High - we still pre-configure all clients, but users can tweak configs for non-standard use cases | ## Decision -Do nothing (option C) +Do nothing (option C) is proposed. We pre-configure all clients, but users can break things if they explicitly re-configure them From b28c7084c29a67125fbda1325a91348969a889a8 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Thu, 5 Nov 2026 12:59:34 +0100 Subject: [PATCH 16/26] polish documentation --- docs/adrs/008-anthropic-foundation-models.md | 5 +++++ .../com/sap/ai/sdk/app/controllers/AnthropicController.java | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md index 9cc38991d..a90d377dc 100644 --- a/docs/adrs/008-anthropic-foundation-models.md +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -267,6 +267,11 @@ Every option receives result as a sum of its aspect values. | Score | 12.5 | 8 | __23__ | 13 | 18 | ## Consequences, New Dependencies, Open Points, Next Steps +All options are prone to breaking changes from provider SDK. With every option we will have some +technical means to postpone most of the breaking changes (defend our users). This can only be a temporary solution and +eventually breaking changes will have to be reflected in our API. +Otherwise, two APIs would eventually diverge to an unmanageable state. + Further related decisions are to be made: - [Client api subset](./009-anthropic-client-api-subset.md) - [Client configuration](./010-anthropic-client-configuration.md) \ No newline at end of file diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java index 5da34e14f..aa37c0671 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -1,6 +1,8 @@ package com.sap.ai.sdk.app.controllers; +import com.anthropic.models.messages.ContentBlock; import com.anthropic.models.messages.RawMessageStreamEvent; +import com.anthropic.models.messages.TextBlock; import com.anthropic.models.messages.TextDelta; import com.sap.ai.sdk.app.services.AnthropicService; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; @@ -28,8 +30,8 @@ public class AnthropicController { String sendMessage() { final var message = service.sendMessage("What is the capital of France?"); return message.content().stream() - .filter(block -> block.isText()) - .map(block -> block.text().map(t -> t.text()).orElse("")) + .filter(ContentBlock::isText) + .map(block -> block.text().map(TextBlock::text).orElse("")) .reduce("", String::concat); } From 80c58270c332daa5a74a64b7d3f5f95c79f049ed Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Thu, 5 Nov 2026 16:35:06 +0100 Subject: [PATCH 17/26] address pr feedback, fix auth --- .../anthropic/SapAnthropicHttpClient.java | 3 +- .../anthropic/AiCoreAnthropicClientTest.java | 102 +++++------------- .../anthropic/SapAnthropicHttpClientTest.java | 4 +- .../test/resources/chatCompletionRequest.json | 14 +++ .../{__files => }/invokeStreamResponse.txt | 0 .../app/controllers/AnthropicController.java | 7 +- .../ai/sdk/app/controllers/AnthropicTest.java | 14 ++- 7 files changed, 59 insertions(+), 85 deletions(-) create mode 100644 foundation-models/anthropic/src/test/resources/chatCompletionRequest.json rename foundation-models/anthropic/src/test/resources/{__files => }/invokeStreamResponse.txt (100%) diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java index 8a2b08e2b..914f4c2f7 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java @@ -97,7 +97,8 @@ public void close() { private ClassicHttpRequest toApacheRequest( @Nonnull final HttpRequest request, final boolean isStreaming) { final var targetPath = isStreaming ? INVOKE_STREAM_PATH : INVOKE_PATH; - final var apacheRequest = new BasicClassicHttpRequest(request.method().name(), targetPath); + final var targetUri = destination.getUri().resolve(targetPath); + final var apacheRequest = new BasicClassicHttpRequest(request.method().name(), targetUri); for (final var name : request.headers().names()) { if (shouldSkipHeader(name)) { diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index 432d7ee6c..7e63913b0 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -4,6 +4,8 @@ import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; import static com.github.tomakehurst.wiremock.client.WireMock.badRequest; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.notMatching; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.serverError; @@ -26,7 +28,9 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Cache; import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; import java.io.IOException; +import java.io.InputStream; import java.util.Objects; +import java.util.function.Function; import java.util.stream.Collectors; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.core5.http.ContentType; @@ -40,6 +44,8 @@ class AiCoreAnthropicClientTest { private AiCoreAnthropicClient client; + private final Function fileLoader = + filename -> Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream(filename)); @BeforeEach void setup(final WireMockRuntimeInfo server) { @@ -67,7 +73,7 @@ void testAnthropicModels() { } @Test - void testChatCompletion() { + void testChatCompletion() throws IOException { stubFor( post(urlPathEqualTo("/invoke")) .willReturn( @@ -78,10 +84,21 @@ void testChatCompletion() { final var params = MessageCreateParams.builder() .model("claude-3-haiku-20240307") - .maxTokens(256) + .maxTokens(100) + .addSystemMessage("You are a helpful assistant.") .addUserMessage("Hello!") .build(); + client.messages().create(params); + + try (var requestStream = fileLoader.apply("chatCompletionRequest.json")) { + final var expectedRequest = new String(requestStream.readAllBytes()); + verify( + postRequestedFor(urlPathEqualTo("/invoke")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(expectedRequest))); + } + final var response = client.messages().create(params); assertThat(response).isNotNull(); @@ -94,39 +111,6 @@ void testChatCompletion() { assertThat(response.usage().outputTokens()).isEqualTo(9); } - @Test - void testChatCompletionRequestFormat() { - stubFor( - post(urlPathEqualTo("/invoke")) - .willReturn( - aResponse() - .withBodyFile("invokeResponse.json") - .withHeader("Content-Type", "application/json"))); - - final var params = - MessageCreateParams.builder() - .model("claude-3-haiku-20240307") - .maxTokens(100) - .addSystemMessage("You are a helpful assistant.") - .addUserMessage("What is 2+2?") - .build(); - - client.messages().create(params); - - verify( - postRequestedFor(urlPathEqualTo("/invoke")) - .withHeader("Content-Type", equalTo("application/json")) - .withRequestBody( - com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( - "$.anthropic_version", equalTo("bedrock-2023-05-31"))) - .withRequestBody( - com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( - "$.messages[1].role", equalTo("user"))) - .withRequestBody( - com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( - "$.messages[0].content", equalTo("You are a helpful assistant.")))); - } - @Test void testModelFieldRemovedFromRequest() { stubFor( @@ -146,25 +130,21 @@ void testModelFieldRemovedFromRequest() { client.messages().create(params); verify( - postRequestedFor(urlPathEqualTo("/invoke")) - .withRequestBody( - com.github.tomakehurst.wiremock.client.WireMock.notMatching(".*\"model\".*"))); + postRequestedFor(urlPathEqualTo("/invoke")).withRequestBody(notMatching(".*\"model\".*"))); } @Test void testStreamChatCompletion() throws IOException { - final var inputStream = - Objects.requireNonNull( - getClass().getClassLoader().getResourceAsStream("__files/invokeStreamResponse.txt")); - final var httpClient = mock(HttpClient.class); ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); final var mockResponse = new BasicClassicHttpResponse(200, "OK"); - final var entity = new InputStreamEntity(inputStream, ContentType.TEXT_EVENT_STREAM); - mockResponse.setEntity(entity); + mockResponse.setEntity( + new InputStreamEntity( + Objects.requireNonNull( + getClass().getClassLoader().getResourceAsStream("invokeStreamResponse.txt")), + ContentType.TEXT_EVENT_STREAM)); mockResponse.setHeader("Content-Type", "text/event-stream"); - doReturn(mockResponse).when(httpClient).executeOpen(any(), any(), any()); final var params = @@ -175,8 +155,10 @@ void testStreamChatCompletion() throws IOException { .build(); try (var stream = client.messages().createStreaming(params)) { + final var events = stream.stream().collect(Collectors.toList()); + final var chunks = - stream.stream() + events.stream() .filter(e -> e.isContentBlockDelta()) .map(e -> e.asContentBlockDelta()) .map(RawContentBlockDeltaEvent::delta) @@ -185,35 +167,9 @@ void testStreamChatCompletion() throws IOException { .collect(Collectors.toList()); assertThat(chunks).containsExactly("Hello", "!", " How can I", " assist", " you today?"); - } - } - @Test - void testStreamChatCompletionReturnsStopReason() throws IOException { - final var inputStream = - Objects.requireNonNull( - getClass().getClassLoader().getResourceAsStream("__files/invokeStreamResponse.txt")); - - final var httpClient = mock(HttpClient.class); - ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); - - final var mockResponse = new BasicClassicHttpResponse(200, "OK"); - final var entity = new InputStreamEntity(inputStream, ContentType.TEXT_EVENT_STREAM); - mockResponse.setEntity(entity); - mockResponse.setHeader("Content-Type", "text/event-stream"); - - doReturn(mockResponse).when(httpClient).executeOpen(any(), any(), any()); - - final var params = - MessageCreateParams.builder() - .model("claude-3-haiku-20240307") - .maxTokens(256) - .addUserMessage("Hello!") - .build(); - - try (var stream = client.messages().createStreaming(params)) { final var stopReason = - stream.stream() + events.stream() .filter(e -> e.isMessageDelta()) .map(e -> e.asMessageDelta().delta().stopReason()) .filter(r -> r.isPresent()) diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java index 836f4b4f5..bf6786444 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java @@ -183,9 +183,7 @@ void testStreamingResponseCloseSwallowsIoException() throws IOException { final var brokenStream = spy( Objects.requireNonNull( - getClass() - .getClassLoader() - .getResourceAsStream("__files/invokeStreamResponse.txt"))); + getClass().getClassLoader().getResourceAsStream("invokeStreamResponse.txt"))); final var mockApacheResponse = mock(ClassicHttpResponse.class); doReturn(200).when(mockApacheResponse).getCode(); diff --git a/foundation-models/anthropic/src/test/resources/chatCompletionRequest.json b/foundation-models/anthropic/src/test/resources/chatCompletionRequest.json new file mode 100644 index 000000000..aad55d845 --- /dev/null +++ b/foundation-models/anthropic/src/test/resources/chatCompletionRequest.json @@ -0,0 +1,14 @@ +{ + "max_tokens": 100, + "messages": [ + { + "content": "You are a helpful assistant.", + "role": "system" + }, + { + "content": "Hello!", + "role": "user" + } + ], + "anthropic_version": "bedrock-2023-05-31" +} diff --git a/foundation-models/anthropic/src/test/resources/__files/invokeStreamResponse.txt b/foundation-models/anthropic/src/test/resources/invokeStreamResponse.txt similarity index 100% rename from foundation-models/anthropic/src/test/resources/__files/invokeStreamResponse.txt rename to foundation-models/anthropic/src/test/resources/invokeStreamResponse.txt diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java index aa37c0671..edad27df7 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -1,8 +1,9 @@ package com.sap.ai.sdk.app.controllers; +import static java.util.stream.Collectors.joining; + import com.anthropic.models.messages.ContentBlock; import com.anthropic.models.messages.RawMessageStreamEvent; -import com.anthropic.models.messages.TextBlock; import com.anthropic.models.messages.TextDelta; import com.sap.ai.sdk.app.services.AnthropicService; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; @@ -31,8 +32,8 @@ String sendMessage() { final var message = service.sendMessage("What is the capital of France?"); return message.content().stream() .filter(ContentBlock::isText) - .map(block -> block.text().map(TextBlock::text).orElse("")) - .reduce("", String::concat); + .map(b -> b.asText().text()) + .collect(joining()); } /** Initiate new chat by sending message to Anthropic Claude. Stream response. */ diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java index 59c8a6970..504fbc525 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java @@ -1,7 +1,11 @@ package com.sap.ai.sdk.app.controllers; +import static java.util.stream.Collectors.joining; import static org.assertj.core.api.Assertions.assertThat; +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.RawContentBlockDelta; +import com.anthropic.models.messages.RawMessageStreamEvent; import com.anthropic.models.messages.StopReason; import com.sap.ai.sdk.app.services.AnthropicService; import java.util.concurrent.atomic.AtomicReference; @@ -18,9 +22,9 @@ void chatCompletion() { assertThat(message.content()).isNotEmpty(); final var text = message.content().stream() - .filter(b -> b.isText()) - .map(b -> b.text().map(t -> t.text()).orElse("")) - .reduce("", String::concat); + .filter(ContentBlock::isText) + .map(b -> b.asText().text()) + .collect(joining()); assertThat(text).containsIgnoringCase("paris"); assertThat(message.stopReason()).hasValue(StopReason.END_TURN); assertThat(message.usage().outputTokens()).isGreaterThan(0); @@ -32,9 +36,9 @@ void streamChatCompletion() { try (var stream = service.sendMessageStreamResponse("What is the capital of France?")) { stream.stream() - .filter(event -> event.isContentBlockDelta()) + .filter(RawMessageStreamEvent::isContentBlockDelta) .map(event -> event.asContentBlockDelta().delta()) - .filter(delta -> delta.isText()) + .filter(RawContentBlockDelta::isText) .map(delta -> delta.asText().text()) .forEach(text -> fullResponse.accumulateAndGet(text, String::concat)); } From 024201729dc098482d4599579d784d83feecdf33 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Thu, 5 Nov 2026 18:26:31 +0100 Subject: [PATCH 18/26] fix wrong comment --- .../sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java index 914f4c2f7..5f32c2ee8 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java @@ -46,7 +46,7 @@ @Slf4j @RequiredArgsConstructor(access = AccessLevel.PACKAGE) @SuppressWarnings( - "PMD.CloseResource") // client lifecycle is managed by its user, should be explicitly closed + "PMD.CloseResource") // Apache HttpClient lifecycle is managed by Cloud SDK's ApacheHttpClient5Cache class SapAnthropicHttpClient implements HttpClient { private static final String INVOKE_PATH = "invoke"; From f69ddaa87ad1cd944c49c6a4ae655d75780d200c Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Thu, 5 Nov 2026 17:27:38 +0000 Subject: [PATCH 19/26] Formatting --- .../foundationmodels/anthropic/SapAnthropicHttpClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java index 5f32c2ee8..f29ecbc4f 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java @@ -45,8 +45,8 @@ */ @Slf4j @RequiredArgsConstructor(access = AccessLevel.PACKAGE) -@SuppressWarnings( - "PMD.CloseResource") // Apache HttpClient lifecycle is managed by Cloud SDK's ApacheHttpClient5Cache +@SuppressWarnings("PMD.CloseResource") // Apache HttpClient lifecycle is managed by Cloud SDK's +// ApacheHttpClient5Cache class SapAnthropicHttpClient implements HttpClient { private static final String INVOKE_PATH = "invoke"; From 94852283433f0b21882cb407de79d9d720c05632 Mon Sep 17 00:00:00 2001 From: vladimir-a-sap Date: Fri, 6 Nov 2026 11:47:34 +0100 Subject: [PATCH 20/26] Apply batched suggestions from code review Co-authored-by: Charles Dubois <103174266+CharlesDuboisSAP@users.noreply.github.com> --- .../anthropic/AiCoreAnthropicClient.java | 2 +- .../anthropic/AiCoreAnthropicClientTest.java | 18 +++++++++--------- .../ai/sdk/app/controllers/AnthropicTest.java | 1 - 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java index 44c27606c..8b333ba43 100644 --- a/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java @@ -80,7 +80,7 @@ static AiCoreAnthropicClient forDestination(@Nonnull final HttpDestination desti */ @Nonnull public MessageService messages() { - return forDestination(destination).client().messages(); + return client().messages(); } /** diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index 7e63913b0..e37c3de8d 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -105,7 +105,7 @@ void testChatCompletion() throws IOException { assertThat(response.id()).isEqualTo("msg_01XFDUDYJgAACzvnptvVoYEL"); assertThat(response.stopReason()).hasValue(StopReason.END_TURN); assertThat(response.content()).hasSize(1); - assertThat(response.content().get(0).text().map(t -> t.text())) + assertThat(response.content().get(0).text().map(TextBlock::text)) .hasValue("Hello! How can I assist you today?"); assertThat(response.usage().inputTokens()).isEqualTo(10); assertThat(response.usage().outputTokens()).isEqualTo(9); @@ -155,25 +155,25 @@ void testStreamChatCompletion() throws IOException { .build(); try (var stream = client.messages().createStreaming(params)) { - final var events = stream.stream().collect(Collectors.toList()); + final var events = stream.stream().toList(); final var chunks = events.stream() - .filter(e -> e.isContentBlockDelta()) - .map(e -> e.asContentBlockDelta()) + .filter(RawMessageStreamEvent::isContentBlockDelta) + .map(RawMessageStreamEvent::asContentBlockDelta) .map(RawContentBlockDeltaEvent::delta) - .filter(d -> d.isText()) + .filter(RawContentBlockDelta::isText) .map(d -> d.asText().text()) - .collect(Collectors.toList()); + .toList(); assertThat(chunks).containsExactly("Hello", "!", " How can I", " assist", " you today?"); final var stopReason = events.stream() - .filter(e -> e.isMessageDelta()) + .filter(RawMessageStreamEvent::isMessageDelta) .map(e -> e.asMessageDelta().delta().stopReason()) - .filter(r -> r.isPresent()) - .map(r -> r.get()) + .filter(Optional::isPresent) + .map(Optional::get) .findFirst(); assertThat(stopReason).hasValue(StopReason.END_TURN); diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java index 504fbc525..66c19a86b 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java @@ -18,7 +18,6 @@ class AnthropicTest { void chatCompletion() { final var message = service.sendMessage("What is the capital of France?"); - assertThat(message).isNotNull(); assertThat(message.content()).isNotEmpty(); final var text = message.content().stream() From 1e263f0c37129debd47bf5f85f14598cd91b9c18 Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Fri, 6 Nov 2026 10:48:19 +0000 Subject: [PATCH 21/26] Formatting --- .../foundationmodels/anthropic/AiCoreAnthropicClientTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index e37c3de8d..813feb121 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -31,7 +31,6 @@ import java.io.InputStream; import java.util.Objects; import java.util.function.Function; -import java.util.stream.Collectors; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.InputStreamEntity; From 2b97cc0780db3ce1d100db9f5e03c981caf4cbb2 Mon Sep 17 00:00:00 2001 From: vladimir-a-sap Date: Fri, 6 Nov 2026 12:07:17 +0100 Subject: [PATCH 22/26] Update docs/adrs/010-anthropic-client-configuration.md Co-authored-by: Jonas-Isr --- docs/adrs/010-anthropic-client-configuration.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adrs/010-anthropic-client-configuration.md b/docs/adrs/010-anthropic-client-configuration.md index 3f22f6b58..5cd03e330 100644 --- a/docs/adrs/010-anthropic-client-configuration.md +++ b/docs/adrs/010-anthropic-client-configuration.md @@ -50,9 +50,10 @@ operations exactly as they are in the original interface but not exposing the `. | Safety provided | Maximum - we can configure and enforce any safety guarantees and configuration we need | | Flexibility | Minimum - we can cut off many non standard use cases we cannot foresee or verify now (e.g. usage with proxies and internet gateways) | -#### Option C: Do nothing +#### Option C: No override, document warning We do nothing about configuration. We expose Anthropic sub-api (e.g. `messages`) with its original unaltered interface -and original implementation (just configured correctly to call SAP gateway) +and original implementation (just configured correctly to call SAP gateway). +We document clearly to the users which parts of the configuration they should not change. | Aspect | Value | |-------------------------|---------------------------------------------------------------------------------------------------| From 8ccd054f617ec3312b4a3f61c7ddc3a8fe9d6084 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Fri, 6 Nov 2026 14:15:17 +0100 Subject: [PATCH 23/26] address pr feedback --- .../anthropic/AiCoreAnthropicClientTest.java | 4 +++ .../app/controllers/AnthropicController.java | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java index 813feb121..adb02112c 100644 --- a/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -20,8 +20,11 @@ import com.anthropic.errors.AnthropicException; import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.RawContentBlockDelta; import com.anthropic.models.messages.RawContentBlockDeltaEvent; +import com.anthropic.models.messages.RawMessageStreamEvent; import com.anthropic.models.messages.StopReason; +import com.anthropic.models.messages.TextBlock; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; @@ -30,6 +33,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.core5.http.ContentType; diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java index edad27df7..915ddb5e3 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -6,6 +6,7 @@ import com.anthropic.models.messages.RawMessageStreamEvent; import com.anthropic.models.messages.TextDelta; import com.sap.ai.sdk.app.services.AnthropicService; +import com.sap.ai.sdk.core.common.ClientException; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; import javax.annotation.Nonnull; @@ -50,15 +51,22 @@ ResponseEntity sendMessageStreamResponse() { stream.stream() .filter(RawMessageStreamEvent::isContentBlockDelta) .forEach( - deltaMessage -> - send( - emitter, - deltaMessage - .asContentBlockDelta() - .delta() - .text() - .orElseGet(() -> TextDelta.of("")) - .text())); + deltaMessage -> { + send( + emitter, + deltaMessage + .asContentBlockDelta() + .delta() + .text() + .orElseGet(() -> TextDelta.of("")) + .text()); + try { + Thread.sleep( + 20); // adding artificial slowdown to demonstrate api actually streams + } catch (InterruptedException e) { + throw new ClientException(e); + } + }); } finally { emitter.complete(); } From 28c951d9af166c478beb5c3c5d0e95e8db9fde05 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Fri, 6 Nov 2026 15:01:25 +0100 Subject: [PATCH 24/26] address PR feedback --- docs/adrs/008-anthropic-foundation-models.md | 6 ++++ docs/adrs/009-anthropic-client-api-subset.md | 10 +++++- .../010-anthropic-client-configuration.md | 32 ++++++++++++++----- .../app/controllers/AnthropicController.java | 30 +++++++++++++---- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md index a90d377dc..844c3dd5a 100644 --- a/docs/adrs/008-anthropic-foundation-models.md +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -254,6 +254,12 @@ Official Anthropic Java SDK with Invoke AWS Bedrock Backend (Option C) is propos ## Rationale +Team held multiple discussions on the topic and came to conclusion that Option C represent the most balanced approach. +Option C is best aligned with our resources, AI-SDK strategy and foundation-models API positioning (lower-level feature +access to provider functionality) + +Additional supporting evidence with aspect analysis for every option is given below. + Every aspect value is scored between 1 and 5 points Every option receives result as a sum of its aspect values. diff --git a/docs/adrs/009-anthropic-client-api-subset.md b/docs/adrs/009-anthropic-client-api-subset.md index 3f3e9431f..dc11dc20e 100644 --- a/docs/adrs/009-anthropic-client-api-subset.md +++ b/docs/adrs/009-anthropic-client-api-subset.md @@ -65,9 +65,17 @@ MessagesService messagesApi = client.messages(); | Flexibility | Maximum - if we expose entire official Anthropics client "as is" we barely have any control on the operations level unless we reimplement it ourselves | ## Decision -Expose `messages` sub-api, proceed with other APIs on demand (Option B) is proposed. + +Expose `messages` sub-api, proceed with other APIs gradually added until all features supported by SAP gateway are covered. ## Rationale + +Option A (expose original client fully) was discarded mainly because of it relative inflexibility. If SAP gateway +won't support some of the original APIs functions, with full original client exposure AI SDK could not limit these +unsupported API functions. + +Additional supporting evidence with aspect analysis for every option is given below. + Every aspect value is scored between 1 and 5 points Every option receives result as a sum of its aspect values. diff --git a/docs/adrs/010-anthropic-client-configuration.md b/docs/adrs/010-anthropic-client-configuration.md index 5cd03e330..ed781a172 100644 --- a/docs/adrs/010-anthropic-client-configuration.md +++ b/docs/adrs/010-anthropic-client-configuration.md @@ -63,21 +63,37 @@ We document clearly to the users which parts of the configuration they should no | Flexibility | High - we still pre-configure all clients, but users can tweak configs for non-standard use cases | ## Decision -Do nothing (option C) is proposed. +No override (option C) is proposed. We pre-configure all clients, but users can break things if they explicitly re-configure them ## Rationale + +Options with override were discarded as they bloat codebase, increase maintenance effort not providing (or even +limiting) user value. + +Potential use cases of AI SDK may include network configurations with DMZ and internet gateways or additional proxies. +For such cases API URL customization might be needed. Hiding this option in our SDK would make such use cases +virtually impossible. + +Overriding does not provide full safety either. At least some of the configuration will have to be exposed. Even +with a limited configuration being exposed, there is always a possibility to misconfigure SDK client. + +Given the above, override options were discarded in favor of "no override" (option C) and addition of warning +in documentation. + +Additional supporting evidence with aspect analysis for every option is given below. + Every aspect value is scored between 1 and 5 points Every option receives result as a sum of its aspect values. -| Aspect | A: Override implementation | B: Override interface | __C: Do nothing__ | -|-----------------------|----------------------------|-----------------------|-------------------| -| Implementation effort | Maximum (1) | Medium (3) | __None (5)__ | -| Maintenance effort | Maximum (1) | Medium (3) | __Minimum (5)__ | -| Safety provided | Maximum (5) | Maximum (5) | __Medium (3)__ | -| Flexibility | Minimum (1) | Minimum (1) | __High (4)__ | -| Score | 8 | 12 | __17__ | +| Aspect | A: Override implementation | B: Override interface | __C: No override__ | +|-----------------------|----------------------------|-----------------------|--------------------| +| Implementation effort | Maximum (1) | Medium (3) | __None (5)__ | +| Maintenance effort | Maximum (1) | Medium (3) | __Minimum (5)__ | +| Safety provided | Maximum (5) | Maximum (5) | __Medium (3)__ | +| Flexibility | Minimum (1) | Minimum (1) | __High (4)__ | +| Score | 8 | 12 | __17__ | ## Consequences, New Dependencies, Open Points, Next Steps diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java index 915ddb5e3..d07c079c5 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -1,20 +1,26 @@ package com.sap.ai.sdk.app.controllers; +import static com.openai.core.ObjectMappers.jsonMapper; import static java.util.stream.Collectors.joining; import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.Message; import com.anthropic.models.messages.RawMessageStreamEvent; import com.anthropic.models.messages.TextDelta; +import com.fasterxml.jackson.core.JsonProcessingException; import com.sap.ai.sdk.app.services.AnthropicService; import com.sap.ai.sdk.core.common.ClientException; import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors; import java.io.IOException; import javax.annotation.Nonnull; +import javax.annotation.Nullable; + import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; @@ -26,15 +32,19 @@ public class AnthropicController { @Autowired private AnthropicService service; - /** Initiate new chat by sending message to Anthropic Claude. */ + /** + * Initiate new chat by sending message to Anthropic Claude. + */ @GetMapping("/anthropic/sendMessage") @Nonnull - String sendMessage() { + Object sendMessage(@Nullable @RequestParam(value = "format", required = false) final String format) throws JsonProcessingException { final var message = service.sendMessage("What is the capital of France?"); - return message.content().stream() - .filter(ContentBlock::isText) - .map(b -> b.asText().text()) - .collect(joining()); + if ("json".equals(format)) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(jsonMapper().writeValueAsString(message)); + } + return extractText(message); } /** Initiate new chat by sending message to Anthropic Claude. Stream response. */ @@ -92,4 +102,12 @@ public static void send(@Nonnull final ResponseBodyEmitter emitter, @Nonnull fin emitter.completeWithError(e); } } + + @Nonnull + private static String extractText(@Nonnull final Message message) { + return message.content().stream() + .filter(ContentBlock::isText) + .map(b -> b.asText().text()) + .collect(joining()); + } } From 90a7b00fbe8dc5e2e339d0b9421a75f47b2f1656 Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Fri, 6 Nov 2026 14:02:24 +0000 Subject: [PATCH 25/26] Formatting --- .../app/controllers/AnthropicController.java | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java index d07c079c5..0dae27a13 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -14,7 +14,6 @@ import java.io.IOException; import javax.annotation.Nonnull; import javax.annotation.Nullable; - import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -32,18 +31,18 @@ public class AnthropicController { @Autowired private AnthropicService service; - /** - * Initiate new chat by sending message to Anthropic Claude. - */ + /** Initiate new chat by sending message to Anthropic Claude. */ @GetMapping("/anthropic/sendMessage") @Nonnull - Object sendMessage(@Nullable @RequestParam(value = "format", required = false) final String format) throws JsonProcessingException { + Object sendMessage( + @Nullable @RequestParam(value = "format", required = false) final String format) + throws JsonProcessingException { final var message = service.sendMessage("What is the capital of France?"); - if ("json".equals(format)) { - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body(jsonMapper().writeValueAsString(message)); - } + if ("json".equals(format)) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(jsonMapper().writeValueAsString(message)); + } return extractText(message); } @@ -103,11 +102,11 @@ public static void send(@Nonnull final ResponseBodyEmitter emitter, @Nonnull fin } } - @Nonnull - private static String extractText(@Nonnull final Message message) { - return message.content().stream() - .filter(ContentBlock::isText) - .map(b -> b.asText().text()) - .collect(joining()); - } + @Nonnull + private static String extractText(@Nonnull final Message message) { + return message.content().stream() + .filter(ContentBlock::isText) + .map(b -> b.asText().text()) + .collect(joining()); + } } From 3f5fbde8f5a31056a6a1fed367d3d2925efb59b2 Mon Sep 17 00:00:00 2001 From: Vladimir Aseev Date: Fri, 6 Nov 2026 15:19:38 +0100 Subject: [PATCH 26/26] add Anthropic scope to pr-lint.yaml --- .github/workflows/pr-lint.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr-lint.yaml b/.github/workflows/pr-lint.yaml index ce7b350c8..09b438c58 100644 --- a/.github/workflows/pr-lint.yaml +++ b/.github/workflows/pr-lint.yaml @@ -22,6 +22,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: scopes: | + Anthropic Core OpenAI Orchestration