-
Notifications
You must be signed in to change notification settings - Fork 15
feat: [Orchestration] Support for Orchestration config persistence #697
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3e34453
8abe277
7039c85
435aac5
7a6d117
e6a8239
33ddc76
54849bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| package com.sap.ai.sdk.prompt.registry; | ||
|
|
||
| import static com.sap.ai.sdk.core.JacksonConfiguration.getDefaultObjectMapper; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonSubTypes; | ||
| import com.fasterxml.jackson.annotation.JsonTypeInfo; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | ||
| import com.google.common.collect.Iterables; | ||
| import com.sap.ai.sdk.core.AiCoreService; | ||
| import com.sap.ai.sdk.prompt.registry.client.OrchestrationConfigsApi; | ||
| import com.sap.ai.sdk.prompt.registry.model.AzureContentSafetyInputFilterConfig; | ||
| import com.sap.ai.sdk.prompt.registry.model.AzureContentSafetyOutputFilterConfig; | ||
| import com.sap.ai.sdk.prompt.registry.model.InputFilterConfig; | ||
| import com.sap.ai.sdk.prompt.registry.model.LlamaGuard38bFilterConfig; | ||
| import com.sap.ai.sdk.prompt.registry.model.OutputFilterConfig; | ||
| import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; | ||
| import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient; | ||
| import javax.annotation.Nonnull; | ||
| import lombok.AccessLevel; | ||
| import lombok.NoArgsConstructor; | ||
| import org.springframework.http.client.BufferingClientHttpRequestFactory; | ||
| import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; | ||
| import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; | ||
| import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; | ||
| import org.springframework.web.client.RestTemplate; | ||
|
|
||
| /** | ||
| * Client for managing Orchestration Configurations in the Prompt Registry service. | ||
| * | ||
| * @since 1.14.0 | ||
| */ | ||
| public class OrchestrationConfigClient extends OrchestrationConfigsApi { | ||
|
|
||
| /** | ||
| * Instantiates a client to manage Orchestration Configurations on the Prompt Registry service. | ||
| * | ||
| * @since 1.14.0 | ||
| */ | ||
| public OrchestrationConfigClient() { | ||
| this(new AiCoreService()); | ||
| } | ||
|
|
||
| /** | ||
| * Instantiates a client to manage Orchestration Configurations on the Prompt Registry service. | ||
| * | ||
| * @param aiCoreService The configured connectivity instance to AI Core | ||
| * @since 1.14.0 | ||
| */ | ||
| public OrchestrationConfigClient(@Nonnull final AiCoreService aiCoreService) { | ||
| super(addMixin(aiCoreService)); | ||
| } | ||
|
|
||
| @Nonnull | ||
| private static ApiClient addMixin(@Nonnull final AiCoreService service) { | ||
| final var destination = service.getBaseDestination(); | ||
| final var httpRequestFactory = new HttpComponentsClientHttpRequestFactory(); | ||
| httpRequestFactory.setHttpClient(ApacheHttpClient5Accessor.getHttpClient(destination)); | ||
|
|
||
| final var rt = new RestTemplate(); | ||
| Iterables.filter(rt.getMessageConverters(), MappingJackson2HttpMessageConverter.class) | ||
| .forEach( | ||
| converter -> | ||
| converter.setObjectMapper( | ||
| getDefaultObjectMapper() | ||
| .addMixIn(OutputFilterConfig.class, JacksonMixin.OutputFilter.class) | ||
| .addMixIn(InputFilterConfig.class, JacksonMixin.InputFilter.class))); | ||
| final var yamlMapper = new ObjectMapper(new YAMLFactory()); | ||
| yamlMapper | ||
| .addMixIn(OutputFilterConfig.class, JacksonMixin.OutputFilter.class) | ||
| .addMixIn(InputFilterConfig.class, JacksonMixin.InputFilter.class); | ||
| Iterables.filter(rt.getMessageConverters(), MappingJackson2YamlHttpMessageConverter.class) | ||
| .forEach(converter -> converter.setObjectMapper(yamlMapper)); | ||
|
|
||
| rt.setRequestFactory(new BufferingClientHttpRequestFactory(httpRequestFactory)); | ||
|
|
||
| return new ApiClient(rt).setBasePath(destination.asHttp().getUri().toString()); | ||
| } | ||
|
|
||
| @NoArgsConstructor(access = AccessLevel.PRIVATE) | ||
| private static class JacksonMixin { | ||
|
|
||
| @JsonTypeInfo( | ||
| use = JsonTypeInfo.Id.NAME, | ||
| include = JsonTypeInfo.As.EXISTING_PROPERTY, | ||
| property = "type", | ||
| visible = true) | ||
| @JsonSubTypes({ | ||
| @JsonSubTypes.Type(value = LlamaGuard38bFilterConfig.class, name = "llama_guard_3_8b"), | ||
| @JsonSubTypes.Type( | ||
| value = AzureContentSafetyOutputFilterConfig.class, | ||
| name = "azure_content_safety") | ||
| }) | ||
| interface OutputFilter {} | ||
|
|
||
| @JsonTypeInfo( | ||
| use = JsonTypeInfo.Id.NAME, | ||
| include = JsonTypeInfo.As.EXISTING_PROPERTY, | ||
| property = "type", | ||
| visible = true) | ||
| @JsonSubTypes({ | ||
| @JsonSubTypes.Type(value = LlamaGuard38bFilterConfig.class, name = "llama_guard_3_8b"), | ||
| @JsonSubTypes.Type( | ||
| value = AzureContentSafetyInputFilterConfig.class, | ||
| name = "azure_content_safety") | ||
| }) | ||
| interface InputFilter {} | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package com.sap.ai.sdk.prompt.registry; | ||
|
|
||
| import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; | ||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| import com.github.tomakehurst.wiremock.junit5.WireMockExtension; | ||
| import com.sap.ai.sdk.core.AiCoreService; | ||
| import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; | ||
| import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; | ||
| import java.util.UUID; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.extension.RegisterExtension; | ||
|
|
||
| public class OrchestrationConfigClientTest { | ||
| @RegisterExtension | ||
| private static final WireMockExtension WM = | ||
| WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); | ||
|
|
||
| private static OrchestrationConfigClient client; | ||
|
|
||
| @BeforeEach | ||
| void setup() { | ||
| final HttpDestination destination = DefaultHttpDestination.builder(WM.baseUrl()).build(); | ||
| final AiCoreService service = new AiCoreService().withBaseDestination(destination); | ||
| client = new OrchestrationConfigClient(service); | ||
| } | ||
|
|
||
| @Test | ||
| void testPipelines() { | ||
| final var result = client.listOrchestrationConfigs(); | ||
| assertThat(result.getCount()).isEqualTo(2); | ||
| assertThat(result.getResources()).hasSize(2); | ||
| final var template = result.getResources().get(0); | ||
| assertThat(template.getId()).isEqualTo(UUID.fromString("62e8638a-ae87-4bd5-9027-a0bc67db1609")); | ||
| assertThat(template.getName()).isEqualTo("test-config-for-OrchestrationTest"); | ||
| assertThat(template.getVersion()).isEqualTo("0.0.1"); | ||
| assertThat(template.getScenario()).isEqualTo("sdk-test-scenario"); | ||
| assertThat(template.getCreationTimestamp()).isEqualTo("2025-12-19T16:24:27.442000"); | ||
| assertThat(template.getManagedBy()).isEqualTo("imperative"); | ||
| assertThat(template.isIsVersionHead()).isEqualTo(true); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| { | ||
| "request": { | ||
| "method": "GET", | ||
| "url": "/v2/registry/v2/orchestrationConfigs" | ||
| }, | ||
| "response": { | ||
| "status": 200, | ||
| "headers": { | ||
| "Content-Type": "application/json" | ||
| }, | ||
| "jsonBody": { | ||
| "count": 2, | ||
| "resources": [ | ||
| { | ||
| "id": "62e8638a-ae87-4bd5-9027-a0bc67db1609", | ||
| "name": "test-config-for-OrchestrationTest", | ||
| "version": "0.0.1", | ||
| "scenario": "sdk-test-scenario", | ||
| "creation_timestamp": "2025-12-19T16:24:27.442000", | ||
| "managed_by": "imperative", | ||
| "is_version_head": true | ||
| }, | ||
| { | ||
| "id": "f9f2875a-4c92-471b-a403-51a50e70fe52", | ||
| "name": "test-config", | ||
| "version": "0.0.1", | ||
| "scenario": "sdk-test-scenario", | ||
| "creation_timestamp": "2025-12-19T16:33:05.607000", | ||
| "managed_by": "imperative", | ||
| "is_version_head": true | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,12 @@ | ||
| package com.sap.ai.sdk.orchestration; | ||
|
|
||
| import com.sap.ai.sdk.orchestration.model.ChatMessage; | ||
| import com.sap.ai.sdk.orchestration.model.CompletionPostRequest; | ||
| import com.sap.ai.sdk.orchestration.model.CompletionRequestConfiguration; | ||
| import com.sap.ai.sdk.orchestration.model.CompletionRequestConfigurationReferenceById; | ||
| import com.sap.ai.sdk.orchestration.model.CompletionRequestConfigurationReferenceByIdConfigRef; | ||
| import com.sap.ai.sdk.orchestration.model.CompletionRequestConfigurationReferenceByNameScenarioVersion; | ||
| import com.sap.ai.sdk.orchestration.model.CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef; | ||
| import com.sap.ai.sdk.orchestration.model.ModuleConfigs; | ||
| import com.sap.ai.sdk.orchestration.model.OrchestrationConfig; | ||
| import com.sap.ai.sdk.orchestration.model.OrchestrationConfigModules; | ||
|
|
@@ -12,13 +18,17 @@ | |
| import com.sap.ai.sdk.orchestration.model.TranslationModuleConfig; | ||
| import io.vavr.control.Option; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import javax.annotation.Nonnull; | ||
| import javax.annotation.Nullable; | ||
| import lombok.AccessLevel; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import lombok.val; | ||
|
|
||
| /** Factory to create all data objects from an orchestration configuration. */ | ||
| @Slf4j | ||
| @NoArgsConstructor(access = AccessLevel.NONE) | ||
| final class ConfigToRequestTransformer { | ||
| @Nonnull | ||
|
|
@@ -113,4 +123,45 @@ static InnerModuleConfigs toModuleConfigs(@Nonnull final OrchestrationModuleConf | |
|
|
||
| return OrchestrationConfigModules.createInnerModuleConfigs(moduleConfig); | ||
| } | ||
|
|
||
| @Nonnull | ||
| static CompletionPostRequest fromReferenceToCompletionPostRequest( | ||
| @Nullable final OrchestrationPrompt prompt, | ||
| @Nonnull final OrchestrationConfigReference reference) { | ||
| List<ChatMessage> messageHistory = List.of(); | ||
| Map<String, String> placeholders = Map.of(); | ||
| if (prompt != null) { | ||
| if (!prompt.getMessages().isEmpty()) { | ||
| log.debug( | ||
| "Messages in prompts are ignored when using Orchestration configs via reference. Change the Orchestration config instead."); | ||
| } | ||
|
Comment on lines
+134
to
+137
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could also throw instead of just logging. Was not sure which is better. |
||
| messageHistory = | ||
| prompt.getMessagesHistory().stream().map(Message::createChatMessage).toList(); | ||
| placeholders = prompt.getTemplateParameters(); | ||
| } | ||
|
|
||
| CompletionPostRequest request; | ||
| if (reference.getId() != null) { | ||
| request = | ||
| CompletionRequestConfigurationReferenceById.create() | ||
| .configRef( | ||
| CompletionRequestConfigurationReferenceByIdConfigRef.create() | ||
| .id(reference.getId())); | ||
| ((CompletionRequestConfigurationReferenceById) request).setMessagesHistory(messageHistory); | ||
| ((CompletionRequestConfigurationReferenceById) request).setPlaceholderValues(placeholders); | ||
| } else { | ||
| request = | ||
| CompletionRequestConfigurationReferenceByNameScenarioVersion.create() | ||
| .configRef( | ||
| CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef.create() | ||
| .scenario(reference.getScenario()) | ||
| .name(reference.getName()) | ||
| .version(reference.getVersion())); | ||
| ((CompletionRequestConfigurationReferenceByNameScenarioVersion) request) | ||
| .setMessagesHistory(messageHistory); | ||
| ((CompletionRequestConfigurationReferenceByNameScenarioVersion) request) | ||
| .setPlaceholderValues(placeholders); | ||
| } | ||
| return request; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |
| import java.util.function.Supplier; | ||
| import java.util.stream.Stream; | ||
| import javax.annotation.Nonnull; | ||
| import javax.annotation.Nullable; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import lombok.val; | ||
|
|
@@ -166,6 +167,25 @@ public CompletionPostResponse executeRequest(@Nonnull final CompletionPostReques | |
| COMPLETION_ENDPOINT, request, CompletionPostResponse.class, customHeaders); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a completion using a referenced Orchestration config and a prompt. | ||
| * | ||
| * @param prompt The prompt to store placeholder values and the message history | ||
| * @param reference A reference to an Orchestration config stored in prompt registry | ||
| * @return The completion output | ||
| * @since 1.14.0 | ||
| */ | ||
| @Beta | ||
| @Nonnull | ||
| public OrchestrationChatResponse executeRequestFromReference( | ||
| @Nullable final OrchestrationPrompt prompt, | ||
| @Nonnull final OrchestrationConfigReference reference) { | ||
|
Comment on lines
+180
to
+182
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could add public OrchestrationChatResponse executeRequestFromReference(
@Nonnull final OrchestrationConfigReference reference) {...}as well for even more convenience? (The prompt is used to forward placeholder values and the message history, so it is not always needed but probably in most actual use cases).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not a fan of either method option. Also not liking existing To me this method looks similar to "normal" use-case As a user i would assume methods have similar signature whether they use an explicit orchestration config, or an implicit config. Or is the semantic usage different (enough) to justify a different name schema? |
||
| val request = | ||
| ConfigToRequestTransformer.fromReferenceToCompletionPostRequest(prompt, reference); | ||
| val response = executeRequest(request); | ||
| return new OrchestrationChatResponse(response); | ||
| } | ||
|
|
||
| /** | ||
| * Perform a request to the orchestration service using a module configuration provided as JSON | ||
| * string. This can be useful when building a configuration in the AI Launchpad UI and exporting | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Major/Discussion)
I don't think we want to extend a custom class from generated code. Did we do that before? Is there no other way to apply mixins? Otherwise this has a code smell; we're introducing hand-written public API code for the sake of a workaround.