Skip to content

Commit a8ff4c6

Browse files
Merge branch 'main' into spec-update/orchestration/fix/streaming-response-type
# Conflicts: # docs/release_notes.md # orchestration/pom.xml # orchestration/src/main/java/com/sap/ai/sdk/orchestration/JacksonMixins.java # sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/OrchestrationTest.java
2 parents 4d15b91 + 49cdbbb commit a8ff4c6

30 files changed

+364
-179
lines changed

.github/workflows/e2e-test.yaml

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,25 @@ jobs:
3939
mvn $MVN_ARGS
4040
4141
- name: "Run tests"
42+
id: run_tests
4243
run: |
4344
MVN_ARGS="${{ env.MVN_MULTI_THREADED_ARGS }} surefire:test -pl :spring-app -DskipTests=false"
44-
mvn $MVN_ARGS "-Daicore.landscape=${{ matrix.environment }}"
45+
mvn $MVN_ARGS "-Daicore.landscape=${{ matrix.environment }}" | tee mvn_output.log # tee writes to both the console and a file
46+
47+
awk '/Results:/, /----/' mvn_output.log > test_error.log || true # true ensures the step doesn't fail if no match is found.
48+
ERROR_MSG=$(cat test_error.log | tail +4 | head -n -4 | awk '{gsub(/"/, "\\\""); printf "%s\\n", $0}') # Slack formatting
49+
echo "$ERROR_MSG"
50+
echo "error_message=$ERROR_MSG" >> $GITHUB_OUTPUT
51+
52+
if grep -q "BUILD FAILURE" mvn_output.log; then
53+
echo "Maven build failed."
54+
exit 1
55+
elif grep -q "BUILD SUCCESS" mvn_output.log; then
56+
echo "Maven build succeeded."
57+
else
58+
echo "Maven build status unknown."
59+
exit 1
60+
fi
4561
env:
4662
# See "End-to-end test application instructions" on the README.md to update the secret
4763
AICORE_SERVICE_KEY: ${{ secrets[matrix.secret-name] }}
@@ -74,7 +90,12 @@ jobs:
7490
webhook: ${{ secrets.SLACK_WEBHOOK }}
7591
webhook-type: incoming-webhook
7692
payload: |
77-
{
78-
"text": "⚠️ End-to-end tests failed! 😬 Please inspect & fix by clicking <https://github.com/SAP/ai-sdk-java/actions/runs/${{ github.run_id }}|here>"
79-
}
80-
93+
blocks:
94+
- type: "section"
95+
text:
96+
type: "mrkdwn"
97+
text: "⚠️ End-to-end tests failed! 😬 Please inspect & fix by clicking <https://github.com/SAP/ai-sdk-java/actions/runs/${{ github.run_id }}|here>"
98+
- type: "section"
99+
text:
100+
type: "plain_text"
101+
text: "${{ steps.run_tests.outputs.error_message }}"

docs/release_notes.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
Interfaces with only one implementation were reduced.
1919
As a result, the accessors for fields `OrchestrationModuleConfig.inputTranslationConfig` and `OrchestrationModuleConfig.outputTranslationConfig` now handle the implementing class explicitly.
2020
The same applies to helper methods `DpiMasking#createConfig()` and `MaskingProvider#createConfig()`.
21+
- [Orchestration] `OrchestrationTemplate.withTemplate()` has been deprecated. Please use `OrchestrationTemplate.withTemplateMessages()` instead.
2122
- [Orchestration] The method `createConfig()` is removed from `ContentFilter`, `AzureContentFilter` and `LlamaGuardFilter` and is replaced by `createInputFilterConfig()` and `createOutputFilterConfig()`.
2223

2324
### ✨ New Functionality
2425

26+
- [Orchestration] Added support for [transforming a JSON output into an entity](https://sap.github.io/ai-sdk/docs/java/orchestration/chat-completion#json_schema)
2527
- [Orchestration] Added `AzureContentFilter#promptShield()` available for input filtering.
2628

2729
### 📈 Improvements
@@ -30,4 +32,4 @@
3032

3133
### 🐛 Fixed Issues
3234

33-
-
35+
- [Orchestration] Resolved duplicate JSON property issue, enabling Anthropic Claude chat completions.

orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,14 @@ public AssistantMessage(@Nonnull final List<MessageToolCall> toolCalls) {
6666
@Nonnull
6767
@Override
6868
public ChatMessage createChatMessage() {
69-
if (toolCalls() != null) {
69+
if (toolCalls != null) {
7070
return AssistantChatMessage.create().role(ASSISTANT).toolCalls(toolCalls);
7171
}
72+
if (content.items().size() == 1 && content.items().get(0) instanceof TextItem textItem) {
73+
return AssistantChatMessage.create()
74+
.role(ASSISTANT)
75+
.content(ChatMessageContent.create(textItem.text()));
76+
}
7277
val texts =
7378
content.items().stream()
7479
.filter(item -> item instanceof TextItem)

orchestration/src/main/java/com/sap/ai/sdk/orchestration/JacksonMixins.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.fasterxml.jackson.annotation.JsonSubTypes;
44
import com.fasterxml.jackson.annotation.JsonTypeInfo;
5+
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
56
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
67
import com.sap.ai.sdk.orchestration.model.LLMModuleResult;
78
import lombok.AccessLevel;
@@ -14,12 +15,9 @@ final class JacksonMixins {
1415
@JsonDeserialize(as = LLMModuleResult.class)
1516
interface LLMModuleResultMixIn {}
1617

17-
@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
18-
interface NoneTypeInfoMixin {}
19-
2018
@JsonTypeInfo(
2119
use = JsonTypeInfo.Id.NAME,
22-
include = JsonTypeInfo.As.PROPERTY,
20+
include = As.EXISTING_PROPERTY,
2321
property = "type",
2422
visible = true)
2523
@JsonSubTypes({
@@ -37,7 +35,7 @@ interface ResponseFormatSubTypesMixin {}
3735

3836
@JsonTypeInfo(
3937
use = JsonTypeInfo.Id.NAME,
40-
include = JsonTypeInfo.As.PROPERTY,
38+
include = As.EXISTING_PROPERTY,
4139
property = "role",
4240
visible = true)
4341
@JsonSubTypes({

orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import static lombok.AccessLevel.PACKAGE;
44

5+
import com.fasterxml.jackson.core.JsonProcessingException;
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
58
import com.sap.ai.sdk.orchestration.model.AssistantChatMessage;
69
import com.sap.ai.sdk.orchestration.model.ChatMessage;
710
import com.sap.ai.sdk.orchestration.model.ChatMessageContent;
@@ -104,4 +107,47 @@ public LLMChoice getChoice() {
104107
// We expect choices to be defined and never empty.
105108
return originalResponse.getOrchestrationResult().getChoices().get(0);
106109
}
110+
111+
/**
112+
* Transform a JSON response into an entity of the given type.
113+
*
114+
* <p>This is possible on a request with a {@link OrchestrationTemplate#withJsonSchemaResponse}
115+
* configured into {@link OrchestrationModuleConfig#withTemplateConfig}.
116+
*
117+
* @param type the class type to deserialize the JSON content into.
118+
* @return the deserialized entity of type T.
119+
* @param <T> the type of the entity to deserialize to.
120+
* @throws OrchestrationClientException if the model refused to answer the question or if the
121+
* content
122+
*/
123+
@Nonnull
124+
public <T> T asEntity(@Nonnull final Class<T> type) throws OrchestrationClientException {
125+
final String refusal =
126+
((LLMModuleResultSynchronous) getOriginalResponse().getOrchestrationResult())
127+
.getChoices()
128+
.get(0)
129+
.getMessage()
130+
.getRefusal();
131+
if (refusal != null) {
132+
throw new OrchestrationClientException(
133+
"The model refused to answer the question: " + refusal);
134+
}
135+
try {
136+
return new ObjectMapper().readValue(getContent(), type);
137+
} catch (InvalidDefinitionException e) {
138+
throw new OrchestrationClientException(
139+
"Failed to deserialize the JSON content. Please make sure to use the correct class and that the class has a no-args constructor or is static: "
140+
+ e.getMessage()
141+
+ "\nJSON content: "
142+
+ getContent(),
143+
e);
144+
} catch (JsonProcessingException e) {
145+
throw new OrchestrationClientException(
146+
"Failed to deserialize the JSON content. Please configure an OrchestrationTemplate with format set to JSON schema into your OrchestrationModuleConfig: "
147+
+ e.getMessage()
148+
+ "\nJSON content: "
149+
+ getContent(),
150+
e);
151+
}
152+
}
107153
}

orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationTemplate.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.HashMap;
2020
import java.util.List;
2121
import java.util.Map;
22+
import java.util.stream.Stream;
2223
import javax.annotation.Nonnull;
2324
import javax.annotation.Nullable;
2425
import lombok.AccessLevel;
@@ -41,8 +42,11 @@
4142
@NoArgsConstructor(force = true, access = AccessLevel.PACKAGE)
4243
@Beta
4344
public class OrchestrationTemplate extends TemplateConfig {
45+
46+
/** Please use {@link #withMessages(Message...)} instead. */
4447
@JsonProperty("template")
4548
@Nullable
49+
@With(onMethod_ = {@Deprecated})
4650
List<ChatMessage> template;
4751

4852
@JsonProperty("defaults")
@@ -58,6 +62,17 @@ public class OrchestrationTemplate extends TemplateConfig {
5862
@Nullable
5963
List<ChatCompletionTool> tools;
6064

65+
/**
66+
* Create a new template with the given messages.
67+
*
68+
* @param messages The messages to use in the template.
69+
* @return The updated template.
70+
*/
71+
@Nonnull
72+
public OrchestrationTemplate withMessages(@Nonnull final Message... messages) {
73+
return this.withTemplate(Stream.of(messages).map(Message::createChatMessage).toList());
74+
}
75+
6176
/**
6277
* Create a low-level representation of the template.
6378
*

orchestration/src/main/java/com/sap/ai/sdk/orchestration/SystemMessage.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ public SystemMessage withText(@Nonnull final String message) {
5959
@Nonnull
6060
@Override
6161
public ChatMessage createChatMessage() {
62+
if (content.items().size() == 1 && content.items().get(0) instanceof TextItem textItem) {
63+
return SystemChatMessage.create()
64+
.role(SYSTEM)
65+
.content(ChatMessageContent.create(textItem.text()));
66+
}
6267
val texts =
6368
content.items().stream()
6469
.filter(item -> item instanceof TextItem)

orchestration/src/main/java/com/sap/ai/sdk/orchestration/UserMessage.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ public UserMessage withImage(@Nonnull final String imageUrl) {
9393
public ChatMessage createChatMessage() {
9494
final var contentList = new LinkedList<UserChatMessageContentItem>();
9595

96-
for (final ContentItem item : this.content().items()) {
96+
if (content.items().size() == 1 && content.items().get(0) instanceof TextItem textItem) {
97+
return UserChatMessage.create()
98+
.content(UserChatMessageContent.create(textItem.text()))
99+
.role(USER);
100+
}
101+
for (final ContentItem item : content.items()) {
97102
if (item instanceof TextItem textItem) {
98103
contentList.add(UserChatMessageContentItem.create().type(TEXT).text(textItem.text()));
99104
} else if (item instanceof ImageItem imageItem) {

orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationConvenienceUnitTest.java

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44
import static org.assertj.core.api.Assertions.assertThat;
55

66
import com.fasterxml.jackson.annotation.JsonProperty;
7+
import com.fasterxml.jackson.databind.JsonNode;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
79
import com.sap.ai.sdk.orchestration.model.ChatCompletionTool;
810
import com.sap.ai.sdk.orchestration.model.ChatMessage;
9-
import com.sap.ai.sdk.orchestration.model.ChatMessageContent;
1011
import com.sap.ai.sdk.orchestration.model.FunctionObject;
1112
import com.sap.ai.sdk.orchestration.model.ResponseFormatJsonObject;
1213
import com.sap.ai.sdk.orchestration.model.ResponseFormatJsonSchema;
1314
import com.sap.ai.sdk.orchestration.model.ResponseFormatJsonSchemaJsonSchema;
14-
import com.sap.ai.sdk.orchestration.model.SystemChatMessage;
15-
import com.sap.ai.sdk.orchestration.model.SystemChatMessage.RoleEnum;
1615
import com.sap.ai.sdk.orchestration.model.Template;
1716
import com.sap.ai.sdk.orchestration.model.TemplateRef;
1817
import com.sap.ai.sdk.orchestration.model.TemplateRefByID;
@@ -129,7 +128,8 @@ void testConfigWithResponseSchema() {
129128

130129
@Test
131130
void testTemplateConstruction() {
132-
List<ChatMessage> templateMessages =
131+
Message templateMessages = Message.user("message");
132+
List<ChatMessage> templateMessagesLowLevel =
133133
List.of(
134134
UserChatMessage.create().content(UserChatMessageContent.create("message")).role(USER));
135135
var defaults = Map.of("key", "value");
@@ -140,14 +140,14 @@ void testTemplateConstruction() {
140140
.function(FunctionObject.create().name("func")));
141141
var template =
142142
TemplateConfig.create()
143-
.withTemplate(templateMessages)
143+
.withMessages(templateMessages)
144144
.withDefaults(defaults)
145145
.withTools(tools)
146146
.withJsonResponse();
147147

148148
var templateLowLevel =
149149
Template.create()
150-
.template(templateMessages)
150+
.template(templateMessagesLowLevel)
151151
.defaults(defaults)
152152
.responseFormat(
153153
ResponseFormatJsonObject.create()
@@ -206,15 +206,9 @@ void testTemplateFromLocalFileWithJsonSchemaAndTools() throws IOException {
206206
false);
207207
var expectedTemplateWithJsonSchemaTools =
208208
OrchestrationTemplate.create()
209-
.withTemplate(
210-
List.of(
211-
SystemChatMessage.create()
212-
.role(RoleEnum.SYSTEM)
213-
.content(ChatMessageContent.create("You are a language translator.")),
214-
UserChatMessage.create()
215-
.content(
216-
UserChatMessageContent.create("Whats {{ ?word }} in {{ ?language }}?"))
217-
.role(USER)))
209+
.withMessages(
210+
Message.system("You are a language translator."),
211+
Message.user("Whats {{ ?word }} in {{ ?language }}?"))
218212
.withDefaults(Map.of("word", "apple"))
219213
.withJsonSchemaResponse(
220214
ResponseJsonSchema.fromMap(schema, "translation-schema")
@@ -241,7 +235,12 @@ void testTemplateFromLocalFileWithJsonSchemaAndTools() throws IOException {
241235
"wordToTranslate", Map.of("type", "string"))))
242236
.description("Translate a word.")
243237
.strict(true))));
244-
assertThat(templateWithJsonSchemaTools).isEqualTo(expectedTemplateWithJsonSchemaTools);
238+
239+
var jackson = new ObjectMapper();
240+
JsonNode template = jackson.readTree(jackson.writeValueAsString(templateWithJsonSchemaTools));
241+
JsonNode expectedTemplate =
242+
jackson.readTree(jackson.writeValueAsString(expectedTemplateWithJsonSchemaTools));
243+
assertThat(template).isEqualTo(expectedTemplate);
245244
}
246245

247246
@Test
@@ -267,17 +266,16 @@ void testTemplateFromLocalFileWithJsonObject() throws IOException {
267266
var templateWithJsonObject = TemplateConfig.create().fromYaml(promptTemplateWithJsonObject);
268267
var expectedTemplateWithJsonObject =
269268
OrchestrationTemplate.create()
270-
.withTemplate(
271-
List.of(
272-
SystemChatMessage.create()
273-
.role(RoleEnum.SYSTEM)
274-
.content(ChatMessageContent.create("You are a language translator.")),
275-
UserChatMessage.create()
276-
.content(
277-
UserChatMessageContent.create("Whats {{ ?word }} in {{ ?language }}?"))
278-
.role(USER)))
269+
.withMessages(
270+
Message.system("You are a language translator."),
271+
Message.user("Whats {{ ?word }} in {{ ?language }}?"))
279272
.withDefaults(Map.of("word", "apple"))
280273
.withJsonResponse();
281-
assertThat(templateWithJsonObject).isEqualTo(expectedTemplateWithJsonObject);
274+
275+
var jackson = new ObjectMapper();
276+
JsonNode template = jackson.readTree(jackson.writeValueAsString(templateWithJsonObject));
277+
JsonNode expectedTemplate =
278+
jackson.readTree(jackson.writeValueAsString(expectedTemplateWithJsonObject));
279+
assertThat(template).isEqualTo(expectedTemplate);
282280
}
283281
}

0 commit comments

Comments
 (0)