diff --git a/core/pom.xml b/core/pom.xml
index 78ab5bbc..3ecd1a93 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -94,6 +94,10 @@
org.slf4jslf4j-api
+
+ io.vavr
+ vavr
+ com.sap.cloud.sdk.cloudplatform
@@ -127,6 +131,11 @@
assertj-coretest
+
+ org.mockito
+ mockito-core
+ test
+
diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreDeployment.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreDeployment.java
new file mode 100644
index 00000000..b0620591
--- /dev/null
+++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreDeployment.java
@@ -0,0 +1,103 @@
+package com.sap.ai.sdk.core;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationProperty;
+import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** Connectivity convenience methods for AI Core with deployment. */
+@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
+public class AiCoreDeployment implements AiCoreDestination {
+
+ private static final String AI_RESOURCE_GROUP = "URL.headers.AI-Resource-Group";
+
+ // the delegating AI Core Service instance
+ @Nonnull private final AiCoreService service;
+
+ // the deployment id handler to be used, based on instance
+ @Nonnull private final Function deploymentId;
+
+ // the resource group, "default" if null
+ @Getter(AccessLevel.PROTECTED)
+ @Nonnull
+ private final String resourceGroup;
+
+ /**
+ * Default constructor with "default" resource group.
+ *
+ * @param service The AI Core service.
+ * @param deploymentId The deployment id handler.
+ */
+ public AiCoreDeployment(
+ @Nonnull final AiCoreService service,
+ @Nonnull final Function deploymentId) {
+ this(service, deploymentId, "default");
+ }
+
+ @Nonnull
+ @Override
+ public Destination destination() {
+ final var dest = service.baseDestinationHandler.apply(service);
+ final var builder = service.builderHandler.apply(service, dest);
+ destinationSetUrl(builder, dest);
+ destinationSetHeaders(builder);
+ return builder.build();
+ }
+
+ @Nonnull
+ @Override
+ public ApiClient client() {
+ final var destination = destination();
+ return service.clientHandler.apply(service, destination);
+ }
+
+ /**
+ * Update and set the URL for the destination.
+ *
+ * @param builder The destination builder.
+ * @param dest The original destination reference.
+ */
+ protected void destinationSetUrl(
+ @Nonnull final DefaultHttpDestination.Builder builder, @Nonnull final Destination dest) {
+ String uri = dest.get(DestinationProperty.URI).get();
+ if (!uri.endsWith("/")) {
+ uri = uri + "/";
+ }
+ builder.uri(uri + "v2/inference/deployments/%s/".formatted(getDeploymentId()));
+ }
+
+ /**
+ * Update and set the default request headers for the destination.
+ *
+ * @param builder The destination builder.
+ */
+ protected void destinationSetHeaders(@Nonnull final DefaultHttpDestination.Builder builder) {
+ builder.property(AI_RESOURCE_GROUP, getResourceGroup());
+ }
+
+ /**
+ * Set the resource group.
+ *
+ * @param resourceGroup The resource group.
+ * @return A new instance of the AI Core service.
+ */
+ @Nonnull
+ public AiCoreDeployment withResourceGroup(@Nonnull final String resourceGroup) {
+ return new AiCoreDeployment(service, deploymentId, resourceGroup);
+ }
+
+ /**
+ * Get the deployment id.
+ *
+ * @return The deployment id.
+ */
+ @Nonnull
+ protected String getDeploymentId() {
+ return deploymentId.apply(this);
+ }
+}
diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreDestination.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreDestination.java
new file mode 100644
index 00000000..a61bd494
--- /dev/null
+++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreDestination.java
@@ -0,0 +1,28 @@
+package com.sap.ai.sdk.core;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
+import javax.annotation.Nonnull;
+
+/** Container for an API client and destination. */
+@FunctionalInterface
+public interface AiCoreDestination {
+ /**
+ * Get the destination.
+ *
+ * @return the destination
+ */
+ @Nonnull
+ Destination destination();
+
+ /**
+ * Get the API client.
+ *
+ * @return the API client
+ */
+ @Nonnull
+ default ApiClient client() {
+ final var destination = destination();
+ return new ApiClient(destination);
+ }
+}
diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreService.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreService.java
new file mode 100644
index 00000000..c063b3c7
--- /dev/null
+++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreService.java
@@ -0,0 +1,181 @@
+package com.sap.ai.sdk.core;
+
+import static com.sap.ai.sdk.core.DestinationResolver.AI_CLIENT_TYPE_KEY;
+import static com.sap.ai.sdk.core.DestinationResolver.AI_CLIENT_TYPE_VALUE;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.google.common.collect.Iterables;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationProperty;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException;
+import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
+import java.util.NoSuchElementException;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.client.BufferingClientHttpRequestFactory;
+import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.web.client.RestTemplate;
+
+/** Connectivity convenience methods for AI Core. */
+@Slf4j
+@RequiredArgsConstructor
+public class AiCoreService implements AiCoreDestination {
+
+ final Function baseDestinationHandler;
+ final BiFunction clientHandler;
+ final BiFunction builderHandler;
+
+ private static final DeploymentCache DEPLOYMENT_CACHE = new DeploymentCache();
+
+ /** The default constructor. */
+ public AiCoreService() {
+ this(
+ AiCoreService::getBaseDestination,
+ AiCoreService::getApiClient,
+ AiCoreService::getDestinationBuilder);
+ }
+
+ @Nonnull
+ @Override
+ public ApiClient client() {
+ final var destination = destination();
+ return clientHandler.apply(this, destination);
+ }
+
+ @Nonnull
+ @Override
+ public Destination destination() {
+ final var dest = baseDestinationHandler.apply(this);
+ return builderHandler.apply(this, dest).build();
+ }
+
+ /**
+ * Set a specific base destination.
+ *
+ * @param destination The destination to be used for AI Core service calls.
+ * @return A new instance of the AI Core Service based on the provided destination.
+ */
+ @Nonnull
+ public AiCoreService withDestination(@Nonnull final Destination destination) {
+ return new AiCoreService((service) -> destination, clientHandler, builderHandler);
+ }
+
+ /**
+ * Set a specific deployment by id.
+ *
+ * @param deploymentId The deployment id to be used for AI Core service calls.
+ * @return A new instance of the AI Core Deployment.
+ */
+ @Nonnull
+ public AiCoreDeployment forDeployment(@Nonnull final String deploymentId) {
+ return new AiCoreDeployment(this, obj -> deploymentId);
+ }
+
+ /**
+ * Set a specific deployment by model name. If there are multiple deployments of the same model,
+ * the first one is returned.
+ *
+ * @param modelName The model name to be used for AI Core service calls.
+ * @return A new instance of the AI Core Deployment.
+ * @throws NoSuchElementException if no running deployment is found for the model.
+ */
+ @Nonnull
+ public AiCoreDeployment forDeploymentByModel(@Nonnull final String modelName)
+ throws NoSuchElementException {
+ return new AiCoreDeployment(
+ this,
+ obj ->
+ DEPLOYMENT_CACHE.getDeploymentIdByModel(
+ this.client(), obj.getResourceGroup(), modelName));
+ }
+
+ /**
+ * Set a specific deployment by scenario id. If there are multiple deployments of the same model,
+ * the first one is returned.
+ *
+ * @param scenarioId The scenario id to be used for AI Core service calls.
+ * @return A new instance of the AI Core Deployment.
+ * @throws NoSuchElementException if no running deployment is found for the scenario.
+ */
+ @Nonnull
+ public AiCoreDeployment forDeploymentByScenario(@Nonnull final String scenarioId)
+ throws NoSuchElementException {
+ return new AiCoreDeployment(
+ this,
+ obj ->
+ DEPLOYMENT_CACHE.getDeploymentIdByScenario(
+ this.client(), obj.getResourceGroup(), scenarioId));
+ }
+
+ /**
+ * Get a destination using the default service binding loading logic.
+ *
+ * @return The destination.
+ * @throws DestinationAccessException If the destination cannot be accessed.
+ * @throws DestinationNotFoundException If the destination cannot be found.
+ */
+ @Nonnull
+ protected Destination getBaseDestination()
+ throws DestinationAccessException, DestinationNotFoundException {
+ final var serviceKey = System.getenv("AICORE_SERVICE_KEY");
+ return DestinationResolver.getDestination(serviceKey);
+ }
+
+ /**
+ * Get the destination builder with adjustments for AI Core.
+ *
+ * @param destination The destination.
+ * @return The destination builder.
+ */
+ @Nonnull
+ protected DefaultHttpDestination.Builder getDestinationBuilder(
+ @Nonnull final Destination destination) {
+ final var builder = DefaultHttpDestination.fromDestination(destination);
+ String uri = destination.get(DestinationProperty.URI).get();
+ if (!uri.endsWith("/")) {
+ uri = uri + "/";
+ }
+ builder.uri(uri + "v2/").property(AI_CLIENT_TYPE_KEY, AI_CLIENT_TYPE_VALUE);
+ return builder;
+ }
+
+ /**
+ * Get a destination using the default service binding loading logic.
+ *
+ * @return The destination.
+ * @throws DestinationAccessException If the destination cannot be accessed.
+ * @throws DestinationNotFoundException If the destination cannot be found.
+ */
+ @SuppressWarnings("UnstableApiUsage")
+ @Nonnull
+ protected ApiClient getApiClient(@Nonnull final Destination destination) {
+ final var objectMapper =
+ new Jackson2ObjectMapperBuilder()
+ .modules(new JavaTimeModule())
+ .visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE)
+ .visibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.NONE)
+ .serializationInclusion(JsonInclude.Include.NON_NULL) // THIS STOPS `null` serialization
+ .build();
+
+ 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(objectMapper));
+ rt.setRequestFactory(new BufferingClientHttpRequestFactory(httpRequestFactory));
+
+ return new ApiClient(rt).setBasePath(destination.asHttp().getUri().toString());
+ }
+}
diff --git a/core/src/main/java/com/sap/ai/sdk/core/Core.java b/core/src/main/java/com/sap/ai/sdk/core/Core.java
deleted file mode 100644
index e1d83d32..00000000
--- a/core/src/main/java/com/sap/ai/sdk/core/Core.java
+++ /dev/null
@@ -1,300 +0,0 @@
-package com.sap.ai.sdk.core;
-
-import static com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf.TECHNICAL_USER_PROVIDER;
-
-import com.fasterxml.jackson.annotation.JsonAutoDetect;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.fasterxml.jackson.annotation.PropertyAccessor;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
-import com.sap.ai.sdk.core.client.DeploymentApi;
-import com.sap.ai.sdk.core.client.model.AiDeployment;
-import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingAccessor;
-import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingBuilder;
-import com.sap.cloud.environment.servicebinding.api.ServiceBindingAccessor;
-import com.sap.cloud.environment.servicebinding.api.ServiceBindingMerger;
-import com.sap.cloud.environment.servicebinding.api.ServiceIdentifier;
-import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
-import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
-import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
-import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
-import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader;
-import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions;
-import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.NoSuchElementException;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.client.BufferingClientHttpRequestFactory;
-import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
-import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
-import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
-import org.springframework.web.client.RestTemplate;
-
-/** Connectivity convenience methods for AI Core. */
-@Slf4j
-public class Core {
-
- /**
- * Requires an AI Core service binding.
- *
- * @param resourceGroup the resource group.
- * @return a generic Orchestration ApiClient.
- */
- @Nonnull
- public static ApiClient getOrchestrationClient(@Nonnull final String resourceGroup) {
- return getClient(
- getDestinationForDeployment(getOrchestrationDeployment(resourceGroup), resourceGroup));
- }
-
- /**
- * Get the deployment id from the scenario id. If there are multiple deployments of the same
- * scenario id, the first one is returned.
- *
- * @param resourceGroup the resource group.
- * @return the deployment id
- * @throws NoSuchElementException if no deployment is found for the scenario id.
- */
- private static String getOrchestrationDeployment(@Nonnull final String resourceGroup)
- throws NoSuchElementException {
- final var deployments = new DeploymentApi(getClient(getDestination())).query(resourceGroup);
-
- return deployments.getResources().stream()
- .filter(deployment -> "orchestration".equals(deployment.getScenarioId()))
- .map(AiDeployment::getId)
- .findFirst()
- .orElseThrow(
- () -> new NoSuchElementException("No deployment found with scenario id orchestration"));
- }
-
- /**
- * Requires an AI Core service binding OR a service key in the environment variable {@code
- * AICORE_SERVICE_KEY}.
- *
- * @return a generic AI Core ApiClient.
- */
- @Nonnull
- public static ApiClient getClient() {
- return getClient(getDestination());
- }
-
- /**
- * Get a generic AI Core ApiClient for testing purposes.
- *
- * @param destination The destination to use.
- * @return a generic AI Core ApiClient.
- */
- @Nonnull
- @SuppressWarnings("UnstableApiUsage")
- public static ApiClient getClient(@Nonnull final Destination destination) {
- final var objectMapper =
- new Jackson2ObjectMapperBuilder()
- .modules(new JavaTimeModule())
- .visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE)
- .visibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.NONE)
- .serializationInclusion(JsonInclude.Include.NON_NULL) // THIS STOPS `null` serialization
- .build();
-
- final var httpRequestFactory = new HttpComponentsClientHttpRequestFactory();
- httpRequestFactory.setHttpClient(ApacheHttpClient5Accessor.getHttpClient(destination));
-
- final var restTemplate = new RestTemplate();
- restTemplate.getMessageConverters().stream()
- .filter(MappingJackson2HttpMessageConverter.class::isInstance)
- .map(MappingJackson2HttpMessageConverter.class::cast)
- .forEach(converter -> converter.setObjectMapper(objectMapper));
- restTemplate.setRequestFactory(new BufferingClientHttpRequestFactory(httpRequestFactory));
-
- return new ApiClient(restTemplate).setBasePath(destination.asHttp().getUri().toString());
- }
-
- /**
- * Get a destination pointing to the AI Core service.
- *
- *
Requires an AI Core service binding OR a service key in the environment variable {@code
- * AICORE_SERVICE_KEY}.
- *
- * @return a destination pointing to the AI Core service.
- */
- @Nonnull
- public static Destination getDestination() {
- final var serviceKey = System.getenv("AICORE_SERVICE_KEY");
- return getDestination(serviceKey);
- }
-
- /**
- * For testing only
- *
- *
Get a destination pointing to the AI Core service.
- *
- * @param serviceKey The service key in JSON format.
- * @return a destination pointing to the AI Core service.
- */
- static HttpDestination getDestination(@Nullable final String serviceKey) {
- final var serviceKeyPresent = serviceKey != null;
- final var aiCoreBindingPresent =
- DefaultServiceBindingAccessor.getInstance().getServiceBindings().stream()
- .anyMatch(
- serviceBinding ->
- ServiceIdentifier.AI_CORE.equals(
- serviceBinding.getServiceIdentifier().orElse(null)));
-
- if (!aiCoreBindingPresent && serviceKeyPresent) {
- addServiceBinding(serviceKey);
- }
-
- // get a destination pointing to the AI Core service
- final var opts =
- ServiceBindingDestinationOptions.forService(ServiceIdentifier.AI_CORE)
- .onBehalfOf(TECHNICAL_USER_PROVIDER)
- .build();
- var destination = ServiceBindingDestinationLoader.defaultLoaderChain().getDestination(opts);
-
- destination =
- DefaultHttpDestination.fromDestination(destination)
- // append the /v2 path here, so we don't have to do it in every request when using the
- // generated code this is actually necessary, because the generated code assumes this
- // path to be present on the destination
- .uri(destination.getUri().resolve("/v2"))
- .header("AI-Client-Type", "AI SDK Java")
- .build();
- return destination;
- }
-
- /**
- * Set the AI Core service key as the service binding. This is used for local testing.
- *
- * @param serviceKey The service key in JSON format.
- * @throws AiCoreCredentialsInvalidException if the JSON service key cannot be parsed.
- */
- private static void addServiceBinding(@Nonnull final String serviceKey) {
- log.info(
- """
- Found a service key in environment variable "AICORE_SERVICE_KEY".
- Using a service key is recommended for local testing only.
- Bind the AI Core service to the application for productive usage.""");
-
- var credentials = new HashMap();
- try {
- credentials = new ObjectMapper().readValue(serviceKey, new TypeReference<>() {});
- } catch (JsonProcessingException e) {
- throw new AiCoreCredentialsInvalidException(
- "Error in parsing service key from the \"AICORE_SERVICE_KEY\" environment variable.", e);
- }
-
- final var binding =
- new DefaultServiceBindingBuilder()
- .withServiceIdentifier(ServiceIdentifier.AI_CORE)
- .withCredentials(credentials)
- .build();
- final ServiceBindingAccessor accessor = DefaultServiceBindingAccessor.getInstance();
- final var newAccessor =
- new ServiceBindingMerger(
- List.of(accessor, () -> List.of(binding)), ServiceBindingMerger.KEEP_EVERYTHING);
- DefaultServiceBindingAccessor.setInstance(newAccessor);
- }
-
- /** Exception thrown when the JSON AI Core service key is invalid. */
- static class AiCoreCredentialsInvalidException extends RuntimeException {
- public AiCoreCredentialsInvalidException(
- @Nonnull final String message, @Nonnull final Throwable cause) {
- super(message, cause);
- }
- }
-
- /**
- * Get a destination pointing to the inference endpoint of a deployment on AI Core. Requires an
- * AI Core service binding.
- *
- * @param deploymentId The deployment id.
- * @param resourceGroup The resource group.
- * @return a destination that can be used for inference calls.
- */
- @Nonnull
- public static Destination getDestinationForDeployment(
- @Nonnull final String deploymentId, @Nonnull final String resourceGroup) {
- final var destination = getDestination().asHttp();
- final DefaultHttpDestination.Builder builder =
- DefaultHttpDestination.fromDestination(destination)
- .uri(
- destination
- .getUri()
- .resolve("/v2/inference/deployments/%s/".formatted(deploymentId)));
-
- builder.header("AI-Resource-Group", resourceGroup);
-
- return builder.build();
- }
-
- /**
- * Get a destination pointing to the inference endpoint of a deployment on AI Core. Requires an
- * AI Core service binding.
- *
- * @param modelName The name of the foundation model that is used by a deployment.
- * @param resourceGroup The resource group.
- * @return a destination that can be used for inference calls.
- */
- @Nonnull
- public static Destination getDestinationForModel(
- @Nonnull final String modelName, @Nonnull final String resourceGroup) {
- return getDestinationForDeployment(
- getDeploymentForModel(modelName, resourceGroup), resourceGroup);
- }
-
- /**
- * Get the deployment id from the model name. If there are multiple deployments of the same model,
- * the first one is returned.
- *
- * @param modelName the model name.
- * @param resourceGroup the resource group.
- * @return the deployment id
- * @throws NoSuchElementException if no deployment is found for the model name.
- */
- private static String getDeploymentForModel(
- @Nonnull final String modelName, @Nonnull final String resourceGroup)
- throws NoSuchElementException {
- final var deployments = new DeploymentApi(getClient()).query(resourceGroup);
-
- return deployments.getResources().stream()
- .filter(deployment -> isDeploymentOfModel(modelName, deployment))
- .map(AiDeployment::getId)
- .findFirst()
- .orElseThrow(
- () -> new NoSuchElementException("No deployment found with model name " + modelName));
- }
-
- /** This exists because getBackendDetails() is broken */
- private static boolean isDeploymentOfModel(
- @Nonnull final String modelName, @Nonnull final AiDeployment deployment) {
- final var deploymentDetails = deployment.getDetails();
- // The AI Core specification doesn't mention that this is nullable, but it can be.
- // Remove this check when the specification is fixed.
- if (deploymentDetails == null) {
- return false;
- }
- final var resources = deploymentDetails.getResources();
- if (resources == null) {
- return false;
- }
- Object detailsObject = resources.getBackendDetails();
- // workaround for AIWDF-2124
- if (detailsObject == null) {
- if (!resources.getCustomFieldNames().contains("backend_details")) {
- return false;
- }
- detailsObject = resources.getCustomField("backend_details");
- }
-
- if (detailsObject instanceof Map, ?> details
- && details.get("model") instanceof Map, ?> model
- && model.get("name") instanceof String name) {
- return modelName.equals(name);
- }
- return false;
- }
-}
diff --git a/core/src/main/java/com/sap/ai/sdk/core/DeploymentCache.java b/core/src/main/java/com/sap/ai/sdk/core/DeploymentCache.java
new file mode 100644
index 00000000..aa54ceaf
--- /dev/null
+++ b/core/src/main/java/com/sap/ai/sdk/core/DeploymentCache.java
@@ -0,0 +1,169 @@
+package com.sap.ai.sdk.core;
+
+import com.sap.ai.sdk.core.client.DeploymentApi;
+import com.sap.ai.sdk.core.client.model.AiDeployment;
+import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
+import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.Set;
+import javax.annotation.Nonnull;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Cache for deployment IDs. This class is used to get the deployment id for the orchestration
+ * scenario or for a model.
+ */
+@Slf4j
+class DeploymentCache {
+
+ /** Cache for deployment ids. The key is the model name and the value is the deployment id. */
+ protected final Set CACHE = new HashSet<>();
+
+ /**
+ * Remove all entries from the cache then load all deployments into the cache.
+ *
+ *
Call this whenever a deployment is deleted.
+ *
+ * @param client the API client to query deployments.
+ * @param resourceGroup the resource group, usually "default".
+ */
+ public void resetCache(@Nonnull final ApiClient client, @Nonnull final String resourceGroup) {
+ clearCache();
+ loadCache(client, resourceGroup);
+ }
+
+ /**
+ * Remove all entries from the cache.
+ *
+ *
Call {@link #resetCache} whenever a deployment is deleted.
+ */
+ protected void clearCache() {
+ CACHE.clear();
+ }
+
+ /**
+ * Load all deployments into the cache
+ *
+ *
Call {@link #resetCache} whenever a deployment is deleted.
+ *
+ * @param client the API client to query deployments.
+ * @param resourceGroup the resource group, usually "default".
+ */
+ protected void loadCache(@Nonnull final ApiClient client, @Nonnull final String resourceGroup) {
+ try {
+ final var deployments = new DeploymentApi(client).query(resourceGroup).getResources();
+ CACHE.addAll(deployments);
+ } catch (final OpenApiRequestException e) {
+ log.error("Failed to load deployments into cache", e);
+ }
+ }
+
+ /**
+ * Get the deployment id from the foundation model name. If there are multiple deployments of the
+ * same model, the first one is returned.
+ *
+ * @param client the API client to maybe reset the cache if the deployment is not found.
+ * @param resourceGroup the resource group, usually "default".
+ * @param modelName the name of the foundation model.
+ * @return the deployment id.
+ * @throws NoSuchElementException if no running deployment is found for the model.
+ */
+ @Nonnull
+ public String getDeploymentIdByModel(
+ @Nonnull final ApiClient client,
+ @Nonnull final String resourceGroup,
+ @Nonnull final String modelName)
+ throws NoSuchElementException {
+ return getDeploymentIdByModel(modelName)
+ .orElseGet(
+ () -> {
+ resetCache(client, resourceGroup);
+ return getDeploymentIdByModel(modelName)
+ .orElseThrow(
+ () ->
+ new NoSuchElementException(
+ "No running deployment found for model: " + modelName));
+ });
+ }
+
+ private Optional getDeploymentIdByModel(@Nonnull final String modelName) {
+ return CACHE.stream()
+ .filter(deployment -> isDeploymentOfModel(modelName, deployment))
+ .findFirst()
+ .map(AiDeployment::getId);
+ }
+
+ /**
+ * Get the deployment id from the scenario id. If there are multiple deployments of the * same
+ * model, the first one is returned.
+ *
+ * @param client the API client to maybe reset the cache if the deployment is not found.
+ * @param resourceGroup the resource group, usually "default".
+ * @param scenarioId the scenario id, can be "orchestration".
+ * @return the deployment id.
+ * @throws NoSuchElementException if no running deployment is found for the scenario.
+ */
+ @Nonnull
+ public String getDeploymentIdByScenario(
+ @Nonnull final ApiClient client,
+ @Nonnull final String resourceGroup,
+ @Nonnull final String scenarioId)
+ throws NoSuchElementException {
+ return getDeploymentIdByScenario(scenarioId)
+ .orElseGet(
+ () -> {
+ resetCache(client, resourceGroup);
+ return getDeploymentIdByScenario(scenarioId)
+ .orElseThrow(
+ () ->
+ new NoSuchElementException(
+ "No running deployment found for scenario: " + scenarioId));
+ });
+ }
+
+ private Optional getDeploymentIdByScenario(@Nonnull final String scenarioId) {
+ return CACHE.stream()
+ .filter(deployment -> scenarioId.equals(deployment.getScenarioId()))
+ .findFirst()
+ .map(AiDeployment::getId);
+ }
+
+ /**
+ * This exists because getBackendDetails() is broken
+ *
+ * @param modelName The model name.
+ * @param deployment The deployment.
+ * @return true if the deployment is of the model.
+ */
+ protected static boolean isDeploymentOfModel(
+ @Nonnull final String modelName, @Nonnull final AiDeployment deployment) {
+ final var deploymentDetails = deployment.getDetails();
+ // The AI Core specification doesn't mention that this is nullable, but it can be.
+ // Remove this check when the specification is fixed.
+ if (deploymentDetails == null) {
+ return false;
+ }
+ final var resources = deploymentDetails.getResources();
+ if (resources == null) {
+ return false;
+ }
+ Object detailsObject = resources.getBackendDetails();
+ // workaround for AIWDF-2124
+ if (detailsObject == null) {
+ if (!resources.getCustomFieldNames().contains("backend_details")) {
+ return false;
+ }
+ detailsObject = resources.getCustomField("backend_details");
+ }
+
+ if (detailsObject instanceof Map, ?> details
+ && details.get("model") instanceof Map, ?> model
+ && model.get("name") instanceof String name) {
+ return modelName.equals(name);
+ }
+ return false;
+ }
+}
diff --git a/core/src/main/java/com/sap/ai/sdk/core/DestinationResolver.java b/core/src/main/java/com/sap/ai/sdk/core/DestinationResolver.java
new file mode 100644
index 00000000..3e2eebd8
--- /dev/null
+++ b/core/src/main/java/com/sap/ai/sdk/core/DestinationResolver.java
@@ -0,0 +1,116 @@
+package com.sap.ai.sdk.core;
+
+import static com.google.common.collect.Iterables.tryFind;
+import static com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf.TECHNICAL_USER_PROVIDER;
+import static com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions.forService;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingAccessor;
+import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingBuilder;
+import com.sap.cloud.environment.servicebinding.api.ServiceBindingAccessor;
+import com.sap.cloud.environment.servicebinding.api.ServiceBindingMerger;
+import com.sap.cloud.environment.servicebinding.api.ServiceIdentifier;
+import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+/** Utility class to resolve the destination pointing to the AI Core service. */
+@Slf4j
+class DestinationResolver {
+ static final String AI_CLIENT_TYPE_KEY = "URL.headers.AI-Client-Type";
+ static final String AI_CLIENT_TYPE_VALUE = "AI SDK Java";
+
+ @Getter(AccessLevel.PROTECTED)
+ @Nonnull
+ private static ServiceBindingAccessor accessor = DefaultServiceBindingAccessor.getInstance();
+
+ /**
+ * For testing only
+ *
+ *
Get a destination pointing to the AI Core service.
+ *
+ * @param serviceKey The service key in JSON format.
+ * @return a destination pointing to the AI Core service.
+ */
+ @SuppressWarnings("UnstableApiUsage")
+ static HttpDestination getDestination(@Nullable final String serviceKey) {
+ final Predicate