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 diff --git a/docs/adrs/008-anthropic-foundation-models.md b/docs/adrs/008-anthropic-foundation-models.md new file mode 100644 index 000000000..844c3dd5a --- /dev/null +++ b/docs/adrs/008-anthropic-foundation-models.md @@ -0,0 +1,283 @@ +# 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 model classes. 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 model classes. 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: + +![](images/option_a.drawio.svg) + +```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 | +| 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 | + + +#### Option B: Anthropic SDK with AWS Converse + +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 +Message response = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .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) + .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 | +| 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 | + +#### 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_c.drawio.svg) + +```java +// Chat completion +Message response = + AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .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).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 | +| 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 | + +#### 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 | +| 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 + +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. +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) + +```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 | +| 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 | + + +## Decision +Official Anthropic Java SDK with Invoke AWS Bedrock Backend (Option C) is proposed. + +## 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. + +| 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) | +| 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) | +| 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/docs/adrs/009-anthropic-client-api-subset.md b/docs/adrs/009-anthropic-client-api-subset.md new file mode 100644 index 000000000..dc11dc20e --- /dev/null +++ b/docs/adrs/009-anthropic-client-api-subset.md @@ -0,0 +1,94 @@ +# 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 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. + +| 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 +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 new file mode 100644 index 000000000..ed781a172 --- /dev/null +++ b/docs/adrs/010-anthropic-client-configuration.md @@ -0,0 +1,99 @@ +# 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: 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). +We document clearly to the users which parts of the configuration they should not change. + +| 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 +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: 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/docs/adrs/images/option_a.drawio.svg b/docs/adrs/images/option_a.drawio.svg new file mode 100644 index 000000000..96105c09d --- /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
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 new file mode 100644 index 000000000..57fb802a3 --- /dev/null +++ b/docs/adrs/images/option_b.drawio.svg @@ -0,0 +1,4 @@ + + + +
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 new file mode 100644 index 000000000..b49e063ac --- /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
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 new file mode 100644 index 000000000..081caeb65 --- /dev/null +++ b/docs/adrs/images/option_d.drawio.svg @@ -0,0 +1,4 @@ + + + +
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 new file mode 100644 index 000000000..8af801427 --- /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
calls (/converse)
Intercepts/tweaks
SAP Gateway
  • Authorizes
  • Facilitates
  • Rate limits
  • Routes
AWS Bedrock
\ No newline at end of file diff --git a/docs/release_notes.md b/docs/release_notes.md index 123ca098e..3c6c5c9dc 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -12,6 +12,7 @@ ### ✨ New Functionality +- [Anthropic] Added `AiCoreAnthropicClient` for `messages()`. - [Orchestration] Added new model `SAP_ABAP_2` to `OrchestrationAiModel`. ### 📈 Improvements diff --git a/foundation-models/anthropic/pom.xml b/foundation-models/anthropic/pom.xml new file mode 100644 index 000000000..7ecc9ad62 --- /dev/null +++ b/foundation-models/anthropic/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + com.sap.ai.sdk + sdk-parent + 2.1.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 Anthropic Claude client 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 + + + com.anthropic + anthropic-java-core + + + org.apache.httpcomponents.core5 + httpcore5 + + + com.fasterxml.jackson.core + jackson-databind + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-core + + + 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/AiCoreAnthropicClient.java b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java new file mode 100644 index 000000000..8b333ba43 --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClient.java @@ -0,0 +1,104 @@ +package com.sap.ai.sdk.foundationmodels.anthropic; + +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; +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); + } + + /** + * Returns configured client for Anthropic messages API. + * + * @return client implementation + */ + @Nonnull + public MessageService messages() { + return client().messages(); + } + + /** + * 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 + private 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/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/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..f29ecbc4f --- /dev/null +++ b/foundation-models/anthropic/src/main/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClient.java @@ -0,0 +1,277 @@ +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") // Apache HttpClient lifecycle is managed by Cloud SDK's +// ApacheHttpClient5Cache +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 targetUri = destination.getUri().resolve(targetPath); + final var apacheRequest = new BasicClassicHttpRequest(request.method().name(), targetUri); + + 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/AiCoreAnthropicClientTest.java b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java new file mode 100644 index 000000000..adb02112c --- /dev/null +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/AiCoreAnthropicClientTest.java @@ -0,0 +1,236 @@ +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.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; +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 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; +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.Optional; +import java.util.function.Function; +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 AiCoreAnthropicClientTest { + + private AiCoreAnthropicClient client; + private final Function fileLoader = + filename -> Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream(filename)); + + @BeforeEach + void setup(final WireMockRuntimeInfo server) { + final var destination = DefaultHttpDestination.builder(server.getHttpBaseUrl()).build(); + client = AiCoreAnthropicClient.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() throws IOException { + 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("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(); + 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(TextBlock::text)) + .hasValue("Hello! How can I assist you today?"); + assertThat(response.usage().inputTokens()).isEqualTo(10); + assertThat(response.usage().outputTokens()).isEqualTo(9); + } + + @Test + void testModelFieldRemovedFromRequest() { + 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(256) + .addUserMessage("Hello!") + .build(); + + client.messages().create(params); + + verify( + postRequestedFor(urlPathEqualTo("/invoke")).withRequestBody(notMatching(".*\"model\".*"))); + } + + @Test + void testStreamChatCompletion() throws IOException { + final var httpClient = mock(HttpClient.class); + ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); + + final var mockResponse = new BasicClassicHttpResponse(200, "OK"); + 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 = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); + + try (var stream = client.messages().createStreaming(params)) { + final var events = stream.stream().toList(); + + final var chunks = + events.stream() + .filter(RawMessageStreamEvent::isContentBlockDelta) + .map(RawMessageStreamEvent::asContentBlockDelta) + .map(RawContentBlockDeltaEvent::delta) + .filter(RawContentBlockDelta::isText) + .map(d -> d.asText().text()) + .toList(); + + assertThat(chunks).containsExactly("Hello", "!", " How can I", " assist", " you today?"); + + final var stopReason = + events.stream() + .filter(RawMessageStreamEvent::isMessageDelta) + .map(e -> e.asMessageDelta().delta().stopReason()) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + + assertThat(stopReason).hasValue(StopReason.END_TURN); + } + } + + @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( + post(anyUrl()) + .willReturn( + badRequest() + .withBody( + """ + {"type":"error","error":{"type":"invalid_request_error","message":"field_x is required"}} + """) + .withHeader("Content-Type", "application/json"))); + + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); + + assertThatThrownBy(() -> client.messages().create(params)) + .isInstanceOf(AnthropicException.class) + .hasMessageContaining("400") + .hasMessageContaining("field_x is required"); + } + + @Test + void testServerError() { + stubFor(post(anyUrl()).willReturn(serverError())); + + final var params = + MessageCreateParams.builder() + .model("claude-3-haiku-20240307") + .maxTokens(256) + .addUserMessage("Hello!") + .build(); + + assertThatThrownBy(() -> client.messages().create(params)) + .isInstanceOf(AnthropicException.class) + .hasMessageContaining("500"); + } +} 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..bf6786444 --- /dev/null +++ b/foundation-models/anthropic/src/test/java/com/sap/ai/sdk/foundationmodels/anthropic/SapAnthropicHttpClientTest.java @@ -0,0 +1,205 @@ +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("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/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/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/invokeStreamResponse.txt b/foundation-models/anthropic/src/test/resources/invokeStreamResponse.txt new file mode 100644 index 000000000..2b27d9aef --- /dev/null +++ b/foundation-models/anthropic/src/test/resources/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 7acb30835..94146dafd 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 @@ -50,6 +51,7 @@ + 2.72.0 ${project.basedir} 17 17 @@ -89,6 +91,7 @@ 1.0.1 3.0.8 3.3.1 + 2.2.50 1.86 2.4.21 @@ -141,6 +144,11 @@ import + + com.anthropic + anthropic-java-core + ${anthropic-sdk.version} + io.projectreactor reactor-core @@ -181,6 +189,12 @@ jackson-datatype-jsr310 ${jackson.version} + + + io.swagger.core.v3 + swagger-annotations + ${swagger-annotations.version} + tools.jackson.core jackson-databind @@ -369,6 +383,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 11190a333..f9ece0af7 100644 --- a/sample-code/spring-app/pom.xml +++ b/sample-code/spring-app/pom.xml @@ -139,6 +139,14 @@ com.sap.ai.sdk.foundationmodels sap-rpt + + 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 new file mode 100644 index 000000000..0dae27a13 --- /dev/null +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/AnthropicController.java @@ -0,0 +1,112 @@ +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; + +/** Endpoints for Anthropic Claude operations. */ +@Slf4j +@RestController +@SuppressWarnings("unused") +public class AnthropicController { + + @Autowired private AnthropicService service; + + /** 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 { + 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)); + } + return extractText(message); + } + + /** Initiate new chat by sending message to Anthropic Claude. Stream response. */ + @GetMapping("/anthropic/sendMessageStreamResponse") + @Nonnull + ResponseEntity sendMessageStreamResponse() { + final var message = "Can you give me the first 100 numbers of the Fibonacci sequence?"; + ; + final var emitter = new ResponseBodyEmitter(); + 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()); + try { + Thread.sleep( + 20); // adding artificial slowdown to demonstrate api actually streams + } catch (InterruptedException e) { + throw new ClientException(e); + } + }); + } 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); + } + } + + @Nonnull + private static String extractText(@Nonnull final Message message) { + return message.content().stream() + .filter(ContentBlock::isText) + .map(b -> b.asText().text()) + .collect(joining()); + } +} 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..1141d736e --- /dev/null +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AnthropicService.java @@ -0,0 +1,46 @@ +package com.sap.ai.sdk.app.services; + +import static com.sap.ai.sdk.foundationmodels.anthropic.AnthropicModel.CLAUDE_45_HAIKU; + +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 class for Anthropic service using latest convenience api. */ +@Service +public class AnthropicService { + + private static final MessageCreateParams.Builder BASE_PARAMS = + MessageCreateParams.builder().model(CLAUDE_45_HAIKU.name()).maxTokens(1000); + + /** + * 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 sendMessage(@Nonnull final String userMessage) { + return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .messages() + .create(BASE_PARAMS.addUserMessage(userMessage).build()); + } + + /** + * Asynchronous stream of an OpenAI chat request. + * + * @param userMessage the message to send to the assistant. + * @return the emitter that streams the assistant message response. + */ + @Nonnull + public StreamResponse sendMessageStreamResponse( + @Nonnull final String userMessage) { + return AiCoreAnthropicClient.forModel(CLAUDE_45_HAIKU) + .messages() + .createStreaming(BASE_PARAMS.addUserMessage(userMessage).build()); + } +} 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..af8cca18d 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 +
+
    +
  • +
    + +
    + Initiate new chat by sending message to Anthropic Claude. +
    +
    +
  • +
  • +
    + +
    + Initiate new chat by sending message to Anthropic Claude. Stream response. +
    +
    +
  • +
+
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..66c19a86b --- /dev/null +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AnthropicTest.java @@ -0,0 +1,47 @@ +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; +import org.junit.jupiter.api.Test; + +class AnthropicTest { + final AnthropicService service = new AnthropicService(); + + @Test + void chatCompletion() { + final var message = service.sendMessage("What is the capital of France?"); + + assertThat(message.content()).isNotEmpty(); + final var text = + message.content().stream() + .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); + } + + @Test + void streamChatCompletion() { + final var fullResponse = new AtomicReference<>(""); + + try (var stream = service.sendMessageStreamResponse("What is the capital of France?")) { + stream.stream() + .filter(RawMessageStreamEvent::isContentBlockDelta) + .map(event -> event.asContentBlockDelta().delta()) + .filter(RawContentBlockDelta::isText) + .map(delta -> delta.asText().text()) + .forEach(text -> fullResponse.accumulateAndGet(text, String::concat)); + } + + assertThat(fullResponse.get()).containsIgnoringCase("paris"); + } +}