diff --git a/core-services/prompt-registry/pom.xml b/core-services/prompt-registry/pom.xml
index 13541e763..940ea87b5 100644
--- a/core-services/prompt-registry/pom.xml
+++ b/core-services/prompt-registry/pom.xml
@@ -38,11 +38,11 @@
${project.basedir}/../../
- 84%
- 90%
- 92%
+ 92%
+ 94%
+ 95%
100%
- 80%
+ 85%
100%
@@ -85,6 +85,10 @@
com.fasterxml.jackson.core
jackson-databind
+
+ com.fasterxml.jackson.core
+ jackson-core
+
com.google.guava
guava
diff --git a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java
index c614abf33..5471676dd 100644
--- a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java
+++ b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java
@@ -4,10 +4,17 @@
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.google.common.collect.Iterables;
import com.sap.ai.sdk.core.AiCoreService;
import com.sap.ai.sdk.prompt.registry.client.PromptTemplatesApi;
+import com.sap.ai.sdk.prompt.registry.model.MultiChatContent;
+import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate;
import com.sap.ai.sdk.prompt.registry.model.PromptTemplate;
import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSpecResponseFormat;
import com.sap.ai.sdk.prompt.registry.model.ResponseFormatJsonObject;
@@ -16,6 +23,8 @@
import com.sap.ai.sdk.prompt.registry.model.SingleChatTemplate;
import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
+import java.io.IOException;
+import java.util.ArrayList;
import javax.annotation.Nonnull;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -75,7 +84,7 @@ private static ApiClient addMixin(@Nonnull final AiCoreService service) {
@NoArgsConstructor(access = AccessLevel.PRIVATE)
private static class JacksonMixin {
@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
- @JsonDeserialize(as = SingleChatTemplate.class)
+ @JsonDeserialize(using = PromptTemplateDeserializer.class)
interface TemplateMixIn {}
@JsonTypeInfo(
@@ -90,4 +99,40 @@ interface TemplateMixIn {}
})
interface ResponseFormat {}
}
+
+ private static class PromptTemplateDeserializer extends JsonDeserializer {
+
+ @Override
+ public PromptTemplate deserialize(
+ @Nonnull final JsonParser jsonParser,
+ @Nonnull final DeserializationContext deserializationContext)
+ throws IOException {
+
+ final JsonNode root = jsonParser.readValueAsTree();
+ final JsonNode roleNode = root.path("role");
+ final String role = roleNode.asText();
+ final JsonNode content = root.path("content");
+
+ if (!roleNode.isTextual()) {
+ throw JsonMappingException.from(
+ jsonParser, "PromptTemplate requires textual 'role' property.");
+ }
+
+ if (content.isTextual()) {
+ return SingleChatTemplate.create().role(role).content(content.asText());
+ }
+ if (content.isArray()) {
+ final var contentList = new ArrayList();
+ for (final JsonNode item : content) {
+ contentList.add(jsonParser.getCodec().treeToValue(item, MultiChatContent.class));
+ }
+ return MultiChatTemplate.create().role(role).content(contentList);
+ }
+
+ throw JsonMappingException.from(
+ jsonParser,
+ "PromptTemplate content must be either a string or an array, but found: "
+ + content.getNodeType());
+ }
+ }
}
diff --git a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java
index 37e383add..7ab8ed62e 100644
--- a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java
+++ b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java
@@ -1,9 +1,13 @@
package com.sap.ai.sdk.prompt.registry.spring;
+import com.sap.ai.sdk.prompt.registry.model.ImageContent;
+import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate;
import com.sap.ai.sdk.prompt.registry.model.PromptTemplate;
import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionResponse;
import com.sap.ai.sdk.prompt.registry.model.SingleChatTemplate;
+import com.sap.ai.sdk.prompt.registry.model.TextContent;
import java.util.List;
+import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import lombok.val;
import org.springframework.ai.chat.messages.AssistantMessage;
@@ -34,17 +38,44 @@ public static List promptTemplateToMessages(
// TRANSFORM TEMPLATE TO SPRING AI MESSAGES
return res.stream()
.map(
- (PromptTemplate t) -> {
- final SingleChatTemplate message = (SingleChatTemplate) t;
- return (Message)
- switch (message.getRole()) {
- case "system" -> new SystemMessage(message.getContent());
- case "user" -> new UserMessage(message.getContent());
- case "assistant" -> new AssistantMessage(message.getContent());
- default ->
- throw new IllegalArgumentException("Unknown role: " + message.getRole());
- };
+ (PromptTemplate template) -> {
+ if (template instanceof SingleChatTemplate message) {
+ return fromRole(message.getRole(), message.getContent());
+ }
+ if (template instanceof MultiChatTemplate message) {
+ return fromRole(message.getRole(), getMultiTemplateTextContent(message));
+ }
+ throw new IllegalArgumentException(
+ "Unsupported PromptTemplate type: " + template.getClass().getName());
})
.toList();
}
+
+ @Nonnull
+ private static String getMultiTemplateTextContent(@Nonnull final MultiChatTemplate message) {
+ return message.getContent().stream()
+ .map(
+ item -> {
+ if (item instanceof TextContent textContent) {
+ return textContent.getText();
+ }
+ if (item instanceof ImageContent) {
+ throw new UnsupportedOperationException(
+ "MultiChatTemplate with image content is not supported by SpringAiConverter yet.");
+ }
+ throw new UnsupportedOperationException(
+ "Unsupported MultiChatContent type: " + item.getClass().getName());
+ })
+ .collect(Collectors.joining("\n"));
+ }
+
+ @Nonnull
+ private static Message fromRole(@Nonnull final String role, @Nonnull final String content) {
+ return switch (role) {
+ case "system" -> new SystemMessage(content);
+ case "user" -> new UserMessage(content);
+ case "assistant" -> new AssistantMessage(content);
+ default -> throw new IllegalArgumentException("Unknown role: " + role);
+ };
+ }
}
diff --git a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java
index 428a87eed..b81131c0a 100644
--- a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java
+++ b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java
@@ -2,15 +2,21 @@
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.sap.ai.sdk.core.AiCoreService;
+import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate;
import com.sap.ai.sdk.prompt.registry.model.PromptTemplateGetResponse;
+import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionRequest;
import com.sap.ai.sdk.prompt.registry.model.ResponseFormatJsonObject;
import com.sap.ai.sdk.prompt.registry.model.ResponseFormatJsonSchema;
import com.sap.ai.sdk.prompt.registry.model.ResponseFormatText;
+import com.sap.ai.sdk.prompt.registry.model.SingleChatTemplate;
+import com.sap.ai.sdk.prompt.registry.model.TextContent;
import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
+import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -95,4 +101,66 @@ void testGetTemplateWithResponseFormatJsonSchema() {
assertThat(format.getJsonSchema().isStrict()).isFalse();
assertThat(format.getJsonSchema().getSchema()).isNotNull();
}
+
+ @Test
+ void testGetTemplateWithMultiChatTemplate() {
+ final var uuid = UUID.fromString("8f79fec4-ae07-4c35-96e3-df7f4a3f1df5");
+ final var response = client.getPromptTemplateByUuid(uuid);
+
+ assertThat(response.getSpec()).isNotNull();
+ assertThat(response.getSpec().getTemplate()).hasSize(2);
+ assertThat(response.getSpec().getTemplate().get(0)).isInstanceOf(SingleChatTemplate.class);
+ assertThat(response.getSpec().getTemplate().get(1)).isInstanceOf(MultiChatTemplate.class);
+
+ final var multiTemplate = (MultiChatTemplate) response.getSpec().getTemplate().get(1);
+ assertThat(multiTemplate.getRole()).isEqualTo("user");
+ assertThat(multiTemplate.getContent()).hasSize(2);
+ assertThat(multiTemplate.getContent().get(0)).isInstanceOf(TextContent.class);
+ assertThat(((TextContent) multiTemplate.getContent().get(0)).getText())
+ .isEqualTo("First content line");
+ assertThat(((TextContent) multiTemplate.getContent().get(1)).getText())
+ .isEqualTo("Second content line");
+ }
+
+ @Test
+ void testGetTemplateWithInvalidRoleType() {
+ final var uuid = UUID.fromString("45cb1358-0bf1-4f43-870b-00f14d0f9f16");
+
+ assertThatThrownBy(() -> client.getPromptTemplateByUuid(uuid))
+ .hasStackTraceContaining("PromptTemplate requires textual 'role' property.");
+ }
+
+ @Test
+ void testGetTemplateWithInvalidContentType() {
+ final var uuid = UUID.fromString("55cb1358-0bf1-4f43-870b-00f14d0f9f16");
+
+ assertThatThrownBy(() -> client.getPromptTemplateByUuid(uuid))
+ .hasStackTraceContaining(
+ "PromptTemplate content must be either a string or an array, but found: BOOLEAN");
+ }
+
+ @Test
+ void testParsePromptTemplateHotPath() {
+ final var request =
+ PromptTemplateSubstitutionRequest.create()
+ .inputParams(Map.of("inputExample", "I love football"));
+
+ final var response =
+ client.parsePromptTemplateByNameVersion(
+ "categorization", "0.0.1", "hotpath-serde", "default", null, false, request);
+
+ assertThat(response.getParsedPrompt()).hasSize(2);
+ assertThat(response.getParsedPrompt().get(0)).isInstanceOf(SingleChatTemplate.class);
+ assertThat(response.getParsedPrompt().get(1)).isInstanceOf(SingleChatTemplate.class);
+
+ final var systemTemplate = (SingleChatTemplate) response.getParsedPrompt().get(0);
+ assertThat(systemTemplate.getRole()).isEqualTo("system");
+ assertThat(systemTemplate.getContent())
+ .isEqualTo(
+ "You classify input text into the two following categories: Finance, Tech, Sports");
+
+ final var userTemplate = (SingleChatTemplate) response.getParsedPrompt().get(1);
+ assertThat(userTemplate.getRole()).isEqualTo("user");
+ assertThat(userTemplate.getContent()).isEqualTo("I love football");
+ }
}
diff --git a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptTemplateSerdeUnitTest.java b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptTemplateSerdeUnitTest.java
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptTemplateSerdeUnitTest.java
@@ -0,0 +1 @@
+
diff --git a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java
index de3053bf7..7008efabe 100644
--- a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java
+++ b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java
@@ -7,7 +7,11 @@
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.sap.ai.sdk.core.AiCoreService;
import com.sap.ai.sdk.prompt.registry.PromptClient;
+import com.sap.ai.sdk.prompt.registry.model.MultiChatContent;
+import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate;
+import com.sap.ai.sdk.prompt.registry.model.PromptTemplate;
import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionRequest;
+import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionResponse;
import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
import java.util.List;
@@ -68,4 +72,65 @@ void testInvalidRoleThrowsException() {
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unknown role: error");
}
+
+ @Test
+ void testMultiChatTemplateTextContentToSpringAi() {
+ var client = new PromptClient(SERVICE);
+ val promptResponse =
+ client.parsePromptTemplateByNameVersion(
+ "categorization",
+ "0.0.1",
+ "multi-text",
+ "default",
+ null,
+ false,
+ PromptTemplateSubstitutionRequest.create()
+ .inputParams(Map.of("inputExample", "I love football")));
+
+ List messages = SpringAiConverter.promptTemplateToMessages(promptResponse);
+ assertThat(messages)
+ .isEqualTo(List.of(new UserMessage("First content line\nSecond content line")));
+ }
+
+ @Test
+ void testMultiChatTemplateImageContentThrowsException() {
+ var client = new PromptClient(SERVICE);
+ val promptResponse =
+ client.parsePromptTemplateByNameVersion(
+ "categorization",
+ "0.0.1",
+ "multi-image",
+ "default",
+ null,
+ false,
+ PromptTemplateSubstitutionRequest.create()
+ .inputParams(Map.of("inputExample", "I love football")));
+
+ assertThatThrownBy(() -> SpringAiConverter.promptTemplateToMessages(promptResponse))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("image content is not supported");
+ }
+
+ @Test
+ void testUnsupportedPromptTemplateTypeThrowsException() {
+ final PromptTemplate unsupportedTemplate = new PromptTemplate() {};
+ final var promptResponse =
+ PromptTemplateSubstitutionResponse.create().parsedPrompt(List.of(unsupportedTemplate));
+
+ assertThatThrownBy(() -> SpringAiConverter.promptTemplateToMessages(promptResponse))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unsupported PromptTemplate type");
+ }
+
+ @Test
+ void testUnsupportedMultiChatContentTypeThrowsException() {
+ final MultiChatContent unsupportedContent = new MultiChatContent() {};
+ final var message = MultiChatTemplate.create().role("user").content(unsupportedContent);
+ final var promptResponse =
+ PromptTemplateSubstitutionResponse.create().parsedPrompt(List.of(message));
+
+ assertThatThrownBy(() -> SpringAiConverter.promptTemplateToMessages(promptResponse))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Unsupported MultiChatContent type");
+ }
}
diff --git a/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidContentType.json b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidContentType.json
new file mode 100644
index 000000000..97a07228e
--- /dev/null
+++ b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidContentType.json
@@ -0,0 +1,30 @@
+{
+ "request": {
+ "method": "GET",
+ "url": "/v2/lm/promptTemplates/55cb1358-0bf1-4f43-870b-00f14d0f9f16"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "id": "55cb1358-0bf1-4f43-870b-00f14d0f9f16",
+ "name": "invalid-content",
+ "version": "0.0.1",
+ "scenario": "test-retrival",
+ "creationTimestamp": "2025-12-02T16:06:05.400000",
+ "managedBy": "imperative",
+ "isVersionHead": true,
+ "spec": {
+ "template": [
+ {
+ "role": "user",
+ "content": true
+ }
+ ],
+ "tools": []
+ }
+ }
+ }
+}
diff --git a/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidRoleType.json b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidRoleType.json
new file mode 100644
index 000000000..2ad8a29ee
--- /dev/null
+++ b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidRoleType.json
@@ -0,0 +1,30 @@
+{
+ "request": {
+ "method": "GET",
+ "url": "/v2/lm/promptTemplates/45cb1358-0bf1-4f43-870b-00f14d0f9f16"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "id": "45cb1358-0bf1-4f43-870b-00f14d0f9f16",
+ "name": "invalid-role",
+ "version": "0.0.1",
+ "scenario": "test-retrival",
+ "creationTimestamp": "2025-12-02T16:06:05.400000",
+ "managedBy": "imperative",
+ "isVersionHead": true,
+ "spec": {
+ "template": [
+ {
+ "role": 123,
+ "content": "Test content"
+ }
+ ],
+ "tools": []
+ }
+ }
+ }
+}
diff --git a/core-services/prompt-registry/src/test/resources/mappings/templateWithMultiChat.json b/core-services/prompt-registry/src/test/resources/mappings/templateWithMultiChat.json
new file mode 100644
index 000000000..6c1a789f4
--- /dev/null
+++ b/core-services/prompt-registry/src/test/resources/mappings/templateWithMultiChat.json
@@ -0,0 +1,43 @@
+{
+ "request": {
+ "method": "GET",
+ "url": "/v2/lm/promptTemplates/8f79fec4-ae07-4c35-96e3-df7f4a3f1df5"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "id": "8f79fec4-ae07-4c35-96e3-df7f4a3f1df5",
+ "name": "test-multi",
+ "version": "0.0.1",
+ "scenario": "test-retrival",
+ "creationTimestamp": "2025-12-02T16:06:05.400000",
+ "managedBy": "imperative",
+ "isVersionHead": true,
+ "spec": {
+ "template": [
+ {
+ "role": "system",
+ "content": "System content"
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "First content line"
+ },
+ {
+ "type": "text",
+ "text": "Second content line"
+ }
+ ]
+ }
+ ],
+ "tools": []
+ }
+ }
+ }
+}
diff --git a/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsHotPathSerde.json b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsHotPathSerde.json
new file mode 100644
index 000000000..1c3e09173
--- /dev/null
+++ b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsHotPathSerde.json
@@ -0,0 +1,29 @@
+{
+ "request": {
+ "method": "POST",
+ "url": "/v2/lm/scenarios/categorization/promptTemplates/hotpath-serde/versions/0.0.1/substitution?metadata=false",
+ "bodyPatterns": [
+ {
+ "matchesJsonPath": "$.inputParams.inputExample"
+ }
+ ]
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "parsedPrompt": [
+ {
+ "role": "system",
+ "content": "You classify input text into the two following categories: Finance, Tech, Sports"
+ },
+ {
+ "role": "user",
+ "content": "I love football"
+ }
+ ]
+ }
+ }
+}
diff --git a/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiImage.json b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiImage.json
new file mode 100644
index 000000000..ff8de11ef
--- /dev/null
+++ b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiImage.json
@@ -0,0 +1,31 @@
+{
+ "request": {
+ "method": "POST",
+ "url": "/v2/lm/scenarios/categorization/promptTemplates/multi-image/versions/0.0.1/substitution?metadata=false"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "parsedPrompt": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "Describe this image"
+ },
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "https://example.invalid/image.png"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiText.json b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiText.json
new file mode 100644
index 000000000..78059c450
--- /dev/null
+++ b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiText.json
@@ -0,0 +1,29 @@
+{
+ "request": {
+ "method": "POST",
+ "url": "/v2/lm/scenarios/categorization/promptTemplates/multi-text/versions/0.0.1/substitution?metadata=false"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "parsedPrompt": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "First content line"
+ },
+ {
+ "type": "text",
+ "text": "Second content line"
+ }
+ ]
+ }
+ ]
+ }
+ }
+}