com.sap.cds
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java
deleted file mode 100644
index 40374bb..0000000
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
- */
-package com.sap.cds.feature.aicore.api;
-
-import com.sap.cds.services.cds.CqnService;
-import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
-
-/**
- * CAP service contract for SAP AI Core integration.
- *
- * The service exposes resource-group, configuration and deployment lifecycle as CDS entities
- * (see {@link #RESOURCE_GROUPS}, {@link #DEPLOYMENTS}, {@link #CONFIGURATIONS}) and additionally
- * provides programmatic helpers to:
- *
- *
- * Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating
- * it on-demand when multi-tenancy is enabled.
- * Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link
- * #deploymentId(String, ModelDeploymentSpec)}).
- * Build an {@link ApiClient} preconfigured for inference against a specific deployment
- * ({@link #inferenceClient(String, String)}).
- *
- *
- * The implementation is tenant-aware: it reads the current tenant from the {@code
- * RequestContext}. Callers do not need to pass tenant identifiers explicitly.
- */
-public interface AICoreService extends CqnService {
-
- /** Default service name under which an instance is registered in the service catalog. */
- String DEFAULT_NAME = "AICore";
-
- /** Qualified name of the {@code resourceGroups} entity exposed by this service. */
- String RESOURCE_GROUPS = "AICore.resourceGroups";
-
- /** Qualified name of the {@code deployments} entity exposed by this service. */
- String DEPLOYMENTS = "AICore.deployments";
-
- /** Qualified name of the {@code configurations} entity exposed by this service. */
- String CONFIGURATIONS = "AICore.configurations";
-
- /**
- * Returns the AI Core resource group ID associated with the current tenant.
- *
- *
When multi-tenancy is disabled the configured default resource group is returned. When
- * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and
- * created on first call if it does not exist.
- *
- * @return the AI Core resource group ID for the current tenant
- */
- String resourceGroup();
-
- /**
- * Returns the deployment ID for the given model spec inside the given resource group.
- *
- *
Looks up an existing RUNNING/PENDING deployment that matches the spec, otherwise creates a
- * configuration (if missing) and a new deployment, then polls until the deployment reaches
- * RUNNING. Results are cached per {@code (resourceGroupId, configurationName)} pair.
- *
- * @param resourceGroupId the AI Core resource group to operate in
- * @param spec the deployment specification (scenario, executable, configuration name and
- * existing-match predicate)
- * @return the deployment ID
- */
- String deploymentId(String resourceGroupId, ModelDeploymentSpec spec);
-
- /**
- * Returns an {@link ApiClient} preconfigured with the inference destination for the given
- * deployment, suitable for constructing foundation-model SDK clients.
- *
- * @param resourceGroupId the AI Core resource group containing the deployment
- * @param deploymentId the deployment ID returned by {@link #deploymentId(String,
- * ModelDeploymentSpec)}
- * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint
- */
- ApiClient inferenceClient(String resourceGroupId, String deploymentId);
-}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java
new file mode 100644
index 0000000..04857c0
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java
@@ -0,0 +1,44 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.api;
+
+import com.sap.cds.services.EventContext;
+import com.sap.cds.services.EventName;
+
+/**
+ * Typed {@link EventContext} for the {@code deploymentId} event.
+ *
+ *
Emitted on the AI Core service to resolve (or create) a deployment matching the given spec
+ * inside the given resource group. The ON handler performs cache lookup, retry, configuration
+ * creation, deployment creation and polling.
+ */
+@EventName(DeploymentIdContext.EVENT)
+public interface DeploymentIdContext extends EventContext {
+
+ /** Event name constant. */
+ String EVENT = "deploymentId";
+
+ /** Returns the resource group ID to operate in. */
+ String getResourceGroupId();
+
+ /** Sets the resource group ID to operate in. */
+ void setResourceGroupId(String resourceGroupId);
+
+ /** Returns the deployment specification. */
+ ModelDeploymentSpec getSpec();
+
+ /** Sets the deployment specification. */
+ void setSpec(ModelDeploymentSpec spec);
+
+ /** Returns the resolved deployment ID (set by the ON handler). */
+ String getResult();
+
+ /** Sets the resolved deployment ID. */
+ void setResult(String deploymentId);
+
+ /** Creates a new context instance. */
+ static DeploymentIdContext create() {
+ return EventContext.create(DeploymentIdContext.class, null);
+ }
+}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java
new file mode 100644
index 0000000..8ed8710
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java
@@ -0,0 +1,44 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.api;
+
+import com.sap.cds.services.EventContext;
+import com.sap.cds.services.EventName;
+import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
+
+/**
+ * Typed {@link EventContext} for the {@code inferenceClient} event.
+ *
+ *
Emitted on the AI Core service to build an {@link ApiClient} preconfigured with the inference
+ * destination for the given deployment.
+ */
+@EventName(InferenceClientContext.EVENT)
+public interface InferenceClientContext extends EventContext {
+
+ /** Event name constant. */
+ String EVENT = "inferenceClient";
+
+ /** Returns the resource group ID containing the deployment. */
+ String getResourceGroupId();
+
+ /** Sets the resource group ID containing the deployment. */
+ void setResourceGroupId(String resourceGroupId);
+
+ /** Returns the deployment ID. */
+ String getDeploymentId();
+
+ /** Sets the deployment ID. */
+ void setDeploymentId(String deploymentId);
+
+ /** Returns the configured {@link ApiClient} (set by the ON handler). */
+ ApiClient getResult();
+
+ /** Sets the configured {@link ApiClient}. */
+ void setResult(ApiClient client);
+
+ /** Creates a new context instance. */
+ static InferenceClientContext create() {
+ return EventContext.create(InferenceClientContext.class, null);
+ }
+}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java
index a51d1b6..72b29f3 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java
@@ -9,8 +9,8 @@
import java.util.function.Predicate;
/**
- * Describes a target AI Core deployment used by {@link AICoreService#deploymentId(String,
- * ModelDeploymentSpec)} to look up or create a deployment inside a resource group.
+ * Describes a target AI Core deployment used by the {@code deploymentId} event to look up or create
+ * a deployment inside a resource group.
*
*
The spec carries the AI Core scenario/executable identification, the human-readable
* configuration name (used as a stable key for caching and idempotent reuse), the parameter
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java
new file mode 100644
index 0000000..9d08328
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java
@@ -0,0 +1,44 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.api;
+
+import com.sap.cds.services.EventContext;
+import com.sap.cds.services.EventName;
+
+/**
+ * Typed {@link EventContext} for the {@code resourceGroup} event.
+ *
+ *
Emitted on the AI Core service to resolve the AI Core resource group ID for the current
+ * tenant. In multi-tenancy mode, the resource group is created on-demand if it does not exist. In
+ * single-tenancy mode, the configured default resource group is returned.
+ *
+ *
If {@link #getTenantId()} is non-null, the handler uses the explicit tenant ID. Otherwise, the
+ * current tenant is read from the {@code RequestContext}.
+ */
+@EventName(ResourceGroupContext.EVENT)
+public interface ResourceGroupContext extends EventContext {
+
+ /** Event name constant. */
+ String EVENT = "resourceGroup";
+
+ /**
+ * Returns the explicit tenant ID (optional). If {@code null}, the handler reads the tenant from
+ * the current {@code RequestContext}.
+ */
+ String getTenantId();
+
+ /** Sets an explicit tenant ID. */
+ void setTenantId(String tenantId);
+
+ /** Returns the resolved resource group ID (set by the ON handler). */
+ String getResult();
+
+ /** Sets the resolved resource group ID. */
+ void setResult(String resourceGroupId);
+
+ /** Creates a new context instance. */
+ static ResourceGroupContext create() {
+ return EventContext.create(ResourceGroupContext.class, null);
+ }
+}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java
new file mode 100644
index 0000000..e004b12
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java
@@ -0,0 +1,23 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.core;
+
+import com.sap.ai.sdk.core.AiCoreService;
+import com.sap.ai.sdk.core.client.ConfigurationApi;
+import com.sap.ai.sdk.core.client.DeploymentApi;
+import com.sap.ai.sdk.core.client.ResourceGroupApi;
+
+/**
+ * Holder for the AI Core SDK API clients, built once from the service binding at startup.
+ *
+ * @param deploymentApi client for deployment CRUD operations
+ * @param configurationApi client for configuration CRUD operations
+ * @param resourceGroupApi client for resource-group CRUD operations
+ * @param sdkService the AI Core SDK service for inference destination resolution
+ */
+public record AICoreClients(
+ DeploymentApi deploymentApi,
+ ConfigurationApi configurationApi,
+ ResourceGroupApi resourceGroupApi,
+ AiCoreService sdkService) {}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java
new file mode 100644
index 0000000..84ded92
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java
@@ -0,0 +1,58 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.core;
+
+import com.sap.cds.services.environment.CdsEnvironment;
+
+/**
+ * Immutable configuration for the AI Core plugin, read once from {@link CdsEnvironment} at startup.
+ *
+ * @param defaultResourceGroup the resource group to use when multi-tenancy is disabled
+ * @param resourceGroupPrefix prefix for tenant-specific resource groups (e.g. "cds-")
+ * @param maxRetries max retry attempts for transient AI Core errors
+ * @param initialDelayMs initial backoff delay in milliseconds
+ * @param multiTenancyEnabled whether multi-tenancy is active
+ */
+public record AICoreConfig(
+ String defaultResourceGroup,
+ String resourceGroupPrefix,
+ int maxRetries,
+ long initialDelayMs,
+ boolean multiTenancyEnabled) {
+
+ /** The AI Core resource-group label key used to associate groups with CDS tenants. */
+ public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID";
+
+ private static final String DEFAULT_RESOURCE_GROUP = "default";
+ private static final String DEFAULT_RESOURCE_GROUP_PREFIX = "cds-";
+ private static final int DEFAULT_MAX_RETRIES = 10;
+ private static final long DEFAULT_INITIAL_DELAY_MS = 300;
+
+ public AICoreConfig {
+ if (maxRetries < 1) {
+ throw new IllegalArgumentException("cds.ai.core.maxRetries must be >= 1, got " + maxRetries);
+ }
+ if (initialDelayMs < 1) {
+ throw new IllegalArgumentException(
+ "cds.ai.core.initialDelayMs must be >= 1, got " + initialDelayMs);
+ }
+ if (defaultResourceGroup == null || defaultResourceGroup.isBlank()) {
+ throw new IllegalArgumentException("cds.ai.core.resourceGroup must not be blank");
+ }
+ if (resourceGroupPrefix == null) {
+ throw new IllegalArgumentException("cds.ai.core.resourceGroupPrefix must not be null");
+ }
+ }
+
+ /** Creates an {@code AICoreConfig} from the runtime environment properties. */
+ public static AICoreConfig from(CdsEnvironment env, boolean multiTenancyEnabled) {
+ return new AICoreConfig(
+ env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP),
+ env.getProperty(
+ "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX),
+ env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES),
+ env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS),
+ multiTenancyEnabled);
+ }
+}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java
index bfd4c8c..8ad1bda 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java
@@ -7,14 +7,18 @@
import com.sap.ai.sdk.core.client.ConfigurationApi;
import com.sap.ai.sdk.core.client.DeploymentApi;
import com.sap.ai.sdk.core.client.ResourceGroupApi;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler;
+import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler;
+import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler;
import com.sap.cds.feature.aicore.core.handler.ActionHandler;
import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler;
import com.sap.cds.feature.aicore.core.handler.DeploymentHandler;
+import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler;
+import com.sap.cds.feature.aicore.core.handler.MockAICoreSetupHandler;
import com.sap.cds.feature.aicore.core.handler.MockEntityHandler;
import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
import com.sap.cds.services.environment.CdsProperties;
+import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig;
import com.sap.cds.services.mt.DeploymentService;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.runtime.CdsRuntimeConfiguration;
@@ -24,103 +28,126 @@
import org.slf4j.LoggerFactory;
/**
- * {@link CdsRuntimeConfiguration} that wires the {@code AICore} service and its event handlers into
- * the CAP Java runtime.
+ * {@link CdsRuntimeConfiguration} that wires the {@code AICore} remote service and its event
+ * handlers into the CAP Java runtime.
*
- *
Detects the presence of an SAP AI Core service binding (either a regular service binding or
- * the {@code AICORE_SERVICE_KEY} environment variable used for hybrid local testing) and registers
- * either {@link AICoreServiceImpl} (when a binding is found) or {@link MockAICoreServiceImpl}
- * (no-binding fallback). Picked up automatically through {@code ServiceLoader}; applications do not
- * need to instantiate this class directly.
+ *
In the {@link #environment} phase, a {@link RemoteServiceConfig} entry for "AICore" is
+ * injected into the runtime properties so the framework's {@code RemoteServiceConfiguration}
+ * auto-creates the service instance from the CDS model. This follows the same pattern used by
+ * {@code cds-feature-notifications}.
+ *
+ *
In the {@link #eventHandlers} phase, production or mock handlers are registered depending on
+ * whether an AI Core service binding is present.
*/
public class AICoreServiceConfiguration implements CdsRuntimeConfiguration {
private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class);
- private static boolean hasAICoreBinding(CdsRuntime runtime) {
- boolean hasServiceBinding =
- runtime
- .getEnvironment()
- .getServiceBindings()
- .filter(b -> ServiceBindingUtils.matches(b, "aicore"))
- .findFirst()
- .isPresent();
- if (hasServiceBinding) {
- return true;
- }
- String envKey = System.getenv("AICORE_SERVICE_KEY");
- return envKey != null && !envKey.isBlank();
- }
+ private AICoreConfig config;
+ private AICoreClients clients;
+ private DeploymentResolver resolver;
/**
- * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url}
- * property or the presence of a {@link DeploymentService} in the service catalog. This aligns
- * with the standard CAP Java convention — no custom property flag is needed.
+ * Injects a {@link RemoteServiceConfig} for "AICore" into the runtime properties. This runs
+ * before all {@code services()} methods, ensuring the framework's {@code
+ * RemoteServiceConfiguration} will auto-create a {@code RemoteServiceImpl} for the AICore CDS
+ * service definition.
*/
- private static boolean detectMultiTenancy(CdsRuntime runtime) {
- CdsProperties props = runtime.getEnvironment().getCdsProperties();
- String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl();
- if (sidecarUrl != null && !sidecarUrl.isBlank()) {
- return true;
- }
- return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null;
+ @Override
+ public void environment(CdsRuntimeConfigurer configurer) {
+ RemoteServiceConfig remoteConfig = new RemoteServiceConfig(AICore_.CDS_NAME);
+ remoteConfig.setModel(AICore_.CDS_NAME);
+ configurer
+ .getCdsRuntime()
+ .getEnvironment()
+ .getCdsProperties()
+ .getRemote()
+ .getServices()
+ .putIfAbsent(AICore_.CDS_NAME, remoteConfig);
}
@Override
public void services(CdsRuntimeConfigurer configurer) {
CdsRuntime runtime = configurer.getCdsRuntime();
- boolean hasBinding = hasAICoreBinding(runtime);
+ if (!hasAICoreModel(runtime)) {
+ logger.debug("AICore CDS model not found in runtime model - skipping handler setup.");
+ return;
+ }
+ boolean hasBinding = hasAICoreBinding(runtime);
boolean multiTenancyEnabled = detectMultiTenancy(runtime);
+ this.config = AICoreConfig.from(runtime.getEnvironment(), multiTenancyEnabled);
+
if (hasBinding) {
- AICoreServiceImpl service =
- new AICoreServiceImpl(
- AICoreService.DEFAULT_NAME,
- runtime,
- multiTenancyEnabled,
- new DeploymentApi(),
- new ConfigurationApi(),
- new ResourceGroupApi(),
- new AiCoreService());
- configurer.service(service);
- logger.info("Registered AICoreService backed by AI Core binding.");
+ DeploymentApi deploymentApi = new DeploymentApi();
+ ConfigurationApi configurationApi = new ConfigurationApi();
+ ResourceGroupApi resourceGroupApi = new ResourceGroupApi();
+ AiCoreService sdkService = new AiCoreService();
+
+ this.clients =
+ new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService);
+ this.resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi);
+ logger.info("AI Core binding detected - production handlers will be registered.");
} else {
- MockAICoreServiceImpl mockService =
- new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled);
- configurer.service(mockService);
- logger.info("Registered MockAICoreService (no AI Core binding found).");
+ logger.info("No AI Core binding found - mock handlers will be registered.");
}
}
@Override
public void eventHandlers(CdsRuntimeConfigurer configurer) {
- CdsRuntime runtime = configurer.getCdsRuntime();
-
- AICoreService registered =
- runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME);
+ if (config == null) {
+ return; // No AICore model - services() skipped
+ }
- if (registered instanceof AICoreServiceImpl service) {
- configurer.eventHandler(new ResourceGroupHandler(service));
- configurer.eventHandler(new DeploymentHandler(service));
- configurer.eventHandler(new ConfigurationHandler(service));
- configurer.eventHandler(new ActionHandler(service));
- configurer.eventHandler(new AICoreApplicationServiceHandler(service));
- logger.debug("Registered Prod AI-Core Implementation");
+ if (clients != null) {
+ configurer.eventHandler(new AICoreApiHandler(config, clients, resolver));
+ configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver));
+ configurer.eventHandler(new DeploymentHandler(config, clients, resolver));
+ configurer.eventHandler(new ConfigurationHandler(config, clients, resolver));
+ configurer.eventHandler(new ActionHandler(config, clients, resolver));
+ logger.debug("Registered production AI Core event handlers.");
- if (service.isMultiTenancyEnabled()) {
- configurer.eventHandler(new AICoreSetupHandler(service));
- logger.debug("Registered AI-Core Setup Handler for MTX subscribe/unsubscribe.");
+ if (config.multiTenancyEnabled()) {
+ configurer.eventHandler(new AICoreSetupHandler(clients, resolver));
+ logger.debug("Registered AI Core setup handler for MTX subscribe/unsubscribe.");
}
- } else if (registered instanceof MockAICoreServiceImpl mockService) {
+ } else {
+ MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config);
configurer.eventHandler(new MockEntityHandler());
- configurer.eventHandler(new AICoreApplicationServiceHandler(mockService));
- if (mockService.isMultiTenancyEnabled()) {
- configurer.eventHandler(new MockAICoreSetupHandler(mockService));
- logger.debug("Registered Mock AI-Core Setup Handler for MTX subscribe/unsubscribe.");
+ configurer.eventHandler(mockApiHandler);
+ logger.debug("Registered mock AI Core event handlers.");
+
+ if (config.multiTenancyEnabled()) {
+ configurer.eventHandler(new MockAICoreSetupHandler(mockApiHandler));
+ logger.debug("Registered mock AI Core setup handler for MTX subscribe/unsubscribe.");
}
- logger.debug("Registered Mock AI-Core Implementation");
}
}
+
+ private static boolean hasAICoreModel(CdsRuntime runtime) {
+ return runtime.getCdsModel().findService(AICore_.CDS_NAME).isPresent();
+ }
+
+ private static boolean hasAICoreBinding(CdsRuntime runtime) {
+ return runtime
+ .getEnvironment()
+ .getServiceBindings()
+ .filter(b -> ServiceBindingUtils.matches(b, "aicore"))
+ .findFirst()
+ .isPresent();
+ }
+
+ private static boolean detectMultiTenancy(CdsRuntime runtime) {
+ CdsProperties props = runtime.getEnvironment().getCdsProperties();
+ String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl();
+ if (sidecarUrl != null && !sidecarUrl.isBlank()) {
+ return true;
+ }
+ return runtime
+ .getServiceCatalog()
+ .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME)
+ != null;
+ }
}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java
deleted file mode 100644
index 52eeb4f..0000000
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java
+++ /dev/null
@@ -1,426 +0,0 @@
-/*
- * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
- */
-package com.sap.cds.feature.aicore.core;
-
-import com.github.benmanes.caffeine.cache.Cache;
-import com.github.benmanes.caffeine.cache.Caffeine;
-import com.sap.ai.sdk.core.AiCoreService;
-import com.sap.ai.sdk.core.client.ConfigurationApi;
-import com.sap.ai.sdk.core.client.DeploymentApi;
-import com.sap.ai.sdk.core.client.ResourceGroupApi;
-import com.sap.ai.sdk.core.model.AiConfigurationBaseData;
-import com.sap.ai.sdk.core.model.AiConfigurationList;
-import com.sap.ai.sdk.core.model.AiDeployment;
-import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest;
-import com.sap.ai.sdk.core.model.AiDeploymentList;
-import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails;
-import com.sap.ai.sdk.core.model.AiDeploymentStatus;
-import com.sap.ai.sdk.core.model.BckndResourceGroup;
-import com.sap.ai.sdk.core.model.BckndResourceGroupLabel;
-import com.sap.ai.sdk.core.model.BckndResourceGroupList;
-import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.api.ModelDeploymentSpec;
-import com.sap.cds.services.ErrorStatuses;
-import com.sap.cds.services.ServiceException;
-import com.sap.cds.services.environment.CdsEnvironment;
-import com.sap.cds.services.runtime.CdsRuntime;
-import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
-import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException;
-import io.github.resilience4j.core.IntervalFunction;
-import io.github.resilience4j.retry.Retry;
-import io.github.resilience4j.retry.RetryConfig;
-import java.time.Duration;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.ConcurrentHashMap;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Production implementation of {@link AICoreService} backed by an SAP AI Core service binding.
- *
- *
Provides resource-group, configuration and deployment lifecycle management together with a
- * factory for inference {@link ApiClient}s scoped to a specific deployment. Resource group lookup
- * results, deployment IDs and per-cache-key locks are cached in bounded {@link Caffeine} caches so
- * repeated calls within a tenant or resource group avoid round-trips to AI Core.
- *
- *
Most state-changing AI Core calls are wrapped in a Resilience4j {@link Retry} that retries
- * known transient errors (HTTP 403/404/412, see {@link #notReadyYet(OpenApiRequestException)}) with
- * exponential backoff capped at 30 seconds.
- */
-public class AICoreServiceImpl extends AbstractAICoreService {
-
- private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class);
-
- public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID";
-
- private static final String DEFAULT_RESOURCE_GROUP = "default";
- private static final String DEFAULT_RESOURCE_GROUP_PREFIX = "cds-";
- private static final int DEFAULT_MAX_RETRIES = 10;
- private static final long DEFAULT_INITIAL_DELAY_MS = 300;
- private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1);
- private static final int DEFAULT_CACHE_MAX_SIZE = 10_000;
-
- private final Cache tenantResourceGroupCache;
- private final Cache resourceGroupDeploymentCache;
-
- /**
- * Per-cache-key monitors guarding deployment lookup/creation. Stored in a {@link
- * ConcurrentHashMap} (not a Caffeine cache) so that two threads asking for the same key are
- * guaranteed to obtain the same monitor instance — locks must never live in a
- * size/time-evicting cache, otherwise concurrent callers can synchronize on different objects and
- * race to create duplicate AI Core deployments.
- */
- private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>();
-
- private final int maxRetries;
- private final long initialDelayMs;
- private final String defaultResourceGroup;
- private final String resourceGroupPrefix;
- private final boolean multiTenancyEnabled;
- private final Retry retry;
- private final DeploymentApi deploymentApi;
- private final ConfigurationApi configurationApi;
- private final ResourceGroupApi resourceGroupApi;
- private final AiCoreService sdkService;
-
- public AICoreServiceImpl(
- String name,
- CdsRuntime runtime,
- boolean multiTenancyEnabled,
- DeploymentApi deploymentApi,
- ConfigurationApi configurationApi,
- ResourceGroupApi resourceGroupApi,
- AiCoreService sdkService) {
- super(name, runtime);
- this.multiTenancyEnabled = multiTenancyEnabled;
- CdsEnvironment env = runtime.getEnvironment();
- this.maxRetries =
- env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES);
- this.initialDelayMs =
- env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS);
- this.defaultResourceGroup =
- env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP);
- this.resourceGroupPrefix =
- env.getProperty(
- "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX);
- this.retry = buildRetry(maxRetries, initialDelayMs);
- this.tenantResourceGroupCache = newCache();
- this.resourceGroupDeploymentCache = newCache();
- this.deploymentApi = deploymentApi;
- this.configurationApi = configurationApi;
- this.resourceGroupApi = resourceGroupApi;
- this.sdkService = sdkService;
- }
-
- private static Cache newCache() {
- return Caffeine.newBuilder()
- .maximumSize(DEFAULT_CACHE_MAX_SIZE)
- .expireAfterAccess(DEFAULT_CACHE_EXPIRY)
- .build();
- }
-
- @Override
- public String resourceGroupForTenant(String tenantId) {
- if (!multiTenancyEnabled || tenantId == null) {
- logger.debug("Using default resource group {}", defaultResourceGroup);
- return defaultResourceGroup;
- }
- return getOrCreateResourceGroupForTenant(tenantId);
- }
-
- @Override
- public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) {
- String cacheKey = deploymentCacheKey(resourceGroupId, spec);
- Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object());
- synchronized (lock) {
- String cached = resourceGroupDeploymentCache.getIfPresent(cacheKey);
- if (cached != null) {
- try {
- var current = deploymentApi.get(resourceGroupId, cached);
- if (AiDeploymentStatus.RUNNING.equals(current.getStatus())
- || AiDeploymentStatus.PENDING.equals(current.getStatus())) {
- return cached;
- }
- } catch (OpenApiRequestException e) {
- // Only 404 means the cached deployment was deleted out-of-band — drop the stale entry
- // and fall through to discover or create a new one. Any other status (5xx, 401, 412,
- // network errors, …) is propagated so the caller's retry/backoff policy can handle it
- // rather than silently invalidating a potentially valid cache entry and triggering a
- // duplicate deployment.
- Integer status = e.statusCode();
- if (status == null || status != 404) {
- throw e;
- }
- logger.debug(
- "Cached deployment {} in resource group {} no longer exists (404), "
- + "invalidating cache entry",
- cached,
- resourceGroupId);
- }
- resourceGroupDeploymentCache.invalidate(cacheKey);
- }
- AiDeploymentList deploymentList = queryDeploymentsUntilReady(resourceGroupId, spec);
- Optional existing =
- deploymentList.getResources().stream()
- .filter(
- d ->
- spec.configurationName().equals(d.getConfigurationName())
- && spec.matchesExisting().test(d)
- && (AiDeploymentStatus.RUNNING.equals(d.getStatus())
- || AiDeploymentStatus.PENDING.equals(d.getStatus())))
- .findFirst()
- .map(AiDeployment::getId);
- if (existing.isPresent()) {
- String deploymentId = existing.get();
- resourceGroupDeploymentCache.put(cacheKey, deploymentId);
- return deploymentId;
- }
- return createDeployment(resourceGroupId, spec, cacheKey);
- }
- }
-
- @Override
- public ApiClient inferenceClient(String resourceGroupId, String deploymentId) {
- var destination =
- sdkService.getInferenceDestination(resourceGroupId).usingDeploymentId(deploymentId);
- logger.debug("Inference destination URI: {}", destination.getUri());
- return ApiClient.create(destination);
- }
-
- @Override
- public boolean isMultiTenancyEnabled() {
- return multiTenancyEnabled;
- }
-
- @Override
- public Retry getRetry() {
- return retry;
- }
-
- @Override
- public String getDefaultResourceGroup() {
- return defaultResourceGroup;
- }
-
- @Override
- public String getResourceGroupPrefix() {
- return resourceGroupPrefix;
- }
-
- @Override
- public Map getTenantResourceGroupCache() {
- return tenantResourceGroupCache.asMap();
- }
-
- @Override
- public Map getResourceGroupDeploymentCache() {
- return resourceGroupDeploymentCache.asMap();
- }
-
- public DeploymentApi getDeploymentApi() {
- return deploymentApi;
- }
-
- public ConfigurationApi getConfigurationApi() {
- return configurationApi;
- }
-
- public ResourceGroupApi getResourceGroupApi() {
- return resourceGroupApi;
- }
-
- @Override
- public String resolveResourceGroupFromKeys(Map keys) {
- if (keys.containsKey("resourceGroup_resourceGroupId")) {
- return (String) keys.get("resourceGroup_resourceGroupId");
- }
- Object rgObj = keys.get("resourceGroup");
- if (rgObj instanceof Map, ?> rgMap && rgMap.containsKey("resourceGroupId")) {
- return (String) rgMap.get("resourceGroupId");
- }
- return resourceGroup();
- }
-
- @Override
- public void clearTenantCache(String tenantId) {
- String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId);
- if (resourceGroupId != null) {
- String prefix = resourceGroupId + "::";
- resourceGroupDeploymentCache
- .asMap()
- .keySet()
- .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix));
- deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix));
- }
- }
-
- /**
- * Builds the cache key for the {@code resourceGroupDeploymentCache} and {@code deploymentLocks}
- * maps. Package-private so tests can derive the same key the production code uses, instead of
- * duplicating the format inline.
- */
- static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) {
- return resourceGroupId + "::" + spec.configurationName();
- }
-
- private String getOrCreateResourceGroupForTenant(String tenantId) {
- return tenantResourceGroupCache.get(
- tenantId,
- key -> {
- List labelSelector = List.of(TENANT_LABEL_KEY + "=" + key);
- BckndResourceGroupList result =
- resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector);
- List resources = result.getResources();
- if (resources != null && !resources.isEmpty()) {
- return resources.get(0).getResourceGroupId();
- }
- String resourceGroupId = resourceGroupPrefix + key;
- BckndResourceGroupLabel label =
- BckndResourceGroupLabel.create().key(TENANT_LABEL_KEY).value(key);
- BckndResourceGroupsPostRequest request =
- BckndResourceGroupsPostRequest.create()
- .resourceGroupId(resourceGroupId)
- .labels(List.of(label));
- try {
- resourceGroupApi.create(request);
- logger.debug("Created resource group {} for tenant {}", resourceGroupId, key);
- } catch (OpenApiRequestException e) {
- if (e.statusCode() != null && e.statusCode() == 409) {
- logger.debug(
- "Resource group {} already exists (409 Conflict), reusing", resourceGroupId);
- } else {
- throw e;
- }
- }
- return resourceGroupId;
- });
- }
-
- private String createDeployment(
- String resourceGroupId, ModelDeploymentSpec spec, String cacheKey) {
- AiConfigurationList configList =
- configurationApi.query(
- resourceGroupId, spec.scenarioId(), null, null, null, null, null, null);
- String configId =
- configList.getResources().stream()
- .filter(c -> spec.configurationName().equals(c.getName()))
- .findFirst()
- .map(
- c -> {
- logger.debug(
- "Reusing existing configuration {} ({}) in resource group {}",
- c.getId(),
- spec.configurationName(),
- resourceGroupId);
- return c.getId();
- })
- .orElseGet(() -> createConfiguration(resourceGroupId, spec));
-
- return Retry.decorateSupplier(
- retry,
- () -> {
- var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId);
- var deployResponse = deploymentApi.create(resourceGroupId, deployRequest);
- String deploymentId = deployResponse.getId();
- logger.debug(
- "Created deployment {} ({}) in resource group {}, polling for RUNNING",
- deploymentId,
- spec.configurationName(),
- resourceGroupId);
- return pollUntilRunning(resourceGroupId, deploymentId, cacheKey);
- })
- .get();
- }
-
- private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) {
- AiConfigurationBaseData configRequest =
- AiConfigurationBaseData.create()
- .name(spec.configurationName())
- .executableId(spec.executableId())
- .scenarioId(spec.scenarioId())
- .parameterBindings(spec.parameterBindings());
- String configId = configurationApi.create(resourceGroupId, configRequest).getId();
- logger.debug(
- "Created configuration {} ({}) in resource group {}",
- configId,
- spec.configurationName(),
- resourceGroupId);
- return configId;
- }
-
- private String pollUntilRunning(String resourceGroupId, String deploymentId, String cacheKey) {
- Retry pollRetry =
- Retry.of(
- "pollDeployment",
- RetryConfig.custom()
- .maxAttempts(maxRetries)
- .intervalFunction(IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0))
- .retryOnResult(
- deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus()))
- .retryOnException(e -> false)
- .build());
-
- AiDeploymentResponseWithDetails result =
- Retry.decorateSupplier(
- pollRetry,
- () -> {
- var current = deploymentApi.get(resourceGroupId, deploymentId);
- logger.debug("Deployment {} status: {}", deploymentId, current.getStatus());
- return current;
- })
- .get();
-
- if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) {
- resourceGroupDeploymentCache.put(cacheKey, deploymentId);
- return deploymentId;
- }
- logger.error(
- "Deployment {} in resource group {} did not reach RUNNING status after {} retries",
- deploymentId,
- resourceGroupId,
- maxRetries);
- throw new ServiceException(
- ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available");
- }
-
- private AiDeploymentList queryDeploymentsUntilReady(
- String resourceGroupId, ModelDeploymentSpec spec) {
- return Retry.decorateSupplier(
- retry,
- () ->
- deploymentApi.query(
- resourceGroupId, null, null, spec.scenarioId(), null, null, null, null))
- .get();
- }
-
- static boolean notReadyYet(OpenApiRequestException e) {
- Throwable t = e;
- while (t != null) {
- if (t instanceof OpenApiRequestException oae) {
- Integer code = oae.statusCode();
- if (code != null && (code == 403 || code == 404 || code == 412)) {
- return true;
- }
- }
- t = t.getCause();
- }
- return false;
- }
-
- private static final long MAX_INTERVAL_MS = 30_000L;
-
- private static Retry buildRetry(int maxAttempts, long initialDelayMs) {
- RetryConfig config =
- RetryConfig.custom()
- .maxAttempts(maxAttempts)
- .intervalFunction(
- IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0, MAX_INTERVAL_MS))
- .retryOnException(e -> e instanceof OpenApiRequestException oae && notReadyYet(oae))
- .build();
- return Retry.of("aicore", config);
- }
-}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java
deleted file mode 100644
index 0e7ec79..0000000
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
- */
-package com.sap.cds.feature.aicore.core;
-
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.services.request.RequestContext;
-import com.sap.cds.services.request.UserInfo;
-import com.sap.cds.services.runtime.CdsRuntime;
-import com.sap.cds.services.utils.services.AbstractCqnService;
-import io.github.resilience4j.retry.Retry;
-import java.util.Map;
-
-/**
- * Abstract base class for AICore service implementations, providing shared internal methods for
- * cache access, configuration, and resource group resolution. These methods are not part of the
- * public {@link AICoreService} contract but are shared between the real and mock implementations.
- */
-public abstract class AbstractAICoreService extends AbstractCqnService implements AICoreService {
-
- protected AbstractAICoreService(String name, CdsRuntime runtime) {
- super(name, runtime);
- }
-
- /** Returns the {@link CdsRuntime} that this service was created with. */
- public CdsRuntime getRuntime() {
- return runtime;
- }
-
- /**
- * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no
- * tenant is set (e.g. in single-tenant mode).
- */
- public String currentTenantId() {
- return RequestContext.getCurrent(runtime).getUserInfo().getTenant();
- }
-
- /**
- * Returns whether the current request is running as a system/provider user. Provider users are
- * allowed to see all tenants' resources.
- */
- public boolean isProviderUser() {
- UserInfo userInfo = RequestContext.getCurrent(runtime).getUserInfo();
- return userInfo.isSystemUser() || userInfo.isInternalUser();
- }
-
- /**
- * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService}
- * interface — callers should not need to be aware of multi-tenancy.
- */
- public abstract boolean isMultiTenancyEnabled();
-
- /**
- * Returns the shared {@link Retry} used internally for transient AI Core errors. Not part of the
- * public {@link AICoreService} interface but accessible to internal callers (e.g. the
- * recommendations module) that need consistent backoff behaviour.
- */
- public abstract Retry getRetry();
-
- /**
- * Returns the resource group for the given tenant ID. This is an internal method used by setup
- * handlers where the tenant ID is explicitly available from the subscribe/unsubscribe context.
- *
- * @param tenantId the CDS tenant identifier
- * @return the AI Core resource group ID
- */
- public abstract String resourceGroupForTenant(String tenantId);
-
- @Override
- public String resourceGroup() {
- return resourceGroupForTenant(currentTenantId());
- }
-
- /** Returns the configured default resource group identifier. */
- public abstract String getDefaultResourceGroup();
-
- /** Returns the configured resource group prefix used for tenant-specific groups. */
- public abstract String getResourceGroupPrefix();
-
- /** Returns the tenant-to-resource-group cache as an unmodifiable view. */
- public abstract Map getTenantResourceGroupCache();
-
- /** Returns the resource-group-to-deployment cache as an unmodifiable view. */
- public abstract Map getResourceGroupDeploymentCache();
-
- /** Evicts all cache entries associated with the given tenant. */
- public abstract void clearTenantCache(String tenantId);
-
- /**
- * Resolves the resource group ID from CQN keys, checking for explicit resource group references
- * before falling back to tenant-based resolution.
- */
- public abstract String resolveResourceGroupFromKeys(Map keys);
-}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java
new file mode 100644
index 0000000..1db6136
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java
@@ -0,0 +1,252 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.core;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.sap.ai.sdk.core.client.DeploymentApi;
+import com.sap.ai.sdk.core.client.ResourceGroupApi;
+import com.sap.ai.sdk.core.model.AiDeploymentStatus;
+import com.sap.ai.sdk.core.model.BckndResourceGroup;
+import com.sap.ai.sdk.core.model.BckndResourceGroupLabel;
+import com.sap.ai.sdk.core.model.BckndResourceGroupList;
+import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest;
+import com.sap.cds.feature.aicore.api.ModelDeploymentSpec;
+import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException;
+import io.github.resilience4j.core.IntervalFunction;
+import io.github.resilience4j.retry.Retry;
+import io.github.resilience4j.retry.RetryConfig;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Stateful component that manages tenant-to-resource-group and resource-group-to-deployment caches,
+ * per-key locks, and retry policies for AI Core operations.
+ *
+ * Handlers interact with this class through intention-revealing operations ({@link
+ * #resolveResourceGroup}, {@link #resolveDeployment}, {@link #invalidateTenant}) instead of
+ * manipulating caches and locks directly.
+ */
+public class DeploymentResolver {
+
+ private static final Logger logger = LoggerFactory.getLogger(DeploymentResolver.class);
+
+ private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1);
+ private static final int DEFAULT_CACHE_MAX_SIZE = 10_000;
+ private static final long MAX_INTERVAL_MS = 30_000L;
+
+ private final Cache tenantResourceGroupCache;
+ private final Cache deploymentCache;
+
+ /**
+ * Per-cache-key monitors guarding deployment lookup/creation. Stored in a {@link
+ * ConcurrentHashMap} (not a Caffeine cache) so that two threads asking for the same key are
+ * guaranteed to obtain the same monitor instance — locks must never live in a
+ * size/time-evicting cache, otherwise concurrent callers can synchronize on different objects and
+ * race to create duplicate AI Core deployments.
+ */
+ private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>();
+
+ private final AICoreConfig config;
+ private final DeploymentApi deploymentApi;
+ private final ResourceGroupApi resourceGroupApi;
+ private final Retry retry;
+
+ public DeploymentResolver(
+ AICoreConfig config, DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) {
+ this.config = config;
+ this.deploymentApi = deploymentApi;
+ this.resourceGroupApi = resourceGroupApi;
+ this.retry = buildRetry(config.maxRetries(), config.initialDelayMs());
+ this.tenantResourceGroupCache = newCache();
+ this.deploymentCache = newCache();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Resource group resolution
+ // ──────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Resolves the resource group for a tenant. Returns the configured default resource group if
+ * multi-tenancy is disabled or tenant is {@code null}. Otherwise looks up (or creates) the
+ * tenant's resource group via the AI Core API, caching the result. Thread-safe.
+ *
+ * @param tenantId the CDS tenant identifier (may be {@code null})
+ * @return the AI Core resource group ID
+ */
+ public String resolveResourceGroup(String tenantId) {
+ if (!config.multiTenancyEnabled() || tenantId == null) {
+ return config.defaultResourceGroup();
+ }
+ return tenantResourceGroupCache.get(tenantId, this::findOrCreateResourceGroup);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Deployment resolution
+ // ──────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Resolves a deployment ID for the given spec within a resource group. On cache hit, validates
+ * via {@link DeploymentApi#get} that the deployment is still RUNNING or PENDING. On cache miss or
+ * stale entry, acquires a per-key lock and calls the {@code loader} to find or create the
+ * deployment. The result is cached.
+ *
+ * @param resourceGroupId the AI Core resource group
+ * @param spec the deployment specification
+ * @param loader supplier that finds an existing or creates a new deployment — called under lock
+ * on cache miss
+ * @return the deployment ID
+ */
+ public String resolveDeployment(
+ String resourceGroupId, ModelDeploymentSpec spec, Supplier loader) {
+ String cacheKey = deploymentCacheKey(resourceGroupId, spec);
+ Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object());
+
+ synchronized (lock) {
+ String cached = deploymentCache.getIfPresent(cacheKey);
+ if (cached != null) {
+ if (validateCachedDeployment(resourceGroupId, cached)) {
+ return cached;
+ }
+ deploymentCache.invalidate(cacheKey);
+ }
+
+ String deploymentId = loader.get();
+ deploymentCache.put(cacheKey, deploymentId);
+ return deploymentId;
+ }
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Cache management
+ // ──────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Evicts all cache entries associated with the given tenant: the resource-group mapping, all
+ * deployments in that resource group, and their lock entries.
+ */
+ public void invalidateTenant(String tenantId) {
+ String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId);
+ if (resourceGroupId != null) {
+ String prefix = resourceGroupId + "::";
+ deploymentCache
+ .asMap()
+ .keySet()
+ .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix));
+ deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix));
+ }
+ }
+
+ /** Returns the shared {@link Retry} for wrapping transient AI Core operations. */
+ public Retry getRetry() {
+ return retry;
+ }
+
+ /**
+ * Returns an unmodifiable view of the tenant-to-resource-group cache. Primarily for diagnostics
+ * and the setup handler's unsubscribe logic.
+ */
+ public Map getTenantResourceGroupCacheView() {
+ return Collections.unmodifiableMap(tenantResourceGroupCache.asMap());
+ }
+
+ /** Builds the cache key for deployment lookups. */
+ static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) {
+ return resourceGroupId + "::" + spec.configurationName();
+ }
+
+ /** Returns whether the given {@link OpenApiRequestException} indicates a transient state. */
+ public static boolean notReadyYet(OpenApiRequestException e) {
+ Throwable t = e;
+ while (t != null) {
+ if (t instanceof OpenApiRequestException oae) {
+ Integer code = oae.statusCode();
+ if (code != null && (code == 403 || code == 404 || code == 412)) {
+ return true;
+ }
+ }
+ t = t.getCause();
+ }
+ return false;
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Internal
+ // ──────────────────────────────────────────────────────────────────────────
+
+ private String findOrCreateResourceGroup(String tenantId) {
+ List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId);
+ BckndResourceGroupList result =
+ resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector);
+ List resources = result.getResources();
+ if (resources != null && !resources.isEmpty()) {
+ return resources.get(0).getResourceGroupId();
+ }
+ String resourceGroupId = config.resourceGroupPrefix() + tenantId;
+ BckndResourceGroupLabel label =
+ BckndResourceGroupLabel.create().key(AICoreConfig.TENANT_LABEL_KEY).value(tenantId);
+ BckndResourceGroupsPostRequest request =
+ BckndResourceGroupsPostRequest.create()
+ .resourceGroupId(resourceGroupId)
+ .labels(List.of(label));
+ try {
+ resourceGroupApi.create(request);
+ logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId);
+ } catch (OpenApiRequestException e) {
+ if (e.statusCode() != null && e.statusCode() == 409) {
+ logger.debug("Resource group {} already exists (409 Conflict), reusing", resourceGroupId);
+ } else {
+ throw e;
+ }
+ }
+ return resourceGroupId;
+ }
+
+ /**
+ * Validates that a cached deployment ID is still active (RUNNING or PENDING). Returns {@code
+ * true} if valid, {@code false} if stale (404). Throws on unexpected errors so the caller's
+ * retry/backoff policy can handle them.
+ */
+ private boolean validateCachedDeployment(String resourceGroupId, String deploymentId) {
+ try {
+ var current = deploymentApi.get(resourceGroupId, deploymentId);
+ return AiDeploymentStatus.RUNNING.equals(current.getStatus())
+ || AiDeploymentStatus.PENDING.equals(current.getStatus());
+ } catch (OpenApiRequestException e) {
+ Integer status = e.statusCode();
+ if (status != null && status == 404) {
+ logger.debug(
+ "Cached deployment {} in resource group {} no longer exists (404), invalidating",
+ deploymentId,
+ resourceGroupId);
+ return false;
+ }
+ throw e;
+ }
+ }
+
+ private static Cache newCache() {
+ return Caffeine.newBuilder()
+ .maximumSize(DEFAULT_CACHE_MAX_SIZE)
+ .expireAfterAccess(DEFAULT_CACHE_EXPIRY)
+ .build();
+ }
+
+ private static Retry buildRetry(int maxAttempts, long initialDelayMs) {
+ RetryConfig config =
+ RetryConfig.custom()
+ .maxAttempts(maxAttempts)
+ .intervalFunction(
+ IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0, MAX_INTERVAL_MS))
+ .retryOnException(e -> e instanceof OpenApiRequestException oae && notReadyYet(oae))
+ .build();
+ return Retry.of("aicore", config);
+ }
+}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java
deleted file mode 100644
index aaee071..0000000
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
- * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
- */
-package com.sap.cds.feature.aicore.core;
-
-import com.sap.cds.feature.aicore.api.ModelDeploymentSpec;
-import com.sap.cds.services.environment.CdsEnvironment;
-import com.sap.cds.services.runtime.CdsRuntime;
-import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
-import io.github.resilience4j.retry.Retry;
-import io.github.resilience4j.retry.RetryConfig;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class MockAICoreServiceImpl extends AbstractAICoreService {
-
- private static final Logger logger = LoggerFactory.getLogger(MockAICoreServiceImpl.class);
-
- private final Map tenantResourceGroupCache = new ConcurrentHashMap<>();
- private final Map resourceGroupDeploymentCache = new ConcurrentHashMap<>();
- private final Retry retry;
- private final String defaultResourceGroup;
- private final String resourceGroupPrefix;
- private final boolean multiTenancyEnabled;
-
- public MockAICoreServiceImpl(String name, CdsRuntime runtime) {
- this(name, runtime, false);
- }
-
- public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) {
- super(name, runtime);
- logger.info("MockAICoreService initialized - all operations use in-memory storage.");
- this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build());
- CdsEnvironment env = runtime.getEnvironment();
- this.defaultResourceGroup =
- env.getProperty("cds.ai.core.resourceGroup", String.class, "default");
- this.resourceGroupPrefix =
- env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-");
- this.multiTenancyEnabled = multiTenancyEnabled;
- }
-
- @Override
- public String resourceGroupForTenant(String tenantId) {
- if (!multiTenancyEnabled) {
- return defaultResourceGroup;
- }
- return tenantResourceGroupCache.computeIfAbsent(tenantId, id -> resourceGroupPrefix + id);
- }
-
- @Override
- public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) {
- String key = resourceGroupId + "::" + spec.configurationName();
- return resourceGroupDeploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k);
- }
-
- @Override
- public ApiClient inferenceClient(String resourceGroupId, String deploymentId) {
- throw new UnsupportedOperationException(
- "MockAICoreServiceImpl does not provide an inference client; tests should stub inference.");
- }
-
- @Override
- public boolean isMultiTenancyEnabled() {
- return multiTenancyEnabled;
- }
-
- @Override
- public Retry getRetry() {
- return retry;
- }
-
- @Override
- public String getDefaultResourceGroup() {
- return defaultResourceGroup;
- }
-
- @Override
- public String getResourceGroupPrefix() {
- return resourceGroupPrefix;
- }
-
- @Override
- public Map getTenantResourceGroupCache() {
- return tenantResourceGroupCache;
- }
-
- @Override
- public Map getResourceGroupDeploymentCache() {
- return resourceGroupDeploymentCache;
- }
-
- @Override
- public void clearTenantCache(String tenantId) {
- String resourceGroupId = tenantResourceGroupCache.remove(tenantId);
- if (resourceGroupId != null) {
- String prefix = resourceGroupId + "::";
- resourceGroupDeploymentCache
- .keySet()
- .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix));
- }
- }
-
- @Override
- public String resolveResourceGroupFromKeys(Map keys) {
- if (keys.containsKey("resourceGroup_resourceGroupId")) {
- return (String) keys.get("resourceGroup_resourceGroupId");
- }
- Object rgObj = keys.get("resourceGroup");
- if (rgObj instanceof Map, ?> rgMap && rgMap.containsKey("resourceGroupId")) {
- return (String) rgMap.get("resourceGroupId");
- }
- return defaultResourceGroup;
- }
-}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java
new file mode 100644
index 0000000..2ab2ee7
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java
@@ -0,0 +1,216 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.core.handler;
+
+import com.sap.ai.sdk.core.model.AiConfigurationBaseData;
+import com.sap.ai.sdk.core.model.AiConfigurationList;
+import com.sap.ai.sdk.core.model.AiDeployment;
+import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest;
+import com.sap.ai.sdk.core.model.AiDeploymentList;
+import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails;
+import com.sap.ai.sdk.core.model.AiDeploymentStatus;
+import com.sap.cds.feature.aicore.api.DeploymentIdContext;
+import com.sap.cds.feature.aicore.api.InferenceClientContext;
+import com.sap.cds.feature.aicore.api.ModelDeploymentSpec;
+import com.sap.cds.feature.aicore.api.ResourceGroupContext;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.services.ErrorStatuses;
+import com.sap.cds.services.ServiceException;
+import com.sap.cds.services.handler.EventHandler;
+import com.sap.cds.services.handler.annotations.On;
+import com.sap.cds.services.handler.annotations.ServiceName;
+import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
+import io.github.resilience4j.core.IntervalFunction;
+import io.github.resilience4j.retry.Retry;
+import io.github.resilience4j.retry.RetryConfig;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * ON handler for the AI Core service API events ({@code resourceGroup}, {@code deploymentId},
+ * {@code inferenceClient}).
+ *
+ * Contains the business logic for deployment discovery/creation and inference client
+ * construction. Resource-group resolution is delegated to {@link DeploymentResolver}.
+ */
+@ServiceName(AICore_.CDS_NAME)
+public class AICoreApiHandler implements EventHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class);
+
+ private final AICoreConfig config;
+ private final AICoreClients clients;
+ private final DeploymentResolver resolver;
+
+ public AICoreApiHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) {
+ this.config = config;
+ this.clients = clients;
+ this.resolver = resolver;
+ }
+
+ @On
+ public void onResourceGroup(ResourceGroupContext context) {
+ String tenantId = context.getTenantId();
+ if (tenantId == null) {
+ tenantId = context.getUserInfo().getTenant();
+ }
+ context.setResult(resolver.resolveResourceGroup(tenantId));
+ }
+
+ @On
+ public void onDeploymentId(DeploymentIdContext context) {
+ String resourceGroupId = context.getResourceGroupId();
+ ModelDeploymentSpec spec = context.getSpec();
+
+ String deploymentId =
+ resolver.resolveDeployment(
+ resourceGroupId, spec, () -> findOrCreateDeployment(resourceGroupId, spec));
+ context.setResult(deploymentId);
+ }
+
+ @On
+ public void onInferenceClient(InferenceClientContext context) {
+ var destination =
+ clients
+ .sdkService()
+ .getInferenceDestination(context.getResourceGroupId())
+ .usingDeploymentId(context.getDeploymentId());
+ logger.debug("Inference destination URI: {}", destination.getUri());
+ context.setResult(ApiClient.create(destination));
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Deployment business logic
+ // ──────────────────────────────────────────────────────────────────────────
+
+ private String findOrCreateDeployment(String resourceGroupId, ModelDeploymentSpec spec) {
+ AiDeploymentList deploymentList = queryDeploymentsUntilReady(resourceGroupId, spec);
+ Optional existing =
+ deploymentList.getResources().stream()
+ .filter(
+ d ->
+ spec.configurationName().equals(d.getConfigurationName())
+ && spec.matchesExisting().test(d)
+ && (AiDeploymentStatus.RUNNING.equals(d.getStatus())
+ || AiDeploymentStatus.PENDING.equals(d.getStatus())))
+ .findFirst()
+ .map(AiDeployment::getId);
+ if (existing.isPresent()) {
+ return existing.get();
+ }
+ return createDeployment(resourceGroupId, spec);
+ }
+
+ private String createDeployment(String resourceGroupId, ModelDeploymentSpec spec) {
+ String configId = findOrCreateConfiguration(resourceGroupId, spec);
+
+ // Retry only the creation call — transient 403/412 on fresh resource groups.
+ // Once we have a deployment ID, polling is handled separately to avoid
+ // creating orphaned deployments on poll timeout.
+ String deploymentId =
+ Retry.decorateSupplier(
+ resolver.getRetry(),
+ () -> {
+ var deployRequest =
+ AiDeploymentCreationRequest.create().configurationId(configId);
+ var response = clients.deploymentApi().create(resourceGroupId, deployRequest);
+ logger.debug(
+ "Created deployment {} ({}) in resource group {}",
+ response.getId(),
+ spec.configurationName(),
+ resourceGroupId);
+ return response.getId();
+ })
+ .get();
+
+ return pollUntilRunning(resourceGroupId, deploymentId);
+ }
+
+ private String findOrCreateConfiguration(String resourceGroupId, ModelDeploymentSpec spec) {
+ AiConfigurationList configList =
+ clients
+ .configurationApi()
+ .query(resourceGroupId, spec.scenarioId(), null, null, null, null, null, null);
+ return configList.getResources().stream()
+ .filter(c -> spec.configurationName().equals(c.getName()))
+ .findFirst()
+ .map(
+ c -> {
+ logger.debug(
+ "Reusing existing configuration {} ({}) in resource group {}",
+ c.getId(),
+ spec.configurationName(),
+ resourceGroupId);
+ return c.getId();
+ })
+ .orElseGet(() -> createConfiguration(resourceGroupId, spec));
+ }
+
+ private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) {
+ AiConfigurationBaseData configRequest =
+ AiConfigurationBaseData.create()
+ .name(spec.configurationName())
+ .executableId(spec.executableId())
+ .scenarioId(spec.scenarioId())
+ .parameterBindings(spec.parameterBindings());
+ String configId = clients.configurationApi().create(resourceGroupId, configRequest).getId();
+ logger.debug(
+ "Created configuration {} ({}) in resource group {}",
+ configId,
+ spec.configurationName(),
+ resourceGroupId);
+ return configId;
+ }
+
+ private String pollUntilRunning(String resourceGroupId, String deploymentId) {
+ Retry pollRetry =
+ Retry.of(
+ "pollDeployment-" + deploymentId,
+ RetryConfig.custom()
+ .maxAttempts(config.maxRetries())
+ .intervalFunction(
+ IntervalFunction.ofExponentialBackoff(config.initialDelayMs(), 2.0))
+ .retryOnResult(
+ deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus()))
+ .retryOnException(e -> false)
+ .build());
+
+ AiDeploymentResponseWithDetails result =
+ Retry.decorateSupplier(
+ pollRetry,
+ () -> {
+ var current = clients.deploymentApi().get(resourceGroupId, deploymentId);
+ logger.debug("Deployment {} status: {}", deploymentId, current.getStatus());
+ return current;
+ })
+ .get();
+
+ if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) {
+ return deploymentId;
+ }
+ logger.error(
+ "Deployment {} in resource group {} did not reach RUNNING status after {} retries",
+ deploymentId,
+ resourceGroupId,
+ config.maxRetries());
+ throw new ServiceException(
+ ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available");
+ }
+
+ private AiDeploymentList queryDeploymentsUntilReady(
+ String resourceGroupId, ModelDeploymentSpec spec) {
+ Retry retry = resolver.getRetry();
+ return Retry.decorateSupplier(
+ retry,
+ () ->
+ clients
+ .deploymentApi()
+ .query(resourceGroupId, null, null, spec.scenarioId(), null, null, null, null))
+ .get();
+ }
+}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java
deleted file mode 100644
index 1b340cd..0000000
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
- */
-package com.sap.cds.feature.aicore.core.handler;
-
-import com.sap.cds.ql.CQL;
-import com.sap.cds.ql.cqn.CqnAnalyzer;
-import com.sap.cds.ql.cqn.CqnSelect;
-import com.sap.cds.ql.cqn.CqnStructuredTypeRef;
-import com.sap.cds.ql.cqn.Modifier;
-import com.sap.cds.reflect.CdsEntity;
-import com.sap.cds.reflect.CdsModel;
-import com.sap.cds.services.cds.ApplicationService;
-import com.sap.cds.services.cds.CdsCreateEventContext;
-import com.sap.cds.services.cds.CdsDeleteEventContext;
-import com.sap.cds.services.cds.CdsReadEventContext;
-import com.sap.cds.services.cds.CdsUpdateEventContext;
-import com.sap.cds.services.cds.CqnService;
-import com.sap.cds.services.handler.EventHandler;
-import com.sap.cds.services.handler.annotations.HandlerOrder;
-import com.sap.cds.services.handler.annotations.On;
-import com.sap.cds.services.handler.annotations.ServiceName;
-import com.sap.cds.services.utils.OrderConstants;
-
-/**
- * Intercepts CRUD events on application service entities that are projections on AICore entities
- * and delegates them to the AICore service. Without this, the framework would try to forward these
- * to the PersistenceService, which fails since AICore entities have no database tables.
- */
-@ServiceName(value = "*", type = ApplicationService.class)
-public class AICoreApplicationServiceHandler implements EventHandler {
-
- private final CqnService aiCoreService;
-
- public AICoreApplicationServiceHandler(CqnService aiCoreService) {
- this.aiCoreService = aiCoreService;
- }
-
- @On
- @HandlerOrder(OrderConstants.On.FEATURE)
- public void onRead(CdsReadEventContext context) {
- String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel());
- if (sourceEntity == null) {
- return;
- }
- CqnSelect rewritten = CQL.copy(context.getCqn(), entityModifier(sourceEntity));
- context.setResult(aiCoreService.run(rewritten));
- }
-
- @On
- @HandlerOrder(OrderConstants.On.FEATURE)
- public void onCreate(CdsCreateEventContext context) {
- String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel());
- if (sourceEntity == null) {
- return;
- }
- context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity))));
- }
-
- @On
- @HandlerOrder(OrderConstants.On.FEATURE)
- public void onUpdate(CdsUpdateEventContext context) {
- String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel());
- if (sourceEntity == null) {
- return;
- }
- context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity))));
- }
-
- @On
- @HandlerOrder(OrderConstants.On.FEATURE)
- public void onDelete(CdsDeleteEventContext context) {
- String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel());
- if (sourceEntity == null) {
- return;
- }
- context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity))));
- }
-
- private String resolveAICoreSource(CdsEntity entity, CdsModel model) {
- if (entity == null || !entity.isProjection()) {
- return null;
- }
- return entity
- .query()
- .filter(q -> q.from().isRef())
- .map(q -> CqnAnalyzer.create(model).analyze(q).targetEntity())
- .map(CdsEntity::getQualifiedName)
- .filter(name -> name.startsWith("AICore."))
- .orElse(null);
- }
-
- private static Modifier entityModifier(String targetEntity) {
- return new Modifier() {
- @Override
- public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) {
- var copy = CQL.copy(ref);
- copy.rootSegment().id(targetEntity);
- return copy.build();
- }
- };
- }
-}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java
similarity index 76%
rename from cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java
rename to cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java
index e4fd983..1c07e5f 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java
@@ -1,16 +1,19 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
*/
-package com.sap.cds.feature.aicore.core;
+package com.sap.cds.feature.aicore.core.handler;
-import com.sap.ai.sdk.core.client.ResourceGroupApi;
import com.sap.ai.sdk.core.model.BckndResourceGroup;
import com.sap.ai.sdk.core.model.BckndResourceGroupList;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.After;
import com.sap.cds.services.handler.annotations.Before;
+import com.sap.cds.services.handler.annotations.HandlerOrder;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.mt.DeploymentService;
import com.sap.cds.services.mt.SubscribeEventContext;
@@ -25,19 +28,22 @@ public class AICoreSetupHandler implements EventHandler {
private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandler.class);
- private final AICoreServiceImpl service;
+ private final AICoreClients clients;
+ private final DeploymentResolver resolver;
- public AICoreSetupHandler(AICoreServiceImpl service) {
- this.service = service;
+ public AICoreSetupHandler(AICoreClients clients, DeploymentResolver resolver) {
+ this.clients = clients;
+ this.resolver = resolver;
}
- @After(event = DeploymentService.EVENT_SUBSCRIBE)
+ @After
+ @HandlerOrder(HandlerOrder.LATE)
public void afterSubscribe(SubscribeEventContext context) {
String tenantId = context.getTenant();
logger.debug("Creating AI Core resources for tenant {}", tenantId);
try {
- String resourceGroupId = service.resourceGroupForTenant(tenantId);
- logger.info("Created AI Core resource group {} for tenant {}", resourceGroupId, tenantId);
+ String resourceGroupId = resolver.resolveResourceGroup(tenantId);
+ logger.info("Ensured AI Core resource group {} for tenant {}", resourceGroupId, tenantId);
} catch (Exception e) {
throw new ServiceException(
ErrorStatuses.SERVER_ERROR,
@@ -47,7 +53,8 @@ public void afterSubscribe(SubscribeEventContext context) {
}
}
- @Before(event = DeploymentService.EVENT_UNSUBSCRIBE)
+ @Before
+ @HandlerOrder(HandlerOrder.EARLY)
public void beforeUnsubscribe(UnsubscribeEventContext context) {
String tenantId = context.getTenant();
logger.debug("Deleting AI Core resources for tenant {}", tenantId);
@@ -55,7 +62,7 @@ public void beforeUnsubscribe(UnsubscribeEventContext context) {
deleteResourceGroupForTenant(tenantId);
} finally {
// Always evict cache entries so a retry won't reuse stale state.
- service.clearTenantCache(tenantId);
+ resolver.invalidateTenant(tenantId);
}
}
@@ -68,7 +75,7 @@ private void deleteResourceGroupForTenant(String tenantId) {
return;
}
try {
- service.getResourceGroupApi().delete(resourceGroupId);
+ clients.resourceGroupApi().delete(resourceGroupId);
logger.info("Deleted AI Core resource group {} for tenant {}", resourceGroupId, tenantId);
} catch (OpenApiRequestException e) {
if (e.statusCode() != null && e.statusCode() == 404) {
@@ -92,17 +99,16 @@ private void deleteResourceGroupForTenant(String tenantId) {
* Core API filtered by the tenant label. Returns {@code null} if no resource group is found.
*/
private String resolveResourceGroupId(String tenantId) {
- String cached = service.getTenantResourceGroupCache().get(tenantId);
+ String cached = resolver.getTenantResourceGroupCacheView().get(tenantId);
if (cached != null) {
return cached;
}
logger.debug(
"No cached resource group for tenant {}, falling back to AI Core lookup", tenantId);
- ResourceGroupApi api = service.getResourceGroupApi();
- List labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId);
+ List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId);
BckndResourceGroupList result;
try {
- result = api.getAll(null, null, null, null, null, null, labelSelector);
+ result = clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector);
} catch (OpenApiRequestException e) {
throw new ServiceException(
ErrorStatuses.SERVER_ERROR,
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java
index 0549daa..fc3392d 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java
@@ -4,10 +4,14 @@
package com.sap.cds.feature.aicore.core.handler;
import com.sap.ai.sdk.core.model.BckndResourceGroup;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
import com.sap.cds.services.ErrorStatuses;
+import com.sap.cds.services.EventContext;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.handler.EventHandler;
+import com.sap.cds.services.request.UserInfo;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -15,14 +19,30 @@
abstract class AbstractCrudHandler implements EventHandler {
- protected final AICoreServiceImpl service;
+ protected final AICoreConfig config;
+ protected final AICoreClients clients;
+ protected final DeploymentResolver resolver;
- protected AbstractCrudHandler(AICoreServiceImpl service) {
- this.service = service;
+ protected AbstractCrudHandler(
+ AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) {
+ this.config = config;
+ this.clients = clients;
+ this.resolver = resolver;
}
- protected String resolveResourceGroup(Map keys) {
- return service.resolveResourceGroupFromKeys(keys);
+ /**
+ * Resolves the resource group ID from CQN keys. Checks for an explicit resource-group reference
+ * in the keys before falling back to tenant-based resolution via the {@link DeploymentResolver}.
+ */
+ protected String resolveResourceGroup(EventContext context, Map keys) {
+ if (keys.containsKey("resourceGroup_resourceGroupId")) {
+ return (String) keys.get("resourceGroup_resourceGroupId");
+ }
+ Object rgObj = keys.get("resourceGroup");
+ if (rgObj instanceof Map, ?> rgMap && rgMap.containsKey("resourceGroupId")) {
+ return (String) rgMap.get("resourceGroupId");
+ }
+ return resolver.resolveResourceGroup(context.getUserInfo().getTenant());
}
/**
@@ -30,26 +50,34 @@ protected String resolveResourceGroup(Map keys) {
* users may access any resource group. In single-tenancy mode, no restriction is applied. Throws
* 404 if the resource group does not belong to the current tenant.
*/
- protected void ensureResourceGroupAccessible(String resourceGroupId) {
- if (service.isProviderUser() || !service.isMultiTenancyEnabled()) {
+ protected void ensureResourceGroupAccessible(EventContext context, String resourceGroupId) {
+ if (isProviderUser(context) || !config.multiTenancyEnabled()) {
return;
}
- String currentTenant = service.currentTenantId();
+ String currentTenant = context.getUserInfo().getTenant();
if (currentTenant == null) {
return;
}
- BckndResourceGroup rg = service.getResourceGroupApi().get(resourceGroupId);
+ BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId);
if (rg.getLabels() != null
&& rg.getLabels().stream()
.anyMatch(
l ->
- AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())
+ AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey())
&& currentTenant.equals(l.getValue()))) {
return;
}
throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found");
}
+ /**
+ * Returns whether the current request user is a system/provider user (bypasses tenant checks).
+ */
+ protected static boolean isProviderUser(EventContext context) {
+ UserInfo userInfo = context.getUserInfo();
+ return userInfo.isSystemUser() || userInfo.isInternalUser();
+ }
+
protected static Map merge(Map keys, Map values) {
Map merged = new HashMap<>(values);
keys.forEach(
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java
index 810068b..f2ea8ef 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java
@@ -3,47 +3,43 @@
*/
package com.sap.cds.feature.aicore.core.handler;
-import com.sap.ai.sdk.core.client.DeploymentApi;
import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest;
import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments;
-import com.sap.cds.services.EventContext;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.DeploymentsStopContext;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_;
+import com.sap.cds.ql.cqn.CqnAnalyzer;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-@ServiceName(AICoreService.DEFAULT_NAME)
+@ServiceName(AICore_.CDS_NAME)
public class ActionHandler extends AbstractCrudHandler {
private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class);
- public ActionHandler(AICoreServiceImpl service) {
- super(service);
+ public ActionHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) {
+ super(config, clients, resolver);
}
- @On(event = "stop", entity = AICoreService.DEPLOYMENTS)
- public void onStop(EventContext context) {
- Map keys = asMap(context.get("keys"));
+ @On(entity = Deployments_.CDS_NAME)
+ public void onStop(DeploymentsStopContext context) {
+ CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel());
+ Map keys = analyzer.analyze(context.getCqn()).targetKeys();
+
String deploymentId = (String) keys.get(Deployments.ID);
- String resourceGroupId = resolveResourceGroup(keys);
+ String resourceGroupId = resolveResourceGroup(context, keys);
- DeploymentApi api = service.getDeploymentApi();
AiDeploymentModificationRequest modRequest =
AiDeploymentModificationRequest.create().targetStatus(AiDeploymentTargetStatus.STOPPED);
- api.modify(resourceGroupId, deploymentId, modRequest);
+ clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest);
logger.debug("Stopped deployment {} in resource group {}", deploymentId, resourceGroupId);
context.setCompleted();
}
-
- @SuppressWarnings("unchecked")
- private static Map asMap(Object obj) {
- if (obj instanceof Map) {
- return (Map) obj;
- }
- return Map.of();
- }
}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java
index bafdbff..01a9f4b 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java
@@ -3,15 +3,17 @@
*/
package com.sap.cds.feature.aicore.core.handler;
-import com.sap.ai.sdk.core.client.ConfigurationApi;
import com.sap.ai.sdk.core.model.AiConfiguration;
import com.sap.ai.sdk.core.model.AiConfigurationBaseData;
import com.sap.ai.sdk.core.model.AiConfigurationList;
import com.sap.ai.sdk.core.model.AiParameterArgumentBinding;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.ArtifactArgumentBinding;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBinding;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBindingList;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups;
@@ -21,7 +23,6 @@
import com.sap.cds.reflect.CdsModel;
import com.sap.cds.services.cds.CdsCreateEventContext;
import com.sap.cds.services.cds.CdsReadEventContext;
-import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import java.util.ArrayList;
@@ -31,19 +32,17 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-@ServiceName(AICoreService.DEFAULT_NAME)
+@ServiceName(AICore_.CDS_NAME)
public class ConfigurationHandler extends AbstractCrudHandler {
private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class);
- private final ConfigurationApi configurationApi;
-
- public ConfigurationHandler(AICoreServiceImpl service) {
- super(service);
- this.configurationApi = service.getConfigurationApi();
+ public ConfigurationHandler(
+ AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) {
+ super(config, clients, resolver);
}
- @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS)
+ @On(entity = Configurations_.CDS_NAME)
public void onRead(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
CdsModel model = context.getModel();
@@ -51,8 +50,8 @@ public void onRead(CdsReadEventContext context) {
Map keys = analysis.targetKeys();
Map values = analysis.targetValues();
- String resourceGroupId = resolveResourceGroup(merge(keys, values));
- ensureResourceGroupAccessible(resourceGroupId);
+ String resourceGroupId = resolveResourceGroup(context, merge(keys, values));
+ ensureResourceGroupAccessible(context, resourceGroupId);
logger.debug(
"Reading configurations for resourceGroup={}, keys={}, values={}",
resourceGroupId,
@@ -61,12 +60,14 @@ public void onRead(CdsReadEventContext context) {
String id = (String) keys.get(Configurations.ID);
if (id != null) {
- AiConfiguration config = configurationApi.get(resourceGroupId, id);
+ AiConfiguration config = clients.configurationApi().get(resourceGroupId, id);
context.setResult(List.of(toConfigurations(config, resourceGroupId)));
} else {
String scenarioId = (String) values.get(Configurations.SCENARIO_ID);
AiConfigurationList result =
- configurationApi.query(resourceGroupId, scenarioId, null, null, null, null, null, null);
+ clients
+ .configurationApi()
+ .query(resourceGroupId, scenarioId, null, null, null, null, null, null);
List> results =
mapResources(result.getResources(), c -> toConfigurations(c, resourceGroupId));
logger.debug("ConfigurationApi.query returned {} resources", results.size());
@@ -74,13 +75,13 @@ public void onRead(CdsReadEventContext context) {
}
}
- @On(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS)
+ @On
public void onCreate(CdsCreateEventContext context, List entries) {
List> results = new ArrayList<>();
for (Configurations entry : entries) {
- String resourceGroupId = resolveResourceGroup(entry);
- ensureResourceGroupAccessible(resourceGroupId);
+ String resourceGroupId = resolveResourceGroup(context, entry);
+ ensureResourceGroupAccessible(context, resourceGroupId);
AiConfigurationBaseData request =
AiConfigurationBaseData.create()
@@ -97,7 +98,7 @@ public void onCreate(CdsCreateEventContext context, List entries
request.parameterBindings(sdkBindings);
}
- var response = configurationApi.create(resourceGroupId, request);
+ var response = clients.configurationApi().create(resourceGroupId, request);
entry.setId(response.getId());
results.add(entry);
logger.debug(
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java
index 0b3df10..c16f166 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java
@@ -3,16 +3,18 @@
*/
package com.sap.cds.feature.aicore.core.handler;
-import com.sap.ai.sdk.core.client.DeploymentApi;
import com.sap.ai.sdk.core.model.AiDeployment;
import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest;
import com.sap.ai.sdk.core.model.AiDeploymentList;
import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest;
import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails;
import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups;
import com.sap.cds.ql.cqn.AnalysisResult;
import com.sap.cds.ql.cqn.CqnAnalyzer;
@@ -25,7 +27,6 @@
import com.sap.cds.services.cds.CdsDeleteEventContext;
import com.sap.cds.services.cds.CdsReadEventContext;
import com.sap.cds.services.cds.CdsUpdateEventContext;
-import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import java.time.OffsetDateTime;
@@ -35,19 +36,17 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-@ServiceName(AICoreService.DEFAULT_NAME)
+@ServiceName(AICore_.CDS_NAME)
public class DeploymentHandler extends AbstractCrudHandler {
private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class);
- private final DeploymentApi deploymentApi;
-
- public DeploymentHandler(AICoreServiceImpl service) {
- super(service);
- this.deploymentApi = service.getDeploymentApi();
+ public DeploymentHandler(
+ AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) {
+ super(config, clients, resolver);
}
- @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS)
+ @On(entity = Deployments_.CDS_NAME)
public void onRead(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
CdsModel model = context.getModel();
@@ -55,28 +54,28 @@ public void onRead(CdsReadEventContext context) {
Map keys = analysis.targetKeys();
Map values = analysis.targetValues();
- String resourceGroupId = resolveResourceGroup(merge(keys, values));
- ensureResourceGroupAccessible(resourceGroupId);
+ String resourceGroupId = resolveResourceGroup(context, merge(keys, values));
+ ensureResourceGroupAccessible(context, resourceGroupId);
String id = (String) keys.get(Deployments.ID);
if (id != null) {
- AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id);
+ AiDeploymentResponseWithDetails deployment = clients.deploymentApi().get(resourceGroupId, id);
context.setResult(List.of(toDeployments(deployment, resourceGroupId)));
} else {
AiDeploymentList result =
- deploymentApi.query(resourceGroupId, null, null, null, null, null, null, null);
+ clients.deploymentApi().query(resourceGroupId, null, null, null, null, null, null, null);
context.setResult(
mapResources(result.getResources(), d -> toDeployments(d, resourceGroupId)));
}
}
- @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS)
+ @On
public void onCreate(CdsCreateEventContext context, List entries) {
List> results = new ArrayList<>();
for (Deployments entry : entries) {
- String resourceGroupId = resolveResourceGroup(entry);
- ensureResourceGroupAccessible(resourceGroupId);
+ String resourceGroupId = resolveResourceGroup(context, entry);
+ ensureResourceGroupAccessible(context, resourceGroupId);
String configurationId = entry.getConfigurationId();
AiDeploymentCreationRequest request =
@@ -86,7 +85,7 @@ public void onCreate(CdsCreateEventContext context, List entries) {
request.ttl(entry.getTtl());
}
- var response = deploymentApi.create(resourceGroupId, request);
+ var response = clients.deploymentApi().create(resourceGroupId, request);
entry.setId(response.getId());
entry.setStatus(response.getStatus().getValue());
results.add(entry);
@@ -95,7 +94,7 @@ public void onCreate(CdsCreateEventContext context, List entries) {
context.setResult(results);
}
- @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS)
+ @On
public void onUpdate(CdsUpdateEventContext context, List entries) {
if (entries.isEmpty()) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided");
@@ -112,8 +111,8 @@ public void onUpdate(CdsUpdateEventContext context, List entries) {
Map keys = analyzer.analyze(context.getCqn()).targetKeys();
String deploymentId = (String) keys.get(Deployments.ID);
- String resourceGroupId = resolveResourceGroup(merge(keys, data));
- ensureResourceGroupAccessible(resourceGroupId);
+ String resourceGroupId = resolveResourceGroup(context, merge(keys, data));
+ ensureResourceGroupAccessible(context, resourceGroupId);
AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create();
@@ -124,12 +123,12 @@ public void onUpdate(CdsUpdateEventContext context, List entries) {
modRequest.configurationId(data.getConfigurationId());
}
- deploymentApi.modify(resourceGroupId, deploymentId, modRequest);
+ clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest);
logger.debug("Updated deployment {} in resource group {}", deploymentId, resourceGroupId);
context.setResult(List.of(data));
}
- @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS)
+ @On(entity = Deployments_.CDS_NAME)
public void onDelete(CdsDeleteEventContext context) {
CqnDelete delete = context.getCqn();
CdsModel model = context.getModel();
@@ -137,10 +136,10 @@ public void onDelete(CdsDeleteEventContext context) {
Map keys = analyzer.analyze(delete).targetKeys();
String deploymentId = (String) keys.get(Deployments.ID);
- String resourceGroupId = resolveResourceGroup(keys);
- ensureResourceGroupAccessible(resourceGroupId);
+ String resourceGroupId = resolveResourceGroup(context, keys);
+ ensureResourceGroupAccessible(context, resourceGroupId);
- deploymentApi.delete(resourceGroupId, deploymentId);
+ clients.deploymentApi().delete(resourceGroupId, deploymentId);
logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId);
context.setResult(List.of());
}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java
new file mode 100644
index 0000000..4a5ccd3
--- /dev/null
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java
@@ -0,0 +1,91 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.core.handler;
+
+import com.sap.cds.feature.aicore.api.DeploymentIdContext;
+import com.sap.cds.feature.aicore.api.InferenceClientContext;
+import com.sap.cds.feature.aicore.api.ModelDeploymentSpec;
+import com.sap.cds.feature.aicore.api.ResourceGroupContext;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.services.ErrorStatuses;
+import com.sap.cds.services.ServiceException;
+import com.sap.cds.services.handler.EventHandler;
+import com.sap.cds.services.handler.annotations.On;
+import com.sap.cds.services.handler.annotations.ServiceName;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Mock ON handler for the AI Core service API events when no AI Core binding is available. Uses
+ * in-memory maps instead of real API calls.
+ */
+@ServiceName(AICore_.CDS_NAME)
+public class MockAICoreApiHandler implements EventHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(MockAICoreApiHandler.class);
+
+ private final AICoreConfig config;
+ private final Map tenantResourceGroupCache = new ConcurrentHashMap<>();
+ private final Map deploymentCache = new ConcurrentHashMap<>();
+
+ public MockAICoreApiHandler(AICoreConfig config) {
+ this.config = config;
+ }
+
+ @On
+ public void onResourceGroup(ResourceGroupContext context) {
+ String tenantId = context.getTenantId();
+ if (tenantId == null) {
+ tenantId = context.getUserInfo().getTenant();
+ }
+ if (!config.multiTenancyEnabled() || tenantId == null) {
+ context.setResult(config.defaultResourceGroup());
+ return;
+ }
+ context.setResult(resolveResourceGroup(tenantId));
+ }
+
+ @On
+ public void onDeploymentId(DeploymentIdContext context) {
+ String resourceGroupId = context.getResourceGroupId();
+ ModelDeploymentSpec spec = context.getSpec();
+ String key = resourceGroupId + "::" + spec.configurationName();
+ String result = deploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k);
+ context.setResult(result);
+ }
+
+ @On
+ public void onInferenceClient(InferenceClientContext context) {
+ throw new ServiceException(
+ ErrorStatuses.NOT_IMPLEMENTED,
+ "Inference client is not available without an AI Core service binding");
+ }
+
+ /** Resolves (or creates) the resource group name for the given tenant using the configured prefix. */
+ public String resolveResourceGroup(String tenantId) {
+ return tenantResourceGroupCache.computeIfAbsent(tenantId, id -> config.resourceGroupPrefix() + id);
+ }
+
+ /** Returns the mock tenant cache for test inspection. */
+ public Map getTenantResourceGroupCache() {
+ return tenantResourceGroupCache;
+ }
+
+ /** Returns the mock deployment cache for test inspection. */
+ public Map getDeploymentCache() {
+ return deploymentCache;
+ }
+
+ /** Evicts all entries for the given tenant. */
+ public void clearTenantCache(String tenantId) {
+ String resourceGroupId = tenantResourceGroupCache.remove(tenantId);
+ if (resourceGroupId != null) {
+ String prefix = resourceGroupId + "::";
+ deploymentCache.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix));
+ }
+ }
+}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java
similarity index 63%
rename from cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java
rename to cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java
index fe152fd..5c8e891 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java
@@ -1,11 +1,12 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
*/
-package com.sap.cds.feature.aicore.core;
+package com.sap.cds.feature.aicore.core.handler;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.After;
import com.sap.cds.services.handler.annotations.Before;
+import com.sap.cds.services.handler.annotations.HandlerOrder;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.mt.DeploymentService;
import com.sap.cds.services.mt.SubscribeEventContext;
@@ -18,24 +19,26 @@ public class MockAICoreSetupHandler implements EventHandler {
private static final Logger logger = LoggerFactory.getLogger(MockAICoreSetupHandler.class);
- private final MockAICoreServiceImpl service;
+ private final MockAICoreApiHandler mockHandler;
- public MockAICoreSetupHandler(MockAICoreServiceImpl service) {
- this.service = service;
+ public MockAICoreSetupHandler(MockAICoreApiHandler mockHandler) {
+ this.mockHandler = mockHandler;
}
- @After(event = DeploymentService.EVENT_SUBSCRIBE)
+ @After
+ @HandlerOrder(HandlerOrder.LATE)
public void afterSubscribe(SubscribeEventContext context) {
String tenantId = context.getTenant();
- String resourceGroupId = service.resourceGroupForTenant(tenantId);
- logger.info(
- "Mock created in-memory resource group {} for tenant {}", resourceGroupId, tenantId);
+ // Trigger resource group creation in mock cache
+ mockHandler.resolveResourceGroup(tenantId);
+ logger.info("Mock created in-memory resource group for tenant {}", tenantId);
}
- @Before(event = DeploymentService.EVENT_UNSUBSCRIBE)
+ @Before
+ @HandlerOrder(HandlerOrder.EARLY)
public void beforeUnsubscribe(UnsubscribeEventContext context) {
String tenantId = context.getTenant();
- service.clearTenantCache(tenantId);
+ mockHandler.clearTenantCache(tenantId);
logger.info("Mock cleared in-memory caches for tenant {}", tenantId);
}
}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java
index cef5666..a8ea0c9 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java
@@ -4,7 +4,11 @@
package com.sap.cds.feature.aicore.core.handler;
import com.sap.cds.CdsData;
-import com.sap.cds.feature.aicore.api.AICoreService;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_;
+import com.sap.cds.ql.cqn.AnalysisResult;
import com.sap.cds.ql.cqn.CqnAnalyzer;
import com.sap.cds.ql.cqn.CqnDelete;
import com.sap.cds.ql.cqn.CqnInsert;
@@ -15,7 +19,6 @@
import com.sap.cds.services.cds.CdsDeleteEventContext;
import com.sap.cds.services.cds.CdsReadEventContext;
import com.sap.cds.services.cds.CdsUpdateEventContext;
-import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
@@ -25,7 +28,7 @@
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
-@ServiceName(AICoreService.DEFAULT_NAME)
+@ServiceName(AICore_.CDS_NAME)
public class MockEntityHandler implements EventHandler {
private final Map> resourceGroups = new ConcurrentHashMap<>();
@@ -34,23 +37,34 @@ public class MockEntityHandler implements EventHandler {
// --- Resource Groups ---
- @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void readResourceGroups(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
CdsModel model = context.getModel();
CqnAnalyzer analyzer = CqnAnalyzer.create(model);
- Map keys = analyzer.analyze(select).targetKeys();
+ AnalysisResult analysis = analyzer.analyze(select);
+ Map keys = analysis.targetKeys();
String id = (String) keys.get("resourceGroupId");
if (id != null) {
Map rg = resourceGroups.get(id);
context.setResult(rg != null ? List.of(rg) : List.of());
} else {
- context.setResult(List.copyOf(resourceGroups.values()));
+ Map values = analysis.targetValues();
+ String tenantId = (String) values.get("tenantId");
+ if (tenantId != null) {
+ List> filtered =
+ resourceGroups.values().stream()
+ .filter(rg -> tenantId.equals(rg.get("tenantId")))
+ .toList();
+ context.setResult(filtered);
+ } else {
+ context.setResult(List.copyOf(resourceGroups.values()));
+ }
}
}
- @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void createResourceGroups(CdsCreateEventContext context) {
CqnInsert insert = context.getCqn();
List> results = new ArrayList<>();
@@ -65,7 +79,7 @@ public void createResourceGroups(CdsCreateEventContext context) {
context.setResult(results);
}
- @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void updateResourceGroups(CdsUpdateEventContext context) {
CqnUpdate update = context.getCqn();
CdsModel model = context.getModel();
@@ -81,7 +95,7 @@ public void updateResourceGroups(CdsUpdateEventContext context) {
context.setResult(List.of(CdsData.create(existing)));
}
- @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void deleteResourceGroups(CdsDeleteEventContext context) {
CqnDelete delete = context.getCqn();
CdsModel model = context.getModel();
@@ -94,7 +108,7 @@ public void deleteResourceGroups(CdsDeleteEventContext context) {
// --- Deployments ---
- @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS)
+ @On(entity = Deployments_.CDS_NAME)
public void readDeployments(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
CdsModel model = context.getModel();
@@ -110,7 +124,7 @@ public void readDeployments(CdsReadEventContext context) {
}
}
- @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS)
+ @On(entity = Deployments_.CDS_NAME)
public void createDeployments(CdsCreateEventContext context) {
CqnInsert insert = context.getCqn();
List> results = new ArrayList<>();
@@ -125,7 +139,7 @@ public void createDeployments(CdsCreateEventContext context) {
context.setResult(results);
}
- @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS)
+ @On(entity = Deployments_.CDS_NAME)
public void updateDeployments(CdsUpdateEventContext context) {
CqnUpdate update = context.getCqn();
CdsModel model = context.getModel();
@@ -141,7 +155,7 @@ public void updateDeployments(CdsUpdateEventContext context) {
context.setResult(List.of(CdsData.create(existing)));
}
- @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS)
+ @On(entity = Deployments_.CDS_NAME)
public void deleteDeployments(CdsDeleteEventContext context) {
CqnDelete delete = context.getCqn();
CdsModel model = context.getModel();
@@ -154,7 +168,7 @@ public void deleteDeployments(CdsDeleteEventContext context) {
// --- Configurations ---
- @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS)
+ @On(entity = Configurations_.CDS_NAME)
public void readConfigurations(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
CdsModel model = context.getModel();
@@ -170,7 +184,7 @@ public void readConfigurations(CdsReadEventContext context) {
}
}
- @On(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS)
+ @On(entity = Configurations_.CDS_NAME)
public void createConfigurations(CdsCreateEventContext context) {
CqnInsert insert = context.getCqn();
List> results = new ArrayList<>();
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java
index b0e7094..567f292 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java
@@ -3,16 +3,18 @@
*/
package com.sap.cds.feature.aicore.core.handler;
-import com.sap.ai.sdk.core.client.ResourceGroupApi;
import com.sap.ai.sdk.core.model.BckndResourceGroup;
import com.sap.ai.sdk.core.model.BckndResourceGroupLabel;
import com.sap.ai.sdk.core.model.BckndResourceGroupList;
import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest;
import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest;
import com.sap.cds.CdsData;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_;
import com.sap.cds.ql.cqn.AnalysisResult;
import com.sap.cds.ql.cqn.CqnAnalyzer;
import com.sap.cds.ql.cqn.CqnDelete;
@@ -20,12 +22,12 @@
import com.sap.cds.ql.cqn.CqnUpdate;
import com.sap.cds.reflect.CdsModel;
import com.sap.cds.services.ErrorStatuses;
+import com.sap.cds.services.EventContext;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.cds.CdsCreateEventContext;
import com.sap.cds.services.cds.CdsDeleteEventContext;
import com.sap.cds.services.cds.CdsReadEventContext;
import com.sap.cds.services.cds.CdsUpdateEventContext;
-import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import java.util.ArrayList;
@@ -34,19 +36,17 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-@ServiceName(AICoreService.DEFAULT_NAME)
+@ServiceName(AICore_.CDS_NAME)
public class ResourceGroupHandler extends AbstractCrudHandler {
private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class);
- private final ResourceGroupApi resourceGroupApi;
-
- public ResourceGroupHandler(AICoreServiceImpl service) {
- super(service);
- this.resourceGroupApi = service.getResourceGroupApi();
+ public ResourceGroupHandler(
+ AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) {
+ super(config, clients, resolver);
}
- @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void onRead(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
CdsModel model = context.getModel();
@@ -61,18 +61,18 @@ public void onRead(CdsReadEventContext context) {
}
if (resourceGroupId != null) {
- BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId);
- ensureOwnedByCurrentTenant(rg);
+ BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId);
+ ensureOwnedByCurrentTenant(context, rg);
context.setResult(List.of(toMap(rg)));
} else {
- List labelSelector = buildTenantLabelSelector(values);
+ List labelSelector = buildTenantLabelSelector(context, values);
BckndResourceGroupList result =
- resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector);
+ clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector);
context.setResult(mapResources(result.getResources(), this::toMap));
}
}
- @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void onCreate(CdsCreateEventContext context, List entries) {
List> results = new ArrayList<>();
@@ -90,13 +90,12 @@ public void onCreate(CdsCreateEventContext context, List entries
// the auto-generated one based on the tenantId field.
boolean userSuppliedTenantLabel =
labels != null
- && labels.stream()
- .anyMatch(l -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get("key")));
+ && labels.stream().anyMatch(l -> AICoreConfig.TENANT_LABEL_KEY.equals(l.get("key")));
if (entry.getTenantId() != null && !userSuppliedTenantLabel) {
mergedLabels.add(
BckndResourceGroupLabel.create()
- .key(AICoreServiceImpl.TENANT_LABEL_KEY)
+ .key(AICoreConfig.TENANT_LABEL_KEY)
.value(entry.getTenantId()));
}
@@ -108,22 +107,22 @@ public void onCreate(CdsCreateEventContext context, List entries
request.labels(mergedLabels);
}
- resourceGroupApi.create(request);
+ clients.resourceGroupApi().create(request);
logger.debug("Created resource group {}", resourceGroupId);
results.add(entry);
}
context.setResult(results);
}
- @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void onUpdate(CdsUpdateEventContext context) {
CqnUpdate update = context.getCqn();
CdsModel model = context.getModel();
CqnAnalyzer analyzer = CqnAnalyzer.create(model);
Map keys = analyzer.analyze(update).targetKeys();
- String resourceGroupId = resolveResourceGroupId(keys);
- ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId));
+ String resourceGroupId = resolveResourceGroupId(context, keys);
+ ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId));
Map data = update.entries().get(0);
BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create();
@@ -134,51 +133,51 @@ public void onUpdate(CdsUpdateEventContext context) {
patchRequest.labels(toSdkLabels(labels));
}
- resourceGroupApi.patch(resourceGroupId, patchRequest);
+ clients.resourceGroupApi().patch(resourceGroupId, patchRequest);
logger.debug("Updated resource group {}", resourceGroupId);
context.setResult(List.of(CdsData.create(data)));
}
- @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS)
+ @On(entity = ResourceGroups_.CDS_NAME)
public void onDelete(CdsDeleteEventContext context) {
CqnDelete delete = context.getCqn();
CdsModel model = context.getModel();
CqnAnalyzer analyzer = CqnAnalyzer.create(model);
Map keys = analyzer.analyze(delete).targetKeys();
- String resourceGroupId = resolveResourceGroupId(keys);
- ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId));
+ String resourceGroupId = resolveResourceGroupId(context, keys);
+ ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId));
- resourceGroupApi.delete(resourceGroupId);
+ clients.resourceGroupApi().delete(resourceGroupId);
logger.debug("Deleted resource group {}", resourceGroupId);
context.setResult(List.of());
}
- private String resolveResourceGroupId(Map keys) {
+ private String resolveResourceGroupId(EventContext context, Map keys) {
if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) {
return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID);
}
if (keys.containsKey(ResourceGroups.TENANT_ID)) {
- return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID));
+ return resolver.resolveResourceGroup((String) keys.get(ResourceGroups.TENANT_ID));
}
- return service.resourceGroup();
+ return resolver.resolveResourceGroup(context.getUserInfo().getTenant());
}
/**
* Builds a tenant-scoped label selector for list queries. In multi-tenancy mode, non-provider
* users are restricted to their own tenant's resource groups.
*/
- private List buildTenantLabelSelector(Map values) {
+ private List buildTenantLabelSelector(EventContext context, Map values) {
// If a specific tenantId is requested in the query, use that
if (values.containsKey(ResourceGroups.TENANT_ID)) {
String tenantId = (String) values.get(ResourceGroups.TENANT_ID);
- return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId);
+ return List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId);
}
// In MT mode, restrict non-provider users to their own tenant
- if (service.isMultiTenancyEnabled() && !service.isProviderUser()) {
- String currentTenant = service.currentTenantId();
+ if (config.multiTenancyEnabled() && !isProviderUser(context)) {
+ String currentTenant = context.getUserInfo().getTenant();
if (currentTenant != null) {
- return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + currentTenant);
+ return List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + currentTenant);
}
}
return null;
@@ -189,14 +188,14 @@ private List buildTenantLabelSelector(Map values) {
* are allowed to access any resource group. Throws 404 if the resource group belongs to a
* different tenant.
*/
- private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) {
- if (service.isProviderUser()) {
+ private void ensureOwnedByCurrentTenant(EventContext context, BckndResourceGroup rg) {
+ if (isProviderUser(context)) {
return;
}
- if (!service.isMultiTenancyEnabled()) {
+ if (!config.multiTenancyEnabled()) {
return;
}
- String currentTenant = service.currentTenantId();
+ String currentTenant = context.getUserInfo().getTenant();
if (currentTenant == null) {
return;
}
@@ -204,7 +203,7 @@ private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) {
&& rg.getLabels().stream()
.anyMatch(
l ->
- AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())
+ AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey())
&& currentTenant.equals(l.getValue()))) {
return;
}
@@ -234,7 +233,7 @@ private ResourceGroups toMap(BckndResourceGroup rg) {
lm.setKey(l.getKey());
lm.setValue(l.getValue());
labels.add(lm);
- if (AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())) {
+ if (AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey())) {
data.setTenantId(l.getValue());
}
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java
index d2feaaa..fcea483 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java
@@ -3,175 +3,76 @@
*/
package com.sap.cds.feature.aicore.core;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.atLeastOnce;
-import static org.mockito.Mockito.lenient;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.assertj.core.api.Assertions.assertThat;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler;
-import com.sap.cds.feature.aicore.core.handler.MockEntityHandler;
-import com.sap.cds.services.ServiceCatalog;
-import com.sap.cds.services.environment.CdsEnvironment;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.services.cds.RemoteService;
import com.sap.cds.services.environment.CdsProperties;
-import com.sap.cds.services.handler.EventHandler;
+import com.sap.cds.services.impl.environment.SimplePropertiesProvider;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
-import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-@ExtendWith(MockitoExtension.class)
+/**
+ * Tests {@link AICoreServiceConfiguration} using a real {@link CdsRuntime} booted with the AICore
+ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy
+ * Mockito mocks.
+ *
+ * Since the test runtime has no service bindings, the configuration always registers mock
+ * handlers regardless of environment variables.
+ */
class AICoreServiceConfigurationTest {
- @Mock private CdsRuntimeConfigurer configurer;
- @Mock private CdsRuntime runtime;
- @Mock private CdsEnvironment environment;
- @Mock private ServiceCatalog serviceCatalog;
-
- /**
- * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl
- * (lines 116-123) with multi-tenancy disabled.
- */
@Test
- void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() {
- when(configurer.getCdsRuntime()).thenReturn(runtime);
- when(runtime.getServiceCatalog()).thenReturn(serviceCatalog);
- when(runtime.getEnvironment()).thenReturn(environment);
- lenient().when(environment.getProperty(any(String.class), any(Class.class), any()))
- .thenAnswer(invocation -> invocation.getArgument(2));
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("default");
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("cds-");
-
- MockAICoreServiceImpl mockService =
- new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, false);
- when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME))
- .thenReturn(mockService);
-
- AICoreServiceConfiguration config = new AICoreServiceConfiguration();
- config.eventHandlers(configurer);
-
- ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class);
- verify(configurer, atLeastOnce()).eventHandler(captor.capture());
-
- var handlers = captor.getAllValues();
- assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler);
- assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler);
- // No setup handler registered when MT is disabled
- assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler);
+ void noBinding_noMultiTenancy_registersService() {
+ CdsRuntime runtime =
+ CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties()))
+ .environmentConfigurations()
+ .cdsModel("edmx/csn.json")
+ .serviceConfigurations()
+ .eventHandlerConfigurations()
+ .complete();
+
+ RemoteService service =
+ runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
+
+ assertThat(service).isNotNull();
}
- /**
- * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl with
- * multi-tenancy enabled — verifies that MockAICoreSetupHandler is registered (lines 119-121).
- */
@Test
- void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() {
- when(configurer.getCdsRuntime()).thenReturn(runtime);
- when(runtime.getServiceCatalog()).thenReturn(serviceCatalog);
- when(runtime.getEnvironment()).thenReturn(environment);
- lenient().when(environment.getProperty(any(String.class), any(Class.class), any()))
- .thenAnswer(invocation -> invocation.getArgument(2));
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("default");
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("cds-");
-
- MockAICoreServiceImpl mockService =
- new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, true);
- when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME))
- .thenReturn(mockService);
-
- AICoreServiceConfiguration config = new AICoreServiceConfiguration();
- config.eventHandlers(configurer);
-
- ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class);
- verify(configurer, atLeastOnce()).eventHandler(captor.capture());
-
- var handlers = captor.getAllValues();
- assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler);
- assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler);
- assert handlers.stream().anyMatch(h -> h instanceof MockAICoreSetupHandler);
- }
-
- /**
- * Tests detectMultiTenancy returning true when sidecar URL is set (the
- * `if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; }` branch).
- * This is exercised through services() with no AI Core binding present.
- */
- @Test
- void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() {
- String envKey = System.getenv("AICORE_SERVICE_KEY");
- org.junit.jupiter.api.Assumptions.assumeTrue(
- envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set");
- when(configurer.getCdsRuntime()).thenReturn(runtime);
- when(runtime.getEnvironment()).thenReturn(environment);
- when(environment.getServiceBindings()).thenReturn(Stream.empty());
- lenient().when(environment.getProperty(any(String.class), any(Class.class), any()))
- .thenAnswer(invocation -> invocation.getArgument(2));
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("default");
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("cds-");
-
- CdsProperties cdsProperties = new CdsProperties();
+ void noBinding_withSidecarUrl_registersService() {
+ CdsProperties props = new CdsProperties();
CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy();
CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar();
sidecar.setUrl("http://localhost:4004");
mt.setSidecar(sidecar);
- cdsProperties.setMultiTenancy(mt);
- when(environment.getCdsProperties()).thenReturn(cdsProperties);
+ props.setMultiTenancy(mt);
+
+ CdsRuntime runtime =
+ CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props))
+ .environmentConfigurations()
+ .cdsModel("edmx/csn.json")
+ .serviceConfigurations()
+ .eventHandlerConfigurations()
+ .complete();
- AICoreServiceConfiguration config = new AICoreServiceConfiguration();
- config.services(configurer);
+ RemoteService service =
+ runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(MockAICoreServiceImpl.class);
- verify(configurer).service(captor.capture());
- assert captor.getValue().isMultiTenancyEnabled();
+ assertThat(service).isNotNull();
}
- /**
- * Tests detectMultiTenancy returning false when no sidecar URL and no DeploymentService.
- */
@Test
- void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() {
- String envKey = System.getenv("AICORE_SERVICE_KEY");
- org.junit.jupiter.api.Assumptions.assumeTrue(
- envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set");
- when(configurer.getCdsRuntime()).thenReturn(runtime);
- when(runtime.getEnvironment()).thenReturn(environment);
- when(runtime.getServiceCatalog()).thenReturn(serviceCatalog);
- when(environment.getServiceBindings()).thenReturn(Stream.empty());
- lenient().when(environment.getProperty(any(String.class), any(Class.class), any()))
- .thenAnswer(invocation -> invocation.getArgument(2));
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("default");
- lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("cds-");
-
- CdsProperties cdsProperties = new CdsProperties();
- CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy();
- CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar();
- // No URL set - defaults to null
- mt.setSidecar(sidecar);
- cdsProperties.setMultiTenancy(mt);
- when(environment.getCdsProperties()).thenReturn(cdsProperties);
- when(serviceCatalog.getService(any(Class.class), any())).thenReturn(null);
+ void noModel_skipsServiceRegistration() {
+ CdsRuntime runtime =
+ CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties()))
+ .serviceConfigurations()
+ .eventHandlerConfigurations()
+ .complete();
- AICoreServiceConfiguration config = new AICoreServiceConfiguration();
- config.services(configurer);
+ RemoteService service =
+ runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(MockAICoreServiceImpl.class);
- verify(configurer).service(captor.capture());
- assert !captor.getValue().isMultiTenancyEnabled();
+ assertThat(service).isNull();
}
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java
index f5e3396..e43f64b 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java
@@ -4,6 +4,7 @@
package com.sap.cds.feature.aicore.core;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -22,19 +23,28 @@
import com.sap.ai.sdk.core.model.AiDeploymentList;
import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails;
import com.sap.ai.sdk.core.model.AiDeploymentStatus;
-import com.sap.cds.feature.aicore.api.AICoreService;
+import com.sap.cds.feature.aicore.api.DeploymentIdContext;
import com.sap.cds.feature.aicore.api.ModelDeploymentSpec;
-import com.sap.cds.services.environment.CdsEnvironment;
+import com.sap.cds.feature.aicore.api.ResourceGroupContext;
+import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.services.cds.RemoteService;
+import com.sap.cds.services.environment.CdsProperties;
+import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig;
+import com.sap.cds.services.impl.environment.SimplePropertiesProvider;
import com.sap.cds.services.runtime.CdsRuntime;
+import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException;
+import java.lang.reflect.Field;
import java.util.List;
+import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
- * Unit tests for the happy paths of {@link AICoreServiceImpl#deploymentId(String,
- * ModelDeploymentSpec)}: cache hit on a RUNNING deployment, stale-cache invalidation when the
- * cached deployment is gone, and reuse of an existing matching deployment found via query.
+ * Unit tests for the happy paths of deployment ID resolution: cache hit on a RUNNING deployment,
+ * stale-cache invalidation when the cached deployment is gone, and reuse of an existing matching
+ * deployment found via query.
*/
class AICoreServiceImplDeploymentIdTest {
@@ -46,15 +56,42 @@ class AICoreServiceImplDeploymentIdTest {
private DeploymentApi deploymentApi;
private ConfigurationApi configurationApi;
private ResourceGroupApi resourceGroupApi;
- private AICoreServiceImpl service;
+ private RemoteService service;
+ private DeploymentResolver resolver;
private final ModelDeploymentSpec spec =
new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true);
private String cacheKey() {
- // Derive via the production helper rather than hardcoding RG + "::" + CONFIG_NAME so a
- // change to the cache-key format is caught here instead of silently passing wrong-path.
- return AICoreServiceImpl.deploymentCacheKey(RG, spec);
+ return DeploymentResolver.deploymentCacheKey(RG, spec);
+ }
+
+ /**
+ * Creates a {@link RemoteService} properly registered with a CDS runtime and the {@link
+ * AICoreApiHandler} so that {@code emit()} dispatches to the handler.
+ */
+ private RemoteService createService(boolean multiTenancy) {
+ CdsProperties props = new CdsProperties();
+ RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore_.CDS_NAME);
+ rsConfig.setModel(AICore_.CDS_NAME);
+ props.getRemote().getServices().put(AICore_.CDS_NAME, rsConfig);
+
+ CdsRuntimeConfigurer configurer =
+ CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props));
+ configurer.cdsModel("edmx/csn.json");
+ configurer.serviceConfigurations();
+ CdsRuntime runtime = configurer.getCdsRuntime();
+
+ AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy);
+ AICoreClients clients =
+ new AICoreClients(
+ deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class));
+ resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi);
+
+ configurer.eventHandler(new AICoreApiHandler(config, clients, resolver));
+ configurer.complete();
+
+ return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
}
@BeforeEach
@@ -62,41 +99,18 @@ void setUp() {
deploymentApi = mock(DeploymentApi.class);
configurationApi = mock(ConfigurationApi.class);
resourceGroupApi = mock(ResourceGroupApi.class);
- AiCoreService sdkService = mock(AiCoreService.class);
-
- CdsRuntime runtime = mock(CdsRuntime.class);
- CdsEnvironment env = mock(CdsEnvironment.class);
- when(runtime.getEnvironment()).thenReturn(env);
- // Use small retry counts so failures don't slow tests.
- when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any()))
- .thenReturn(1);
- when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any()))
- .thenReturn(1L);
- when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("default");
- when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("cds-");
-
- service =
- new AICoreServiceImpl(
- AICoreService.DEFAULT_NAME,
- runtime,
- false,
- deploymentApi,
- configurationApi,
- resourceGroupApi,
- sdkService);
+ service = createService(false);
}
@Test
- void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() {
- service.getResourceGroupDeploymentCache().put(cacheKey(), DEPLOYMENT_ID);
+ void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() throws Exception {
+ putInDeploymentCache(resolver, cacheKey(), DEPLOYMENT_ID);
AiDeploymentResponseWithDetails running = mock(AiDeploymentResponseWithDetails.class);
when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING);
when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running);
- String result = service.deploymentId(RG, spec);
+ String result = emitDeploymentId(service, RG, spec);
assertThat(result).isEqualTo(DEPLOYMENT_ID);
verify(deploymentApi).get(RG, DEPLOYMENT_ID);
@@ -105,9 +119,9 @@ void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() {
}
@Test
- void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() {
+ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() throws Exception {
String otherDeployment = "dep-456";
- service.getResourceGroupDeploymentCache().put(cacheKey(), "stale-id");
+ putInDeploymentCache(resolver, cacheKey(), "stale-id");
OpenApiRequestException notFound = new OpenApiRequestException("gone").statusCode(404);
when(deploymentApi.get(RG, "stale-id")).thenThrow(notFound);
@@ -121,34 +135,29 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() {
when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any()))
.thenReturn(list);
- String result = service.deploymentId(RG, spec);
+ String result = emitDeploymentId(service, RG, spec);
assertThat(result).isEqualTo(otherDeployment);
- assertThat(service.getResourceGroupDeploymentCache())
- .containsEntry(cacheKey(), otherDeployment);
+ assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment);
verify(deploymentApi, never()).create(any(), any());
}
@Test
- void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() {
- // Transient 5xx must NOT invalidate a potentially valid cache entry. The exception is
- // propagated so the caller's retry/backoff policy can handle it.
- service.getResourceGroupDeploymentCache().put(cacheKey(), "still-valid-id");
+ void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() throws Exception {
+ putInDeploymentCache(resolver, cacheKey(), "still-valid-id");
OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503);
when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError);
- org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.deploymentId(RG, spec))
- .isSameAs(serverError);
+ assertThatThrownBy(() -> emitDeploymentId(service, RG, spec)).rootCause().isSameAs(serverError);
- assertThat(service.getResourceGroupDeploymentCache())
- .containsEntry(cacheKey(), "still-valid-id");
+ assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), "still-valid-id");
verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any());
verify(deploymentApi, never()).create(any(), any());
}
@Test
- void noCache_existingMatchingDeployment_isReusedAndCached() {
+ void noCache_existingMatchingDeployment_isReusedAndCached() throws Exception {
AiDeployment existing = mock(AiDeployment.class);
when(existing.getId()).thenReturn(DEPLOYMENT_ID);
when(existing.getConfigurationName()).thenReturn(CONFIG_NAME);
@@ -158,10 +167,10 @@ void noCache_existingMatchingDeployment_isReusedAndCached() {
when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any()))
.thenReturn(list);
- String result = service.deploymentId(RG, spec);
+ String result = emitDeploymentId(service, RG, spec);
assertThat(result).isEqualTo(DEPLOYMENT_ID);
- assertThat(service.getResourceGroupDeploymentCache()).containsEntry(cacheKey(), DEPLOYMENT_ID);
+ assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID);
verify(deploymentApi, never()).create(any(), any());
verify(deploymentApi, never()).get(any(), any());
}
@@ -181,12 +190,11 @@ void secondCallUsesCachedResult_singleQueryToApi() {
when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING);
when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running);
- String first = service.deploymentId(RG, spec);
- String second = service.deploymentId(RG, spec);
+ String first = emitDeploymentId(service, RG, spec);
+ String second = emitDeploymentId(service, RG, spec);
assertThat(first).isEqualTo(DEPLOYMENT_ID);
assertThat(second).isEqualTo(DEPLOYMENT_ID);
- // First call queries, second call hits the cache and only verifies via get.
verify(deploymentApi, times(1))
.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any());
verify(deploymentApi, times(1)).get(RG, DEPLOYMENT_ID);
@@ -194,47 +202,25 @@ void secondCallUsesCachedResult_singleQueryToApi() {
@Test
void resourceGroupForTenant_nullTenantId_returnsDefault() {
- // Even with MT enabled, a null tenantId should fall back to the default resource group.
- CdsRuntime rtMt = mock(CdsRuntime.class);
- CdsEnvironment envMt = mock(CdsEnvironment.class);
- when(rtMt.getEnvironment()).thenReturn(envMt);
- when(envMt.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())).thenReturn(1);
- when(envMt.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())).thenReturn(1L);
- when(envMt.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("my-default");
- when(envMt.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("cds-");
-
- AICoreServiceImpl mtService =
- new AICoreServiceImpl(
- AICoreService.DEFAULT_NAME,
- rtMt,
- true, // multi-tenancy enabled
- deploymentApi,
- configurationApi,
- resourceGroupApi,
- mock(AiCoreService.class));
-
- String result = mtService.resourceGroupForTenant(null);
- assertThat(result).isEqualTo("my-default");
+ RemoteService mtService = createService(true);
+
+ String result = emitResourceGroup(mtService, null);
+ assertThat(result).isEqualTo("default");
}
@Test
void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() {
- // Single-tenancy always returns default regardless of the tenantId passed.
- String result = service.resourceGroupForTenant("any-tenant");
+ String result = emitResourceGroup(service, "any-tenant");
assertThat(result).isEqualTo("default");
}
@Test
- void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() {
- // Empty deployment list → falls through to create path.
+ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws Exception {
AiDeploymentList emptyList = mock(AiDeploymentList.class);
when(emptyList.getResources()).thenReturn(List.of());
when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any()))
.thenReturn(emptyList);
- // Existing config with the same name, so createConfiguration is skipped.
AiConfigurationList configList = mock(AiConfigurationList.class);
var existingConfig = mock(com.sap.ai.sdk.core.model.AiConfiguration.class);
when(existingConfig.getId()).thenReturn("cfg-1");
@@ -251,11 +237,47 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() {
when(runningPoll.getStatus()).thenReturn(AiDeploymentStatus.RUNNING);
when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(runningPoll);
- String result = service.deploymentId(RG, spec);
+ String result = emitDeploymentId(service, RG, spec);
assertThat(result).isEqualTo(DEPLOYMENT_ID);
- assertThat(service.getResourceGroupDeploymentCache()).containsEntry(cacheKey(), DEPLOYMENT_ID);
+ assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID);
verify(configurationApi, never()).create(any(), any());
verify(deploymentApi).create(eq(RG), any());
}
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ──────────────────────────────────────────────────────────────────────────
+
+ private static String emitDeploymentId(RemoteService svc, String rg, ModelDeploymentSpec spec) {
+ DeploymentIdContext ctx = DeploymentIdContext.create();
+ ctx.setResourceGroupId(rg);
+ ctx.setSpec(spec);
+ svc.emit(ctx);
+ return ctx.getResult();
+ }
+
+ private static String emitResourceGroup(RemoteService svc, String tenantId) {
+ ResourceGroupContext ctx = ResourceGroupContext.create();
+ ctx.setTenantId(tenantId);
+ svc.emit(ctx);
+ return ctx.getResult();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value)
+ throws Exception {
+ Field field = DeploymentResolver.class.getDeclaredField("deploymentCache");
+ field.setAccessible(true);
+ ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver))
+ .put(key, value);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map getDeploymentCache(DeploymentResolver resolver)
+ throws Exception {
+ Field field = DeploymentResolver.class.getDeclaredField("deploymentCache");
+ field.setAccessible(true);
+ return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap();
+ }
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java
index 898e1db..78fb35b 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java
@@ -4,12 +4,11 @@
package com.sap.cds.feature.aicore.core;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
-import com.github.benmanes.caffeine.cache.Cache;
-import com.github.benmanes.caffeine.cache.Caffeine;
+import com.sap.ai.sdk.core.client.DeploymentApi;
+import com.sap.ai.sdk.core.client.ResourceGroupApi;
import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException;
import java.lang.reflect.Field;
import java.util.concurrent.ConcurrentHashMap;
@@ -17,12 +16,14 @@
class AICoreServiceImplTest {
+ private static final AICoreConfig CONFIG = new AICoreConfig("default", "cds-", 10, 300, true);
+
@Test
void notReadyYet_topLevel403_returnsTrue() {
OpenApiRequestException e = mock(OpenApiRequestException.class);
when(e.statusCode()).thenReturn(403);
- assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue();
+ assertThat(DeploymentResolver.notReadyYet(e)).isTrue();
}
@Test
@@ -30,7 +31,7 @@ void notReadyYet_topLevel412_returnsTrue() {
OpenApiRequestException e = mock(OpenApiRequestException.class);
when(e.statusCode()).thenReturn(412);
- assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue();
+ assertThat(DeploymentResolver.notReadyYet(e)).isTrue();
}
@Test
@@ -38,7 +39,7 @@ void notReadyYet_topLevel404_returnsTrue() {
OpenApiRequestException e = mock(OpenApiRequestException.class);
when(e.statusCode()).thenReturn(404);
- assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue();
+ assertThat(DeploymentResolver.notReadyYet(e)).isTrue();
}
@Test
@@ -46,7 +47,7 @@ void notReadyYet_topLevel500_returnsFalse() {
OpenApiRequestException e = mock(OpenApiRequestException.class);
when(e.statusCode()).thenReturn(500);
- assertThat(AICoreServiceImpl.notReadyYet(e)).isFalse();
+ assertThat(DeploymentResolver.notReadyYet(e)).isFalse();
}
@Test
@@ -58,7 +59,7 @@ void notReadyYet_topLevel500WrappingInner403_returnsTrue() {
when(outer.statusCode()).thenReturn(500);
when(outer.getCause()).thenReturn(inner);
- assertThat(AICoreServiceImpl.notReadyYet(outer)).isTrue();
+ assertThat(DeploymentResolver.notReadyYet(outer)).isTrue();
}
@Test
@@ -66,7 +67,7 @@ void notReadyYet_nullStatusCodeOnAllLevels_returnsFalse() {
OpenApiRequestException e = mock(OpenApiRequestException.class);
when(e.statusCode()).thenReturn(null);
- assertThat(AICoreServiceImpl.notReadyYet(e)).isFalse();
+ assertThat(DeploymentResolver.notReadyYet(e)).isFalse();
}
@Test
@@ -74,7 +75,7 @@ void deploymentLocksFieldIsConcurrentHashMap() throws NoSuchFieldException {
// Locks must live in a non-evicting map: a Caffeine cache could evict an entry between two
// threads' lookups, causing them to synchronize on different objects for the same cache key
// and race to create duplicate AI Core deployments.
- Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks");
+ Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks");
assertThat(field.getType()).isEqualTo(ConcurrentHashMap.class);
}
@@ -91,98 +92,111 @@ void concurrentHashMapComputeIfAbsentReturnsSameLockObjectForSameKey() {
}
@Test
- void clearTenantCacheRemovesAllRelatedEntries() throws Exception {
+ void invalidateTenantRemovesAllRelatedEntries() throws Exception {
String tenantId = "tenant-1";
String resourceGroupId = "cds-tenant-1";
- AICoreServiceImpl service = freshService();
- Cache tenantCache = readCache(service, "tenantResourceGroupCache");
- Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache");
- ConcurrentHashMap deploymentLocks = readLocks(service);
-
- tenantCache.put(tenantId, resourceGroupId);
- deploymentCache.put(resourceGroupId, "deployment-id");
- deploymentLocks.put(resourceGroupId, new Object());
+ DeploymentResolver resolver = freshResolver();
+ putInTenantCache(resolver, tenantId, resourceGroupId);
+ putInDeploymentCache(resolver, resourceGroupId, "deployment-id");
+ putInDeploymentLocks(resolver, resourceGroupId);
- service.clearTenantCache(tenantId);
+ resolver.invalidateTenant(tenantId);
- assertThat(tenantCache.asMap()).doesNotContainKey(tenantId);
- assertThat(deploymentCache.asMap()).doesNotContainKey(resourceGroupId);
- assertThat(deploymentLocks).doesNotContainKey(resourceGroupId);
+ assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(tenantId);
+ assertThat(getDeploymentCache(resolver)).doesNotContainKey(resourceGroupId);
+ assertThat(getDeploymentLocks(resolver)).doesNotContainKey(resourceGroupId);
}
@Test
- void clearTenantCacheLeavesOtherTenantsUntouched() throws Exception {
+ void invalidateTenantLeavesOtherTenantsUntouched() throws Exception {
String tenantA = "tenant-a";
String resourceGroupA = "cds-tenant-a";
String tenantB = "tenant-b";
String resourceGroupB = "cds-tenant-b";
- AICoreServiceImpl service = freshService();
- Cache tenantCache = readCache(service, "tenantResourceGroupCache");
- Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache");
- ConcurrentHashMap deploymentLocks = readLocks(service);
+ DeploymentResolver resolver = freshResolver();
+ putInTenantCache(resolver, tenantA, resourceGroupA);
+ putInTenantCache(resolver, tenantB, resourceGroupB);
+ putInDeploymentCache(resolver, resourceGroupA, "deployment-a");
+ putInDeploymentCache(resolver, resourceGroupB, "deployment-b");
+ putInDeploymentLocks(resolver, resourceGroupA);
+ putInDeploymentLocks(resolver, resourceGroupB);
- tenantCache.put(tenantA, resourceGroupA);
- tenantCache.put(tenantB, resourceGroupB);
- deploymentCache.put(resourceGroupA, "deployment-a");
- deploymentCache.put(resourceGroupB, "deployment-b");
- deploymentLocks.put(resourceGroupA, new Object());
- deploymentLocks.put(resourceGroupB, new Object());
+ resolver.invalidateTenant(tenantA);
- service.clearTenantCache(tenantA);
-
- assertThat(tenantCache.asMap()).doesNotContainKey(tenantA).containsKey(tenantB);
- assertThat(deploymentCache.asMap())
+ assertThat(resolver.getTenantResourceGroupCacheView())
+ .doesNotContainKey(tenantA)
+ .containsKey(tenantB);
+ assertThat(getDeploymentCache(resolver))
+ .doesNotContainKey(resourceGroupA)
+ .containsKey(resourceGroupB);
+ assertThat(getDeploymentLocks(resolver))
.doesNotContainKey(resourceGroupA)
.containsKey(resourceGroupB);
- assertThat(deploymentLocks).doesNotContainKey(resourceGroupA).containsKey(resourceGroupB);
}
@Test
- void clearTenantCacheIsNoOpForUnknownTenant() throws Exception {
+ void invalidateTenantIsNoOpForUnknownTenant() throws Exception {
String resourceGroupId = "cds-tenant-1";
- AICoreServiceImpl service = freshService();
- Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache");
- ConcurrentHashMap deploymentLocks = readLocks(service);
+ DeploymentResolver resolver = freshResolver();
+ putInDeploymentCache(resolver, resourceGroupId, "deployment-id");
+ putInDeploymentLocks(resolver, resourceGroupId);
- deploymentCache.put(resourceGroupId, "deployment-id");
- deploymentLocks.put(resourceGroupId, new Object());
+ resolver.invalidateTenant("unknown-tenant");
- service.clearTenantCache("unknown-tenant");
+ assertThat(getDeploymentCache(resolver)).containsKey(resourceGroupId);
+ assertThat(getDeploymentLocks(resolver)).containsKey(resourceGroupId);
+ }
- assertThat(deploymentCache.asMap()).containsKey(resourceGroupId);
- assertThat(deploymentLocks).containsKey(resourceGroupId);
+ private static DeploymentResolver freshResolver() {
+ DeploymentApi deploymentApi = mock(DeploymentApi.class);
+ ResourceGroupApi resourceGroupApi = mock(ResourceGroupApi.class);
+ return new DeploymentResolver(CONFIG, deploymentApi, resourceGroupApi);
}
- private static AICoreServiceImpl freshService() throws Exception {
- AICoreServiceImpl service = mock(AICoreServiceImpl.class, CALLS_REAL_METHODS);
- setField(service, "tenantResourceGroupCache", Caffeine.newBuilder().build());
- setField(service, "resourceGroupDeploymentCache", Caffeine.newBuilder().build());
- setField(service, "deploymentLocks", new ConcurrentHashMap<>());
- return service;
+ @SuppressWarnings("unchecked")
+ private static void putInTenantCache(DeploymentResolver resolver, String key, String value)
+ throws Exception {
+ Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache");
+ field.setAccessible(true);
+ ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver))
+ .put(key, value);
}
@SuppressWarnings("unchecked")
- private static Cache readCache(AICoreServiceImpl service, String fieldName)
+ private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value)
throws Exception {
- Field field = AICoreServiceImpl.class.getDeclaredField(fieldName);
+ Field field = DeploymentResolver.class.getDeclaredField("deploymentCache");
field.setAccessible(true);
- return (Cache) field.get(service);
+ ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver))
+ .put(key, value);
}
@SuppressWarnings("unchecked")
- private static ConcurrentHashMap readLocks(AICoreServiceImpl service)
+ private static java.util.Map getDeploymentCache(DeploymentResolver resolver)
throws Exception {
- Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks");
+ Field field = DeploymentResolver.class.getDeclaredField("deploymentCache");
field.setAccessible(true);
- return (ConcurrentHashMap) field.get(service);
+ return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap();
}
- private static void setField(Object target, String fieldName, Object value) throws Exception {
- Field field = AICoreServiceImpl.class.getDeclaredField(fieldName);
+ private static void putInDeploymentLocks(DeploymentResolver resolver, String key)
+ throws Exception {
+ Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks");
+ field.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ConcurrentHashMap locks =
+ (ConcurrentHashMap) field.get(resolver);
+ locks.put(key, new Object());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ConcurrentHashMap getDeploymentLocks(DeploymentResolver resolver)
+ throws Exception {
+ Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks");
field.setAccessible(true);
- field.set(target, value);
+ return (ConcurrentHashMap) field.get(resolver);
}
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java
index f475e5e..9233690 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java
@@ -12,15 +12,18 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.sap.ai.sdk.core.AiCoreService;
+import com.sap.ai.sdk.core.client.ConfigurationApi;
+import com.sap.ai.sdk.core.client.DeploymentApi;
import com.sap.ai.sdk.core.client.ResourceGroupApi;
import com.sap.ai.sdk.core.model.BckndResourceGroup;
import com.sap.ai.sdk.core.model.BckndResourceGroupList;
+import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.mt.UnsubscribeEventContext;
import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException;
-import java.util.HashMap;
+import java.lang.reflect.Field;
import java.util.List;
-import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -34,31 +37,37 @@ class AICoreSetupHandlerTest {
private static final String TENANT = "tenant-1";
private static final String RG_ID = "cds-tenant-1";
- @Mock private AICoreServiceImpl service;
@Mock private ResourceGroupApi resourceGroupApi;
@Mock private UnsubscribeEventContext unsubscribeContext;
- private Map tenantCache;
+ private DeploymentResolver resolver;
+ private AICoreClients clients;
private AICoreSetupHandler cut;
@BeforeEach
void setUp() {
- tenantCache = new HashMap<>();
- when(service.getTenantResourceGroupCache()).thenReturn(tenantCache);
- when(service.getResourceGroupApi()).thenReturn(resourceGroupApi);
+ AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true);
+ DeploymentApi deploymentApi = mock(DeploymentApi.class);
+ clients =
+ new AICoreClients(
+ deploymentApi,
+ mock(ConfigurationApi.class),
+ resourceGroupApi,
+ mock(AiCoreService.class));
+ resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi);
when(unsubscribeContext.getTenant()).thenReturn(TENANT);
- cut = new AICoreSetupHandler(service);
+ cut = new AICoreSetupHandler(clients, resolver);
}
@Test
- void cacheHit_deletesAndClears() {
- tenantCache.put(TENANT, RG_ID);
+ void cacheHit_deletesAndClears() throws Exception {
+ putInTenantCache(resolver, TENANT, RG_ID);
cut.beforeUnsubscribe(unsubscribeContext);
verify(resourceGroupApi).delete(RG_ID);
verify(resourceGroupApi, never()).getAll(any(), any(), any(), any(), any(), any(), any());
- verify(service).clearTenantCache(TENANT);
+ assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT);
}
@Test
@@ -73,9 +82,9 @@ void cacheMiss_fallsBackToApiAndDeletes() {
cut.beforeUnsubscribe(unsubscribeContext);
assertThat(labelCaptor.getValue())
- .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + TENANT);
+ .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=" + TENANT);
verify(resourceGroupApi).delete(RG_ID);
- verify(service).clearTenantCache(TENANT);
+ assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT);
}
@Test
@@ -87,7 +96,7 @@ void cacheMissAndApiReturnsEmpty_isNoOp() {
cut.beforeUnsubscribe(unsubscribeContext);
verify(resourceGroupApi, never()).delete(any());
- verify(service).clearTenantCache(TENANT);
+ assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT);
}
@Test
@@ -99,24 +108,24 @@ void cacheMissAndApiReturnsNullResources_isNoOp() {
cut.beforeUnsubscribe(unsubscribeContext);
verify(resourceGroupApi, never()).delete(any());
- verify(service).clearTenantCache(TENANT);
+ assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT);
}
@Test
- void deleteReturns404_treatedAsSuccess() {
- tenantCache.put(TENANT, RG_ID);
+ void deleteReturns404_treatedAsSuccess() throws Exception {
+ putInTenantCache(resolver, TENANT, RG_ID);
OpenApiRequestException notFound = new OpenApiRequestException("not found").statusCode(404);
when(resourceGroupApi.delete(RG_ID)).thenThrow(notFound);
cut.beforeUnsubscribe(unsubscribeContext);
verify(resourceGroupApi).delete(RG_ID);
- verify(service).clearTenantCache(TENANT);
+ assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT);
}
@Test
- void deleteReturnsOther5xx_propagatesAsServiceException() {
- tenantCache.put(TENANT, RG_ID);
+ void deleteReturnsOther5xx_propagatesAsServiceException() throws Exception {
+ putInTenantCache(resolver, TENANT, RG_ID);
OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(500);
when(resourceGroupApi.delete(RG_ID)).thenThrow(serverError);
@@ -124,21 +133,12 @@ void deleteReturnsOther5xx_propagatesAsServiceException() {
.isInstanceOf(ServiceException.class)
.hasCauseReference(serverError);
// Cache still cleared in finally.
- verify(service).clearTenantCache(TENANT);
+ assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT);
}
@Test
- void unsubscribeTwice_secondCallIsNoOp() {
- tenantCache.put(TENANT, RG_ID);
- // First call uses cache, deletes successfully and clears (we simulate clearTenantCache
- // by removing from the underlying map).
- org.mockito.Mockito.doAnswer(
- inv -> {
- tenantCache.remove(TENANT);
- return null;
- })
- .when(service)
- .clearTenantCache(TENANT);
+ void unsubscribeTwice_secondCallIsNoOp() throws Exception {
+ putInTenantCache(resolver, TENANT, RG_ID);
cut.beforeUnsubscribe(unsubscribeContext);
@@ -151,7 +151,6 @@ void unsubscribeTwice_secondCallIsNoOp() {
verify(resourceGroupApi, times(1)).delete(RG_ID);
verify(resourceGroupApi, times(1)).getAll(any(), any(), any(), any(), any(), any(), any());
- verify(service, times(2)).clearTenantCache(TENANT);
}
@Test
@@ -163,7 +162,6 @@ void getAllThrows_wrappedInServiceException() {
.isInstanceOf(ServiceException.class)
.hasCauseReference(boom);
verify(resourceGroupApi, never()).delete(any());
- verify(service).clearTenantCache(TENANT);
}
@SuppressWarnings({"unchecked", "rawtypes"})
@@ -176,4 +174,13 @@ private static BckndResourceGroupList listOf(List resources)
when(list.getResources()).thenReturn(resources);
return list;
}
+
+ @SuppressWarnings("unchecked")
+ private static void putInTenantCache(DeploymentResolver resolver, String key, String value)
+ throws Exception {
+ Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache");
+ field.setAccessible(true);
+ ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver))
+ .put(key, value);
+ }
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java
index a8dd214..00919b7 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java
@@ -4,103 +4,116 @@
package com.sap.cds.feature.aicore.core;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.services.environment.CdsEnvironment;
+import com.sap.cds.feature.aicore.api.DeploymentIdContext;
+import com.sap.cds.feature.aicore.api.ModelDeploymentSpec;
+import com.sap.cds.feature.aicore.api.ResourceGroupContext;
+import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.services.cds.RemoteService;
+import com.sap.cds.services.environment.CdsProperties;
+import com.sap.cds.services.impl.environment.SimplePropertiesProvider;
import com.sap.cds.services.runtime.CdsRuntime;
+import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
+import java.util.List;
import org.junit.jupiter.api.Test;
+/**
+ * Tests for {@link MockAICoreApiHandler} verifying the mock behavior when no AI Core binding is
+ * present. These tests boot a real CDS runtime with mock handlers to validate end-to-end flow.
+ */
class MockAICoreServiceImplTest {
- private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) {
- CdsRuntime runtime = mock(CdsRuntime.class);
- CdsEnvironment env = mock(CdsEnvironment.class);
- when(runtime.getEnvironment()).thenReturn(env);
- when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("test-rg");
- when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("prefix-");
- return new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled);
+ private RemoteService createMockService(boolean multiTenancy) {
+ CdsProperties props = new CdsProperties();
+ if (multiTenancy) {
+ CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy();
+ CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar();
+ sidecar.setUrl("http://localhost:4004");
+ mt.setSidecar(sidecar);
+ props.setMultiTenancy(mt);
+ }
+
+ CdsRuntime runtime =
+ CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props))
+ .environmentConfigurations()
+ .cdsModel("edmx/csn.json")
+ .serviceConfigurations()
+ .eventHandlerConfigurations()
+ .complete();
+
+ return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
}
@Test
- void defaultConstructor_setsMultiTenancyFalse() {
- CdsRuntime runtime = mock(CdsRuntime.class);
- CdsEnvironment env = mock(CdsEnvironment.class);
- when(runtime.getEnvironment()).thenReturn(env);
- when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any()))
- .thenReturn("default");
- when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any()))
- .thenReturn("cds-");
-
- MockAICoreServiceImpl service = new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime);
- assertThat(service.isMultiTenancyEnabled()).isFalse();
+ void noMultiTenancy_resourceGroupReturnsDefault() {
+ RemoteService service = createMockService(false);
+ ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ service.emit(rgCtx);
+ assertThat(rgCtx.getResult()).isEqualTo("default");
}
@Test
- void mtConstructor_setsMultiTenancyTrue() {
- MockAICoreServiceImpl service = createService(true);
- assertThat(service.isMultiTenancyEnabled()).isTrue();
+ void noMultiTenancy_resourceGroupForTenant_returnsDefault() {
+ RemoteService service = createMockService(false);
+ ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ rgCtx.setTenantId("any-tenant");
+ service.emit(rgCtx);
+ assertThat(rgCtx.getResult()).isEqualTo("default");
}
@Test
- void resourceGroupForTenant_mtDisabled_alwaysReturnsDefault() {
- MockAICoreServiceImpl service = createService(false);
- assertThat(service.resourceGroupForTenant("tenant-x")).isEqualTo("test-rg");
- assertThat(service.resourceGroupForTenant("tenant-y")).isEqualTo("test-rg");
- }
-
- @Test
- void resourceGroupForTenant_mtEnabled_returnsPrefixedTenantId() {
- MockAICoreServiceImpl service = createService(true);
- String rg = service.resourceGroupForTenant("my-tenant");
- assertThat(rg).isEqualTo("prefix-my-tenant");
- }
-
- @Test
- void resourceGroupForTenant_mtEnabled_cachesResult() {
- MockAICoreServiceImpl service = createService(true);
- String first = service.resourceGroupForTenant("t1");
- String second = service.resourceGroupForTenant("t1");
- assertThat(first).isSameAs(second);
- assertThat(service.getTenantResourceGroupCache()).containsKey("t1");
- }
-
- @Test
- void clearTenantCache_removesCorrectEntries() {
- MockAICoreServiceImpl service = createService(true);
- service.resourceGroupForTenant("t1");
- service.resourceGroupForTenant("t2");
- var spec = new com.sap.cds.feature.aicore.api.ModelDeploymentSpec(
- "scenario", "exec", "cfg1", java.util.List.of(), d -> true);
- service.deploymentId("prefix-t1", spec);
-
- service.clearTenantCache("t1");
-
- assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("t1");
- assertThat(service.getTenantResourceGroupCache()).containsKey("t2");
- assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKeys("prefix-t1::cfg1");
+ void multiTenancy_resourceGroupForTenant_returnsPrefixed() {
+ RemoteService service = createMockService(true);
+ ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ rgCtx.setTenantId("my-tenant");
+ service.emit(rgCtx);
+ assertThat(rgCtx.getResult()).isEqualTo("cds-my-tenant");
}
@Test
- void getRetry_returnsNonNull() {
- MockAICoreServiceImpl service = createService(false);
- assertThat(service.getRetry()).isNotNull();
+ void multiTenancy_resourceGroupForTenant_cachesResult() {
+ RemoteService service = createMockService(true);
+ ResourceGroupContext rgCtx1 = ResourceGroupContext.create();
+ rgCtx1.setTenantId("t1");
+ service.emit(rgCtx1);
+ String first = rgCtx1.getResult();
+
+ ResourceGroupContext rgCtx2 = ResourceGroupContext.create();
+ rgCtx2.setTenantId("t1");
+ service.emit(rgCtx2);
+ String second = rgCtx2.getResult();
+ assertThat(first).isEqualTo(second);
}
@Test
- void getDefaultResourceGroup_readsFromConfig() {
- MockAICoreServiceImpl service = createService(false);
- assertThat(service.getDefaultResourceGroup()).isEqualTo("test-rg");
+ void deploymentId_returnsMockId() {
+ RemoteService service = createMockService(false);
+ var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true);
+ DeploymentIdContext depCtx = DeploymentIdContext.create();
+ depCtx.setResourceGroupId("default");
+ depCtx.setSpec(spec);
+ service.emit(depCtx);
+ String id = depCtx.getResult();
+ assertThat(id).startsWith("mock-deployment-");
}
@Test
- void getResourceGroupPrefix_readsFromConfig() {
- MockAICoreServiceImpl service = createService(false);
- assertThat(service.getResourceGroupPrefix()).isEqualTo("prefix-");
+ void deploymentId_cachesSameResult() {
+ RemoteService service = createMockService(false);
+ var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true);
+
+ DeploymentIdContext depCtx1 = DeploymentIdContext.create();
+ depCtx1.setResourceGroupId("default");
+ depCtx1.setSpec(spec);
+ service.emit(depCtx1);
+ String first = depCtx1.getResult();
+
+ DeploymentIdContext depCtx2 = DeploymentIdContext.create();
+ depCtx2.setResourceGroupId("default");
+ depCtx2.setSpec(spec);
+ service.emit(depCtx2);
+ String second = depCtx2.getResult();
+ assertThat(first).isEqualTo(second);
}
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java
index 03e0579..03d360a 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java
@@ -5,65 +5,160 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.sap.ai.sdk.core.AiCoreService;
import com.sap.ai.sdk.core.client.ConfigurationApi;
+import com.sap.ai.sdk.core.client.DeploymentApi;
+import com.sap.ai.sdk.core.client.ResourceGroupApi;
+import com.sap.ai.sdk.core.model.AiConfiguration;
+import com.sap.ai.sdk.core.model.AiConfigurationBaseData;
+import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse;
import com.sap.ai.sdk.core.model.AiConfigurationList;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
-import com.sap.cds.ql.cqn.AnalysisResult;
-import com.sap.cds.ql.cqn.CqnAnalyzer;
-import com.sap.cds.ql.cqn.CqnSelect;
-import com.sap.cds.reflect.CdsModel;
-import com.sap.cds.services.cds.CdsReadEventContext;
-import java.util.HashMap;
+import com.sap.cds.Result;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_;
+import com.sap.cds.ql.Insert;
+import com.sap.cds.ql.Select;
+import com.sap.cds.services.cds.RemoteService;
+import com.sap.cds.services.impl.environment.SimplePropertiesProvider;
+import com.sap.cds.services.request.RequestContext;
+import com.sap.cds.services.runtime.CdsRuntime;
+import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
import java.util.List;
import java.util.Map;
+import java.util.function.Function;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.MockedStatic;
-import org.mockito.junit.jupiter.MockitoExtension;
-@ExtendWith(MockitoExtension.class)
+/**
+ * Integration-style tests for {@link ConfigurationHandler} using a real CDS runtime. Only the SDK
+ * API clients are mocked since they talk to a remote AI Core service.
+ */
class ConfigurationHandlerTest {
- @Mock private AICoreServiceImpl service;
- @Mock private ConfigurationApi configurationApi;
- @Mock private CdsReadEventContext context;
- @Mock private CqnSelect select;
- @Mock private CdsModel model;
- @Mock private CqnAnalyzer analyzer;
- @Mock private AnalysisResult analysisResult;
+ private static CdsRuntime runtime;
+ private static RemoteService service;
+ private static ConfigurationApi configurationApi;
+ private static ResourceGroupApi resourceGroupApi;
+
+ @BeforeAll
+ static void bootRuntime() {
+ configurationApi = mock(ConfigurationApi.class);
+ resourceGroupApi = mock(ResourceGroupApi.class);
+ DeploymentApi deploymentApi = mock(DeploymentApi.class);
+
+ var props = HandlerTestUtils.aicoreProperties();
+
+ var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props));
+ configurer.cdsModel("edmx/csn.json");
+ configurer.serviceConfigurations();
+ runtime = configurer.getCdsRuntime();
+
+ AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false);
+ AICoreClients clients =
+ new AICoreClients(
+ deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class));
+ DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi);
+
+ configurer.eventHandler(new AICoreApiHandler(config, clients, resolver));
+ configurer.eventHandler(new ConfigurationHandler(config, clients, resolver));
+ configurer.complete();
+
+ service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
+ }
+
+ @BeforeEach
+ void clearMockInvocations() {
+ clearInvocations(configurationApi, resourceGroupApi);
+ }
@Test
- void onRead_nullResources_returnsEmptyListWithoutNpe() {
- when(service.getConfigurationApi()).thenReturn(configurationApi);
- when(context.getCqn()).thenReturn(select);
- when(context.getModel()).thenReturn(model);
- when(analyzer.analyze(select)).thenReturn(analysisResult);
- when(analysisResult.targetKeys()).thenReturn(new HashMap<>());
- when(analysisResult.targetValues()).thenReturn(new HashMap<>());
- when(service.resolveResourceGroupFromKeys(any())).thenReturn("default");
+ void onRead_returnsConfigurationsForResourceGroup() {
+ AiConfiguration cfg = mock(AiConfiguration.class);
+ when(cfg.getId()).thenReturn("cfg-1");
+ when(cfg.getName()).thenReturn("my-config");
+ when(cfg.getExecutableId()).thenReturn("exec-1");
+ when(cfg.getScenarioId()).thenReturn("foundation-models");
+ AiConfigurationList list = mock(AiConfigurationList.class);
+ when(list.getResources()).thenReturn(List.of(cfg));
+ when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(list);
+
+ Result result =
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Configurations_.CDS_NAME)
+ .where(c -> c.get("resourceGroup_resourceGroupId").eq("default"))));
+
+ verify(configurationApi).query(eq("default"), any(), any(), any(), any(), any(), any(), any());
+ assertThat(result.list()).hasSize(1);
+ assertThat(result.single().get("id")).isEqualTo("cfg-1");
+ assertThat(result.single().get("name")).isEqualTo("my-config");
+ }
+
+ @Test
+ void onRead_nullResources_returnsEmptyList() {
AiConfigurationList listWithNullResources = mock(AiConfigurationList.class);
when(listWithNullResources.getResources()).thenReturn(null);
- when(configurationApi.query(any(), any(), any(), any(), any(), any(), any(), any()))
+ when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(listWithNullResources);
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
+ Result result =
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Configurations_.CDS_NAME)
+ .where(c -> c.get("resourceGroup_resourceGroupId").eq("default"))));
+
+ assertThat(result.list()).isEmpty();
+ }
+
+ @Test
+ void onCreate_createsConfiguration() {
+ AiConfigurationCreationResponse response = mock(AiConfigurationCreationResponse.class);
+ when(response.getId()).thenReturn("new-cfg-id");
+ when(configurationApi.create(eq("default"), any(AiConfigurationBaseData.class)))
+ .thenReturn(response);
- ConfigurationHandler handler = new ConfigurationHandler(service);
- handler.onRead(context);
- }
+ Result result =
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Insert.into(Configurations_.CDS_NAME)
+ .entry(
+ Map.of(
+ "name", "test-config",
+ "executableId", "exec-1",
+ "scenarioId", "foundation-models",
+ "resourceGroup_resourceGroupId", "default"))));
- @SuppressWarnings("unchecked")
- ArgumentCaptor>> captor = ArgumentCaptor.forClass(List.class);
- verify(context).setResult(captor.capture());
- assertThat(captor.getValue()).isEmpty();
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(AiConfigurationBaseData.class);
+ verify(configurationApi).create(eq("default"), captor.capture());
+ assertThat(captor.getValue().getName()).isEqualTo("test-config");
+ assertThat(captor.getValue().getExecutableId()).isEqualTo("exec-1");
+ assertThat(captor.getValue().getScenarioId()).isEqualTo("foundation-models");
+ assertThat(result.single().get("id")).isEqualTo("new-cfg-id");
}
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java
index cc26a3c..c4fad6c 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java
@@ -7,187 +7,193 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
+import com.sap.ai.sdk.core.AiCoreService;
+import com.sap.ai.sdk.core.client.ConfigurationApi;
import com.sap.ai.sdk.core.client.DeploymentApi;
+import com.sap.ai.sdk.core.client.ResourceGroupApi;
import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest;
import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse;
import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest;
import com.sap.ai.sdk.core.model.AiExecutionStatus;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
-import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments;
-import com.sap.cds.ql.cqn.AnalysisResult;
-import com.sap.cds.ql.cqn.CqnAnalyzer;
-import com.sap.cds.ql.cqn.CqnUpdate;
-import com.sap.cds.reflect.CdsModel;
+import com.sap.cds.Result;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_;
+import com.sap.cds.ql.Insert;
+import com.sap.cds.ql.Update;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
-import com.sap.cds.services.cds.CdsCreateEventContext;
-import com.sap.cds.services.cds.CdsUpdateEventContext;
-import java.util.HashMap;
-import java.util.List;
+import com.sap.cds.services.cds.RemoteService;
+import com.sap.cds.services.impl.environment.SimplePropertiesProvider;
+import com.sap.cds.services.request.RequestContext;
+import com.sap.cds.services.runtime.CdsRuntime;
+import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
import java.util.Map;
+import java.util.function.Function;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.MockedStatic;
-import org.mockito.junit.jupiter.MockitoExtension;
-@ExtendWith(MockitoExtension.class)
+/**
+ * Integration-style tests for {@link DeploymentHandler} using a real CDS runtime. Only the SDK API
+ * clients (DeploymentApi, ResourceGroupApi, ConfigurationApi) are mocked since they talk to a
+ * remote AI Core service.
+ */
class DeploymentHandlerTest {
- @Mock private AICoreServiceImpl service;
- @Mock private DeploymentApi deploymentApi;
- @Mock private CdsUpdateEventContext updateContext;
- @Mock private CdsCreateEventContext createContext;
+ private static CdsRuntime runtime;
+ private static RemoteService service;
+ private static DeploymentApi deploymentApi;
+ private static ResourceGroupApi resourceGroupApi;
+ private static ConfigurationApi configurationApi;
- private DeploymentHandler cut;
+ @BeforeAll
+ static void bootRuntime() {
+ deploymentApi = mock(DeploymentApi.class);
+ resourceGroupApi = mock(ResourceGroupApi.class);
+ configurationApi = mock(ConfigurationApi.class);
- @BeforeEach
- void setup() {
- when(service.getDeploymentApi()).thenReturn(deploymentApi);
- cut = new DeploymentHandler(service);
- }
+ var props = HandlerTestUtils.aicoreProperties();
- @Test
- void onUpdate_emptyEntries_throwsBadRequest() {
- List entries = List.of();
+ var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props));
+ configurer.cdsModel("edmx/csn.json");
+ configurer.serviceConfigurations();
+ runtime = configurer.getCdsRuntime();
- assertThatThrownBy(() -> cut.onUpdate(updateContext, entries))
- .isInstanceOfSatisfying(
- ServiceException.class,
- e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST))
- .hasMessageContaining("No update payload provided");
+ AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false);
+ AICoreClients clients =
+ new AICoreClients(
+ deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class));
+ DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi);
- verifyNoInteractions(deploymentApi);
- }
+ configurer.eventHandler(new AICoreApiHandler(config, clients, resolver));
+ configurer.eventHandler(new DeploymentHandler(config, clients, resolver));
+ configurer.complete();
- @Test
- void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() {
- List entries = List.of(Deployments.of(Map.of("ttl", "1d")));
-
- assertThatThrownBy(() -> cut.onUpdate(updateContext, entries))
- .isInstanceOfSatisfying(
- ServiceException.class,
- e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST))
- .hasMessageContaining("targetStatus")
- .hasMessageContaining("configurationId");
-
- verifyNoInteractions(deploymentApi);
- }
-
- @Test
- void onUpdate_withTargetStatus_callsModifyWithTargetStatus() {
- Deployments data = Deployments.create();
- data.setTargetStatus("STOPPED");
- List entries = List.of(data);
-
- CqnUpdate cqnUpdate = mock(CqnUpdate.class);
- CdsModel model = mock(CdsModel.class);
- CqnAnalyzer analyzer = mock(CqnAnalyzer.class);
- AnalysisResult analysisResult = mock(AnalysisResult.class);
-
- when(updateContext.getCqn()).thenReturn(cqnUpdate);
- when(updateContext.getModel()).thenReturn(model);
- Map keys = new HashMap<>();
- keys.put(Deployments.ID, "dep-123");
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult);
- when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-1");
- when(service.isProviderUser()).thenReturn(true);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- cut.onUpdate(updateContext, entries);
- }
-
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(AiDeploymentModificationRequest.class);
- verify(deploymentApi).modify(eq("rg-1"), eq("dep-123"), captor.capture());
- assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED");
+ service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
}
- @Test
- void onUpdate_withConfigurationId_callsModifyWithConfigurationId() {
- Deployments data = Deployments.create();
- data.setConfigurationId("config-456");
- List entries = List.of(data);
-
- CqnUpdate cqnUpdate = mock(CqnUpdate.class);
- CdsModel model = mock(CdsModel.class);
- CqnAnalyzer analyzer = mock(CqnAnalyzer.class);
- AnalysisResult analysisResult = mock(AnalysisResult.class);
-
- when(updateContext.getCqn()).thenReturn(cqnUpdate);
- when(updateContext.getModel()).thenReturn(model);
- Map keys = new HashMap<>();
- keys.put(Deployments.ID, "dep-789");
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult);
- when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-2");
- when(service.isProviderUser()).thenReturn(true);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- cut.onUpdate(updateContext, entries);
- }
-
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(AiDeploymentModificationRequest.class);
- verify(deploymentApi).modify(eq("rg-2"), eq("dep-789"), captor.capture());
- assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456");
+ @BeforeEach
+ void clearMockInvocations() {
+ clearInvocations(deploymentApi, resourceGroupApi, configurationApi);
}
@Test
void onCreate_createsDeploymentWithConfigurationId() {
- Deployments entry = Deployments.create();
- entry.setConfigurationId("cfg-1");
- entry.put(Deployments.RESOURCE_GROUP, Map.of("resourceGroupId", "rg-test"));
- List entries = List.of(entry);
-
AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class);
when(response.getId()).thenReturn("new-dep-id");
when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN);
- when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-test");
- when(service.isProviderUser()).thenReturn(true);
- when(deploymentApi.create(eq("rg-test"), any(AiDeploymentCreationRequest.class)))
+ when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class)))
.thenReturn(response);
- cut.onCreate(createContext, entries);
+ Result result =
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Insert.into(Deployments_.CDS_NAME)
+ .entry(
+ Map.of(
+ "configurationId", "cfg-1",
+ "resourceGroup_resourceGroupId", "default"))));
- verify(createContext).setResult(any(List.class));
ArgumentCaptor captor =
ArgumentCaptor.forClass(AiDeploymentCreationRequest.class);
- verify(deploymentApi).create(eq("rg-test"), captor.capture());
+ verify(deploymentApi).create(eq("default"), captor.capture());
assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1");
+ assertThat(result.single().get("id")).isEqualTo("new-dep-id");
}
@Test
void onCreate_withTtl_setsTtlOnRequest() {
- Deployments entry = Deployments.create();
- entry.setConfigurationId("cfg-2");
- entry.setTtl("PT24H");
- List entries = List.of(entry);
-
AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class);
when(response.getId()).thenReturn("dep-ttl");
when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN);
- when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-default");
- when(service.isProviderUser()).thenReturn(true);
- when(deploymentApi.create(eq("rg-default"), any(AiDeploymentCreationRequest.class)))
+ when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class)))
.thenReturn(response);
- cut.onCreate(createContext, entries);
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Insert.into(Deployments_.CDS_NAME)
+ .entry(
+ Map.of(
+ "configurationId", "cfg-2",
+ "ttl", "PT24H",
+ "resourceGroup_resourceGroupId", "default"))));
ArgumentCaptor captor =
ArgumentCaptor.forClass(AiDeploymentCreationRequest.class);
- verify(deploymentApi).create(eq("rg-default"), captor.capture());
+ verify(deploymentApi).create(eq("default"), captor.capture());
assertThat(captor.getValue().getTtl()).isEqualTo("PT24H");
}
+
+ @Test
+ void onUpdate_withTargetStatus_callsModifyWithTargetStatus() {
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Update.entity(Deployments_.CDS_NAME)
+ .where(d -> d.get("id").eq("dep-123"))
+ .data("targetStatus", "STOPPED")));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(AiDeploymentModificationRequest.class);
+ verify(deploymentApi).modify(eq("default"), eq("dep-123"), captor.capture());
+ assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED");
+ }
+
+ @Test
+ void onUpdate_withConfigurationId_callsModifyWithConfigurationId() {
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Update.entity(Deployments_.CDS_NAME)
+ .where(d -> d.get("id").eq("dep-789"))
+ .data("configurationId", "config-456")));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(AiDeploymentModificationRequest.class);
+ verify(deploymentApi).modify(eq("default"), eq("dep-789"), captor.capture());
+ assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456");
+ }
+
+ @Test
+ void onUpdate_withoutTargetStatusOrConfigurationId_throwsBadRequest() {
+ assertThatThrownBy(
+ () ->
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Update.entity(Deployments_.CDS_NAME)
+ .where(d -> d.get("id").eq("dep-x"))
+ .data("ttl", "1d"))))
+ .isInstanceOfSatisfying(
+ ServiceException.class,
+ e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST))
+ .hasMessageContaining("targetStatus")
+ .hasMessageContaining("configurationId");
+ }
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java
new file mode 100644
index 0000000..a8ac72c
--- /dev/null
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java
@@ -0,0 +1,23 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors.
+ */
+package com.sap.cds.feature.aicore.core.handler;
+
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.services.environment.CdsProperties;
+import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig;
+
+/** Shared test utilities for handler tests that boot a CDS runtime with the AICore model. */
+final class HandlerTestUtils {
+
+ private HandlerTestUtils() {}
+
+ /** Creates CdsProperties with the AICore RemoteService configured. */
+ static CdsProperties aicoreProperties() {
+ CdsProperties props = new CdsProperties();
+ RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore_.CDS_NAME);
+ rsConfig.setModel(AICore_.CDS_NAME);
+ props.getRemote().getServices().put(AICore_.CDS_NAME, rsConfig);
+ return props;
+ }
+}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java
index 14a828b..abe4335 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java
@@ -4,454 +4,265 @@
package com.sap.cds.feature.aicore.core.handler;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
-import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.sap.ai.sdk.core.AiCoreService;
+import com.sap.ai.sdk.core.client.ConfigurationApi;
+import com.sap.ai.sdk.core.client.DeploymentApi;
import com.sap.ai.sdk.core.client.ResourceGroupApi;
import com.sap.ai.sdk.core.model.BckndResourceGroup;
import com.sap.ai.sdk.core.model.BckndResourceGroupLabel;
import com.sap.ai.sdk.core.model.BckndResourceGroupList;
import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest;
import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
-import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups;
-import com.sap.cds.ql.cqn.AnalysisResult;
-import com.sap.cds.ql.cqn.CqnAnalyzer;
-import com.sap.cds.ql.cqn.CqnSelect;
-import com.sap.cds.ql.cqn.CqnUpdate;
-import com.sap.cds.reflect.CdsModel;
-import com.sap.cds.services.ServiceException;
-import com.sap.cds.services.cds.CdsCreateEventContext;
-import com.sap.cds.services.cds.CdsReadEventContext;
-import com.sap.cds.services.cds.CdsUpdateEventContext;
-import java.util.HashMap;
+import com.sap.cds.Result;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_;
+import com.sap.cds.ql.Insert;
+import com.sap.cds.ql.Select;
+import com.sap.cds.ql.Update;
+import com.sap.cds.services.cds.RemoteService;
+import com.sap.cds.services.impl.environment.SimplePropertiesProvider;
+import com.sap.cds.services.request.RequestContext;
+import com.sap.cds.services.runtime.CdsRuntime;
+import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
import java.util.List;
import java.util.Map;
+import java.util.function.Function;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.MockedStatic;
-import org.mockito.junit.jupiter.MockitoExtension;
-@ExtendWith(MockitoExtension.class)
+/**
+ * Integration-style tests for {@link ResourceGroupHandler} using a real CDS runtime. Only the SDK
+ * API clients are mocked since they talk to a remote AI Core service.
+ */
class ResourceGroupHandlerTest {
- @Mock private AICoreServiceImpl service;
- @Mock private ResourceGroupApi resourceGroupApi;
- @Mock private CdsCreateEventContext createContext;
+ private static CdsRuntime runtime;
+ private static RemoteService service;
+ private static ResourceGroupApi resourceGroupApi;
+
+ @BeforeAll
+ static void bootRuntime() {
+ resourceGroupApi = mock(ResourceGroupApi.class);
+ DeploymentApi deploymentApi = mock(DeploymentApi.class);
+ ConfigurationApi configurationApi = mock(ConfigurationApi.class);
+
+ var props = HandlerTestUtils.aicoreProperties();
+
+ var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props));
+ configurer.cdsModel("edmx/csn.json");
+ configurer.serviceConfigurations();
+ runtime = configurer.getCdsRuntime();
- private ResourceGroupHandler handler;
+ AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false);
+ AICoreClients clients =
+ new AICoreClients(
+ deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class));
+ DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi);
+
+ configurer.eventHandler(new AICoreApiHandler(config, clients, resolver));
+ configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver));
+ configurer.complete();
+
+ service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
+ }
@BeforeEach
- void setUp() {
- when(service.getResourceGroupApi()).thenReturn(resourceGroupApi);
- handler = new ResourceGroupHandler(service);
+ void clearMockInvocations() {
+ clearInvocations(resourceGroupApi);
}
@Test
- void onCreate_withTenantIdOnly_setsOnlyTenantLabel() {
- Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a");
- List entries = List.of(ResourceGroups.of(entry));
+ void onRead_returnsAllResourceGroups() {
+ BckndResourceGroup rg = mock(BckndResourceGroup.class);
+ when(rg.getResourceGroupId()).thenReturn("rg-1");
+ when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED);
+
+ BckndResourceGroupList list = mock(BckndResourceGroupList.class);
+ when(list.getResources()).thenReturn(List.of(rg));
+ when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())).thenReturn(list);
+
+ Result result =
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx -> service.run(Select.from(ResourceGroups_.CDS_NAME)));
+
+ verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), any());
+ assertThat(result.list()).hasSize(1);
+ assertThat(result.single().get("resourceGroupId")).isEqualTo("rg-1");
+ }
- handler.onCreate(createContext, entries);
+ @Test
+ void onCreate_createsResourceGroup() {
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Insert.into(ResourceGroups_.CDS_NAME)
+ .entry(Map.of("resourceGroupId", "rg-new"))));
- BckndResourceGroupsPostRequest request = captureCreateRequest();
- assertThat(request.getResourceGroupId()).isEqualTo("rg-1");
- assertThat(request.getLabels())
- .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue)
- .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-a"));
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class);
+ verify(resourceGroupApi).create(captor.capture());
+ assertThat(captor.getValue().getResourceGroupId()).isEqualTo("rg-new");
}
@Test
- void onCreate_withLabelsOnly_setsOnlyUserLabels() {
- Map entry =
- Map.of(
- "resourceGroupId",
- "rg-2",
- "labels",
- List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai")));
- List entries = List.of(ResourceGroups.of(entry));
-
- handler.onCreate(createContext, entries);
-
- BckndResourceGroupsPostRequest request = captureCreateRequest();
- assertThat(request.getResourceGroupId()).isEqualTo("rg-2");
- assertThat(request.getLabels())
+ void onCreate_withTenantId_setsTenantLabel() {
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Insert.into(ResourceGroups_.CDS_NAME)
+ .entry(
+ Map.of(
+ "resourceGroupId", "rg-tenant",
+ "tenantId", "tenant-a"))));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class);
+ verify(resourceGroupApi).create(captor.capture());
+ assertThat(captor.getValue().getLabels())
.extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue)
- .containsExactly(tuple("env", "prod"), tuple("team", "ai"));
+ .containsExactly(tuple(AICoreConfig.TENANT_LABEL_KEY, "tenant-a"));
}
@Test
- void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() {
- Map entry =
- Map.of(
- "resourceGroupId",
- "rg-3",
- "tenantId",
- "tenant-b",
- "labels",
- List.of(Map.of("key", "env", "value", "prod")));
- List entries = List.of(ResourceGroups.of(entry));
-
- handler.onCreate(createContext, entries);
-
- BckndResourceGroupsPostRequest request = captureCreateRequest();
- // Tenant label first, then user-supplied labels — and tenant label is NOT lost.
- assertThat(request.getLabels())
+ void onUpdate_withLabels_callsPatchWithLabels() {
+ BckndResourceGroup rg = mock(BckndResourceGroup.class);
+ when(rg.getResourceGroupId()).thenReturn("rg-upd");
+ when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED);
+ when(resourceGroupApi.get("rg-upd")).thenReturn(rg);
+
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Update.entity(ResourceGroups_.CDS_NAME)
+ .where(d -> d.get("resourceGroupId").eq("rg-upd"))
+ .data("labels", List.of(Map.of("key", "env", "value", "staging")))));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class);
+ verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture());
+ assertThat(captor.getValue().getLabels())
.extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue)
- .containsExactly(
- tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-b"), tuple("env", "prod"));
+ .containsExactly(tuple("env", "staging"));
}
@Test
- void onCreate_userSuppliedTenantLabelTakesPrecedence() {
- Map entry =
- Map.of(
- "resourceGroupId",
- "rg-4",
- "tenantId",
- "tenant-auto",
- "labels",
- List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user")));
- List entries = List.of(ResourceGroups.of(entry));
-
- handler.onCreate(createContext, entries);
-
- BckndResourceGroupsPostRequest request = captureCreateRequest();
- assertThat(request.getLabels())
- .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue)
- .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user"));
+ void onUpdate_withoutLabels_callsPatchWithoutLabels() {
+ BckndResourceGroup rg = mock(BckndResourceGroup.class);
+ when(rg.getResourceGroupId()).thenReturn("rg-nolabel");
+ when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED);
+ when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg);
+
+ runtime
+ .requestContext()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Update.entity(ResourceGroups_.CDS_NAME)
+ .where(d -> d.get("resourceGroupId").eq("rg-nolabel"))
+ .data("statusMessage", "updated")));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class);
+ verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture());
+ assertThat(captor.getValue().getLabels()).isNullOrEmpty();
}
+ /**
+ * Multi-tenancy tests use a separate runtime with MT enabled to verify tenant-scoped label
+ * selectors.
+ */
@Nested
- @ExtendWith(MockitoExtension.class)
- class OnUpdateTests {
+ class MultiTenancyTests {
- @Mock private CdsUpdateEventContext updateContext;
- @Mock private CqnUpdate cqnUpdate;
- @Mock private CdsModel model;
- @Mock private CqnAnalyzer analyzer;
- @Mock private AnalysisResult analysisResult;
+ private static CdsRuntime mtRuntime;
+ private static RemoteService mtService;
+ private static ResourceGroupApi mtResourceGroupApi;
- @Test
- void onUpdate_withLabels_callsPatchWithLabels() {
- Map keys = new HashMap<>();
- keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-upd");
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult);
- when(updateContext.getCqn()).thenReturn(cqnUpdate);
- when(updateContext.getModel()).thenReturn(model);
-
- Map data = new HashMap<>();
- data.put(ResourceGroups.LABELS, List.of(Map.of("key", "env", "value", "staging")));
- when(cqnUpdate.entries()).thenReturn(List.of(data));
-
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class);
- when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY);
- when(label.getValue()).thenReturn("my-tenant");
- when(rg.getLabels()).thenReturn(List.of(label));
- when(resourceGroupApi.get("rg-upd")).thenReturn(rg);
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn("my-tenant");
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- handler.onUpdate(updateContext);
- }
-
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class);
- verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture());
- assertThat(captor.getValue().getLabels())
- .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue)
- .containsExactly(tuple("env", "staging"));
- }
+ @BeforeAll
+ static void bootMtRuntime() {
+ mtResourceGroupApi = mock(ResourceGroupApi.class);
+ DeploymentApi deploymentApi = mock(DeploymentApi.class);
+ ConfigurationApi configurationApi = mock(ConfigurationApi.class);
- @Test
- void onUpdate_withoutLabels_callsPatchWithoutLabels() {
- Map keys = new HashMap<>();
- keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-nolabel");
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult);
- when(updateContext.getCqn()).thenReturn(cqnUpdate);
- when(updateContext.getModel()).thenReturn(model);
-
- Map data = new HashMap<>();
- // no labels in update payload
- when(cqnUpdate.entries()).thenReturn(List.of(data));
-
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg);
- when(service.isProviderUser()).thenReturn(true);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- handler.onUpdate(updateContext);
- }
-
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class);
- verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture());
- assertThat(captor.getValue().getLabels()).isNullOrEmpty();
- }
- }
+ var props = HandlerTestUtils.aicoreProperties();
- @Nested
- @ExtendWith(MockitoExtension.class)
- class BuildTenantLabelSelectorTests {
+ var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props));
+ configurer.cdsModel("edmx/csn.json");
+ configurer.serviceConfigurations();
+ mtRuntime = configurer.getCdsRuntime();
- @Mock private CdsReadEventContext readContext;
- @Mock private CqnSelect cqnSelect;
- @Mock private CdsModel model;
- @Mock private CqnAnalyzer analyzer;
- @Mock private AnalysisResult analysisResult;
+ AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true);
+ AICoreClients clients =
+ new AICoreClients(
+ deploymentApi, configurationApi, mtResourceGroupApi, mock(AiCoreService.class));
+ DeploymentResolver resolver =
+ new DeploymentResolver(config, deploymentApi, mtResourceGroupApi);
- @Test
- void readAll_withTenantIdFilter_usesLabelSelector() {
- Map keys = new HashMap<>();
- Map values = new HashMap<>();
- values.put(ResourceGroups.TENANT_ID, "tenant-x");
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
-
- BckndResourceGroupList result = mock(BckndResourceGroupList.class);
- when(result.getResources()).thenReturn(List.of());
- when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any()))
- .thenReturn(result);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- handler.onRead(readContext);
- }
-
- @SuppressWarnings("unchecked")
- ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class);
- verify(resourceGroupApi)
- .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture());
- assertThat(selectorCaptor.getValue())
- .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=tenant-x");
- }
+ configurer.eventHandler(new AICoreApiHandler(config, clients, resolver));
+ configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver));
+ configurer.complete();
- @Test
- void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() {
- Map keys = new HashMap<>();
- Map values = new HashMap<>();
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.isProviderUser()).thenReturn(false);
- when(service.currentTenantId()).thenReturn("current-tenant");
-
- BckndResourceGroupList result = mock(BckndResourceGroupList.class);
- when(result.getResources()).thenReturn(List.of());
- when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any()))
- .thenReturn(result);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- handler.onRead(readContext);
- }
-
- @SuppressWarnings("unchecked")
- ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class);
- verify(resourceGroupApi)
- .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture());
- assertThat(selectorCaptor.getValue())
- .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=current-tenant");
+ mtService = mtRuntime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
}
- @Test
- void readAll_multiTenancy_nullTenant_noLabelSelector() {
- Map keys = new HashMap<>();
- Map values = new HashMap<>();
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.isProviderUser()).thenReturn(false);
- when(service.currentTenantId()).thenReturn(null);
-
- BckndResourceGroupList result = mock(BckndResourceGroupList.class);
- when(result.getResources()).thenReturn(List.of());
- when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any()))
- .thenReturn(result);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- handler.onRead(readContext);
- }
-
- verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null));
- }
-
- @Test
- void readAll_singleTenancy_noLabelSelector() {
- Map keys = new HashMap<>();
- Map values = new HashMap<>();
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
- when(service.isMultiTenancyEnabled()).thenReturn(false);
-
- BckndResourceGroupList result = mock(BckndResourceGroupList.class);
- when(result.getResources()).thenReturn(List.of());
- when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any()))
- .thenReturn(result);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- handler.onRead(readContext);
- }
-
- verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null));
- }
- }
-
- @Nested
- @ExtendWith(MockitoExtension.class)
- class EnsureOwnedByCurrentTenantTests {
-
- @Mock private CdsReadEventContext readContext;
- @Mock private CqnSelect cqnSelect;
- @Mock private CdsModel model;
- @Mock private CqnAnalyzer analyzer;
- @Mock private AnalysisResult analysisResult;
-
- @Test
- void readById_providerUser_allowsAccessToAnyRg() {
- Map keys = new HashMap<>();
- keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-any");
- Map values = new HashMap<>();
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
- when(service.isProviderUser()).thenReturn(true);
-
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- when(rg.getResourceGroupId()).thenReturn("rg-any");
- when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED);
- when(rg.getLabels()).thenReturn(null);
- when(resourceGroupApi.get("rg-any")).thenReturn(rg);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException();
- }
+ @BeforeEach
+ void clearMtMockInvocations() {
+ clearInvocations(mtResourceGroupApi);
}
@Test
- void readById_singleTenancy_allowsAccess() {
- Map keys = new HashMap<>();
- keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-single");
- Map values = new HashMap<>();
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(false);
-
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- when(rg.getResourceGroupId()).thenReturn("rg-single");
- when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED);
- when(rg.getLabels()).thenReturn(null);
- when(resourceGroupApi.get("rg-single")).thenReturn(rg);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException();
- }
- }
-
- @Test
- void readById_multiTenancy_wrongTenant_throws404() {
- Map keys = new HashMap<>();
- keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-other");
- Map values = new HashMap<>();
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn("tenant-a");
-
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class);
- when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY);
- when(label.getValue()).thenReturn("tenant-b");
- when(rg.getLabels()).thenReturn(List.of(label));
- when(resourceGroupApi.get("rg-other")).thenReturn(rg);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- assertThatThrownBy(() -> handler.onRead(readContext))
- .isInstanceOf(ServiceException.class)
- .hasMessageContaining("not found");
- }
- }
+ @SuppressWarnings("unchecked")
+ void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() {
+ BckndResourceGroupList list = mock(BckndResourceGroupList.class);
+ when(list.getResources()).thenReturn(List.of());
+ when(mtResourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(list);
+
+ mtRuntime
+ .requestContext()
+ .modifyUser(
+ u ->
+ u.setTenant("current-tenant")
+ .setIsSystemUser(false)
+ .setIsInternalUser(false)
+ .setName("test-user")
+ .setIsAuthenticated(true))
+ .run(
+ (Function)
+ ctx -> mtService.run(Select.from(ResourceGroups_.CDS_NAME)));
- @Test
- void readById_multiTenancy_matchingTenant_allowsAccess() {
- Map keys = new HashMap<>();
- keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-mine");
- Map values = new HashMap<>();
- when(analysisResult.targetKeys()).thenReturn(keys);
- when(analysisResult.targetValues()).thenReturn(values);
- when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult);
- when(readContext.getCqn()).thenReturn(cqnSelect);
- when(readContext.getModel()).thenReturn(model);
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn("tenant-a");
-
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class);
- when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY);
- when(label.getValue()).thenReturn("tenant-a");
- when(rg.getLabels()).thenReturn(List.of(label));
- when(rg.getResourceGroupId()).thenReturn("rg-mine");
- when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED);
- when(resourceGroupApi.get("rg-mine")).thenReturn(rg);
-
- try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) {
- staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer);
- assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException();
- }
+ ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class);
+ verify(mtResourceGroupApi)
+ .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture());
+ assertThat(selectorCaptor.getValue())
+ .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=current-tenant");
}
}
-
- private BckndResourceGroupsPostRequest captureCreateRequest() {
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class);
- verify(resourceGroupApi).create(captor.capture());
- return captor.getValue();
- }
}
diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java
index d1171ec..a79326f 100644
--- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java
+++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java
@@ -5,150 +5,260 @@
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.sap.ai.sdk.core.AiCoreService;
+import com.sap.ai.sdk.core.client.ConfigurationApi;
+import com.sap.ai.sdk.core.client.DeploymentApi;
import com.sap.ai.sdk.core.client.ResourceGroupApi;
+import com.sap.ai.sdk.core.model.AiDeploymentList;
import com.sap.ai.sdk.core.model.BckndResourceGroup;
import com.sap.ai.sdk.core.model.BckndResourceGroupLabel;
-import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
+import com.sap.cds.Result;
+import com.sap.cds.feature.aicore.core.AICoreClients;
+import com.sap.cds.feature.aicore.core.AICoreConfig;
+import com.sap.cds.feature.aicore.core.DeploymentResolver;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_;
+import com.sap.cds.ql.Select;
import com.sap.cds.services.ServiceException;
+import com.sap.cds.services.cds.RemoteService;
+import com.sap.cds.services.impl.environment.SimplePropertiesProvider;
+import com.sap.cds.services.request.RequestContext;
+import com.sap.cds.services.runtime.CdsRuntime;
+import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
import java.util.List;
+import java.util.function.Function;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
/**
- * Unit tests for the tenant-scoping guard methods in {@link AbstractCrudHandler}.
- * Tests the {@code ensureResourceGroupAccessible} logic which is used by
- * ConfigurationHandler and DeploymentHandler.
+ * Integration-style tests for tenant-scoping logic through actual CQN READ operations. Verifies
+ * that {@code ensureResourceGroupAccessible} (used by DeploymentHandler and ConfigurationHandler)
+ * correctly enforces tenant isolation when multi-tenancy is enabled.
*/
-@ExtendWith(MockitoExtension.class)
class TenantScopingTest {
- @Mock private AICoreServiceImpl service;
- @Mock private ResourceGroupApi resourceGroupApi;
+ private static CdsRuntime runtime;
+ private static RemoteService service;
+ private static DeploymentApi deploymentApi;
+ private static ResourceGroupApi resourceGroupApi;
- /** Concrete subclass to expose the protected method for testing. */
- private static class TestableHandler extends AbstractCrudHandler {
- TestableHandler(AICoreServiceImpl service) {
- super(service);
- }
+ @BeforeAll
+ static void bootRuntime() {
+ deploymentApi = mock(DeploymentApi.class);
+ resourceGroupApi = mock(ResourceGroupApi.class);
+ ConfigurationApi configurationApi = mock(ConfigurationApi.class);
- void callEnsureResourceGroupAccessible(String resourceGroupId) {
- ensureResourceGroupAccessible(resourceGroupId);
- }
- }
+ var props = HandlerTestUtils.aicoreProperties();
- private TestableHandler handler;
+ var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props));
+ configurer.cdsModel("edmx/csn.json");
+ configurer.serviceConfigurations();
+ runtime = configurer.getCdsRuntime();
- @BeforeEach
- void setUp() {
- handler = new TestableHandler(service);
- }
+ AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true);
+ AICoreClients clients =
+ new AICoreClients(
+ deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class));
+ DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi);
- // ── ensureResourceGroupAccessible ──────────────────────────────────────────
+ configurer.eventHandler(new AICoreApiHandler(config, clients, resolver));
+ configurer.eventHandler(new DeploymentHandler(config, clients, resolver));
+ configurer.complete();
- @Test
- void providerUser_allowsAccessToAnyResourceGroup() {
- when(service.isProviderUser()).thenReturn(true);
+ service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
+ }
- assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg"))
- .doesNotThrowAnyException();
- verify(resourceGroupApi, never()).get("any-rg");
+ @BeforeEach
+ void clearMockInvocations() {
+ clearInvocations(deploymentApi, resourceGroupApi);
}
@Test
- void singleTenancy_allowsAccessToAnyResourceGroup() {
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(false);
+ void matchingTenant_allowsAccess() {
+ stubResourceGroupWithTenant("rg-a", "tenant-A");
+ stubDeploymentQuery();
- assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg"))
+ assertThatCode(
+ () ->
+ runtime
+ .requestContext()
+ .modifyUser(
+ u ->
+ u.setTenant("tenant-A")
+ .setIsSystemUser(false)
+ .setIsInternalUser(false)
+ .setName("user-a")
+ .setIsAuthenticated(true))
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Deployments_.CDS_NAME)
+ .where(
+ d ->
+ d.get("resourceGroup_resourceGroupId")
+ .eq("rg-a")))))
.doesNotThrowAnyException();
- verify(resourceGroupApi, never()).get("any-rg");
}
@Test
- void multiTenancy_nullTenant_allowsAccess() {
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn(null);
+ void nonMatchingTenant_throws404() {
+ stubResourceGroupWithTenant("rg-b", "tenant-A");
- assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg"))
- .doesNotThrowAnyException();
- verify(resourceGroupApi, never()).get("any-rg");
+ assertThatThrownBy(
+ () ->
+ runtime
+ .requestContext()
+ .modifyUser(
+ u ->
+ u.setTenant("tenant-B")
+ .setIsSystemUser(false)
+ .setIsInternalUser(false)
+ .setName("user-b")
+ .setIsAuthenticated(true))
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Deployments_.CDS_NAME)
+ .where(
+ d ->
+ d.get("resourceGroup_resourceGroupId")
+ .eq("rg-b")))))
+ .isInstanceOf(ServiceException.class)
+ .hasMessageContaining("not found");
}
@Test
- void multiTenancy_matchingTenantLabel_allowsAccess() {
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn("tenant-a");
- when(service.getResourceGroupApi()).thenReturn(resourceGroupApi);
+ void providerUser_bypassesTenantCheck() {
+ stubResourceGroupWithTenant("rg-c", "tenant-X");
+ stubDeploymentQuery();
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class);
- when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY);
- when(label.getValue()).thenReturn("tenant-a");
- when(rg.getLabels()).thenReturn(List.of(label));
- when(resourceGroupApi.get("rg-for-a")).thenReturn(rg);
-
- assertThatCode(() -> handler.callEnsureResourceGroupAccessible("rg-for-a"))
+ // System user (provider) should bypass tenant check regardless of tenant label
+ assertThatCode(
+ () ->
+ runtime
+ .requestContext()
+ .systemUser()
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Deployments_.CDS_NAME)
+ .where(
+ d ->
+ d.get("resourceGroup_resourceGroupId")
+ .eq("rg-c")))))
.doesNotThrowAnyException();
}
@Test
- void multiTenancy_nonMatchingTenantLabel_throws404() {
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn("tenant-a");
- when(service.getResourceGroupApi()).thenReturn(resourceGroupApi);
-
- BckndResourceGroup rg = mock(BckndResourceGroup.class);
- BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class);
- when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY);
- when(label.getValue()).thenReturn("tenant-b");
- when(rg.getLabels()).thenReturn(List.of(label));
- when(resourceGroupApi.get("rg-for-b")).thenReturn(rg);
+ void nullTenantUser_bypassesTenantCheck() {
+ stubDeploymentQuery();
- assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-for-b"))
- .isInstanceOf(ServiceException.class)
- .hasMessageContaining("not found");
+ // Non-system user with null tenant bypasses check (currentTenantId() returns null)
+ assertThatCode(
+ () ->
+ runtime
+ .requestContext()
+ .modifyUser(
+ u ->
+ u.setTenant(null)
+ .setIsSystemUser(false)
+ .setIsInternalUser(false)
+ .setName("no-tenant-user")
+ .setIsAuthenticated(true))
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Deployments_.CDS_NAME)
+ .where(
+ d ->
+ d.get("resourceGroup_resourceGroupId")
+ .eq("rg-d")))))
+ .doesNotThrowAnyException();
}
@Test
- void multiTenancy_noLabels_throws404() {
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn("tenant-a");
- when(service.getResourceGroupApi()).thenReturn(resourceGroupApi);
-
+ void noLabelsOnResourceGroup_throws404() {
BckndResourceGroup rg = mock(BckndResourceGroup.class);
when(rg.getLabels()).thenReturn(null);
when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg);
- assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-no-labels"))
+ assertThatThrownBy(
+ () ->
+ runtime
+ .requestContext()
+ .modifyUser(
+ u ->
+ u.setTenant("tenant-A")
+ .setIsSystemUser(false)
+ .setIsInternalUser(false)
+ .setName("user-labels")
+ .setIsAuthenticated(true))
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Deployments_.CDS_NAME)
+ .where(
+ d ->
+ d.get("resourceGroup_resourceGroupId")
+ .eq("rg-no-labels")))))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("not found");
}
@Test
- void multiTenancy_emptyLabels_throws404() {
- when(service.isProviderUser()).thenReturn(false);
- when(service.isMultiTenancyEnabled()).thenReturn(true);
- when(service.currentTenantId()).thenReturn("tenant-a");
- when(service.getResourceGroupApi()).thenReturn(resourceGroupApi);
-
+ void emptyLabelsOnResourceGroup_throws404() {
BckndResourceGroup rg = mock(BckndResourceGroup.class);
when(rg.getLabels()).thenReturn(List.of());
when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg);
- assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-empty-labels"))
+ assertThatThrownBy(
+ () ->
+ runtime
+ .requestContext()
+ .modifyUser(
+ u ->
+ u.setTenant("tenant-A")
+ .setIsSystemUser(false)
+ .setIsInternalUser(false)
+ .setName("user-empty")
+ .setIsAuthenticated(true))
+ .run(
+ (Function)
+ ctx ->
+ service.run(
+ Select.from(Deployments_.CDS_NAME)
+ .where(
+ d ->
+ d.get("resourceGroup_resourceGroupId")
+ .eq("rg-empty-labels")))))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("not found");
}
+
+ private void stubResourceGroupWithTenant(String rgId, String tenantId) {
+ BckndResourceGroup rg = mock(BckndResourceGroup.class);
+ BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class);
+ when(label.getKey()).thenReturn(AICoreConfig.TENANT_LABEL_KEY);
+ when(label.getValue()).thenReturn(tenantId);
+ when(rg.getLabels()).thenReturn(List.of(label));
+ when(resourceGroupApi.get(rgId)).thenReturn(rg);
+ }
+
+ private void stubDeploymentQuery() {
+ AiDeploymentList emptyList = mock(AiDeploymentList.class);
+ when(emptyList.getResources()).thenReturn(List.of());
+ when(deploymentApi.query(any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(emptyList);
+ }
}
diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md
index 9b59766..088b165 100644
--- a/cds-feature-recommendations/README.md
+++ b/cds-feature-recommendations/README.md
@@ -7,7 +7,7 @@ AI-powered field recommendations for SAP Fiori UIs in CAP Java applications, lev
The plugin generically hooks into any draft-enabled entity that has properties with a value help. When a user edits a draft record, the plugin:
1. Fetches historical records as training context
-2. Sends context + current row to the RPT-1 model
+2. Sends context + current row to the provided model (default: RPT-1 model)
3. Returns predictions as `SAP_Recommendations` in the OData response
4. Fiori Elements renders the recommendations as suggestions in form fields
@@ -35,14 +35,9 @@ Or use the starter that bundles this with `cds-feature-ai-core`:
```
-### Prerequisites
+Requires an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding — see [`cds-feature-ai-core`](../cds-feature-ai-core/README.md) for setup.
-- An [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding (see [`cds-feature-ai-core`](../cds-feature-ai-core/README.md))
-- Entity must be **draft-enabled** (`@odata.draft.enabled`)
-- At least one field annotated with a **value list**
-- The `@cap-js/ai` CDS plugin must be installed (provides the model enhancement that adds `SAP_Recommendations` as a navigation property)
-
-### CDS Plugin
+#### CDS Plugin
Add `@cap-js/ai` to your project's `package.json`:
@@ -55,7 +50,7 @@ Add `@cap-js/ai` to your project's `package.json`:
}
```
-Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. Without this plugin, predictions will be computed but not serialized in OData responses.
+Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields.
Since the Java module `cds-feature-ai-core` already provides the `AICore` service CDS model, disable the duplicate model from `@cap-js/ai` in your `.cdsrc.json`:
@@ -71,9 +66,16 @@ Since the Java module `cds-feature-ai-core` already provides the `AICore` servic
## Enabling Recommendations
+For recommendations to fire on an entity:
+
+- The entity must be **draft-enabled** (`@odata.draft.enabled`)
+- At least one field must be annotated with a **value list**
+- The `SAP_Recommendations` navigation property must be present — either via the CDS plugin (see above) or added manually (see below). Without it, predictions are computed but not serialized in OData responses.
+
Recommendations are triggered for fields annotated with `@Common.ValueList`, `@Common.ValueListWithFixedValues`, or whose association target has `@cds.odata.valuelist`:
```cds
+@odata.draft.enabled
entity Books {
key ID : Integer;
title : String(111);
@@ -98,6 +100,42 @@ annotate Books with {
}
```
+#### Adding the SAP_Recommendations navigation property manually
+
+If you cannot use the CDS plugin, add the `SAP_Recommendations` navigation property directly in your CDS model. You need to:
+
+1. **Define a `RecommendationItem_*` type** for each CDS primitive type used by your value-list fields. Each type must contain the four fixed fields shown below — only `RecommendedFieldValue` varies by type.
+2. **Extend each target entity** with a `SAP_Recommendations` composition that has one entry per value-list field, using the field name as the property name and the matching `RecommendationItem_*` type.
+
+The property names inside `SAP_Recommendations` must exactly match the field names on the entity (e.g. `genre_ID`, `author_ID`).
+
+```cds
+// Define one type per CDS primitive used by your value-list fields
+type RecommendationItem_Integer {
+ RecommendedFieldValue : Integer;
+ RecommendedFieldDescription : String;
+ RecommendedFieldScoreValue : Decimal;
+ RecommendedFieldIsSuggestion: Boolean;
+}
+
+type RecommendationItem_UUID {
+ RecommendedFieldValue : UUID;
+ RecommendedFieldDescription : String;
+ RecommendedFieldScoreValue : Decimal;
+ RecommendedFieldIsSuggestion: Boolean;
+}
+
+// Extend your entity — one entry per value-list field
+extend my.Books with {
+ SAP_Recommendations: Composition of one {
+ genre_ID : many RecommendationItem_Integer;
+ author_ID: many RecommendationItem_UUID;
+ }
+}
+```
+
+See also the [SAP Fiori Elements – Recommendations documentation](https://help.sap.com/docs/SAPUI5/b2f662dd9d7a4ec680056733050b4d34/1a6324d5ad7f4034a93f911b4e53e080.html).
+
### Adding Text Descriptions
Use `@Common.Text` to show human-readable descriptions alongside recommended values:
@@ -116,13 +154,29 @@ annotate Books with {
}
```
+Fields annotated with `@UI.RecommendationState: 0` are excluded from predictions entirely.
+A value of `1` (or omitting the annotation) means the field is eligible for recommendations.
+
+> **Note:** Since `@UI.RecommendationState` is a UI annotation, you must enable UI annotation loading
+> in the Java runtime for it to take effect. By default, the CAP Java runtime strips `@UI.*`
+> annotations from the in-memory model to reduce memory consumption (they are typically only
+> needed for OData metadata generation, not for runtime logic).
+>
+> ```yaml
+> cds:
+> model:
+> include-ui-annotations: true
+> ```
+
## Configuration
+The following configuration applies to the RPT-1 model implementation.
+
```yaml
cds:
requires:
recommendations:
- contextRowLimit: 2000 # Max historical rows used as training context
+ contextRowLimit: 2000 # Max historical rows used as training context (RPT-1)
```
See [`cds-feature-ai-core`](../cds-feature-ai-core/README.md) for AI Core connection and multi-tenancy configuration.
@@ -150,12 +204,14 @@ SAP Fiori Elements automatically renders these as suggestions in form fields whe
## Supported Field Types
-| Category | Types |
-| -------- | --------------------------------------------------------- |
-| String | `String`, `LargeString`, `UUID` |
-| Numeric | `Integer`, `Int16`, `Int32`, `Int64`, `Decimal`, `Double` |
-| Temporal | `Date`, `Time`, `DateTime`, `Timestamp` |
-| Other | `Boolean` |
+The following field types are supported by the RPT-1 model implementation:
+
+| Category | Types |
+| -------- | ---------------------------------------------------------------------- |
+| String | `String`, `LargeString`, `UUID` (treated as string) |
+| Numeric | `Integer`, `Int16`, `Int32`, `Int64`, `Integer64`, `Decimal`, `Double` |
+| Temporal | `Date`, `Time`, `DateTime`, `Timestamp` |
+| Other | `Boolean` |
Binary, vector, and draft system fields are excluded automatically.
diff --git a/cds-feature-recommendations/pom.xml b/cds-feature-recommendations/pom.xml
index 4d7f75e..b86d41b 100644
--- a/cds-feature-recommendations/pom.xml
+++ b/cds-feature-recommendations/pom.xml
@@ -49,7 +49,6 @@
com.sap.cds
cds-services-impl
- test
diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java
index e7ea5d5..1498d0f 100644
--- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java
+++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java
@@ -6,7 +6,6 @@
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.sap.cds.CdsData;
-import com.sap.cds.feature.aicore.api.AICoreService;
import com.sap.cds.feature.recommendation.api.RecommendationClient;
import com.sap.cds.feature.recommendation.api.RecommendationClientResolver;
import com.sap.cds.reflect.CdsStructuredType;
@@ -29,9 +28,10 @@ class FioriRecommendationHandler implements EventHandler {
private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class);
private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000;
+ private static final String SAP_RECOMMENDATIONS = "SAP_Recommendations";
- private final AICoreService aiCoreService;
- private final RecommendationClientResolver clientResolver;
+ private final RecommendationClientResolver> clientResolver;
+ private final PersistenceService db;
private final RecommendationResultParser resultParser = new RecommendationResultParser();
// Avoids re-evaluating the CDS model on every read to check whether an entity has prediction
// columns. Keys are ":" because if an entity needs a prediction can be
@@ -40,9 +40,9 @@ class FioriRecommendationHandler implements EventHandler {
Caffeine.newBuilder().maximumSize(10_000).build();
FioriRecommendationHandler(
- AICoreService aiCoreService, RecommendationClientResolver clientResolver) {
- this.aiCoreService = aiCoreService;
+ RecommendationClientResolver> clientResolver, PersistenceService db) {
this.clientResolver = clientResolver;
+ this.db = db;
}
void invalidateTenant(String tenantId) {
@@ -77,10 +77,13 @@ public void afterRead(CdsReadEventContext context, List dataList) {
return;
}
- if (!Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) {
+ if (row.containsKey(Drafts.IS_ACTIVE_ENTITY)
+ && !Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) {
return;
}
+ // rowType reflects the projected shape (columns actually selected); target is the full entity.
+ // Fall back to target when rowType is absent, e.g. when the result carries no type metadata.
CdsStructuredType rowType = context.getResult().rowType();
if (rowType == null) {
rowType = target;
@@ -91,9 +94,7 @@ public void afterRead(CdsReadEventContext context, List dataList) {
.getCdsRuntime()
.getEnvironment()
.getProperty(
- "cds.ai.recommendations.contextRowLimit",
- Integer.class,
- DEFAULT_CONTEXT_ROW_LIMIT);
+ "cds.ai.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT);
var builder = new RecommendationContextBuilder(target, rowType, limit);
@@ -102,18 +103,13 @@ public void afterRead(CdsReadEventContext context, List dataList) {
return;
}
- if (builder.contextColumns().isEmpty()) {
- logger.debug("No suitable context columns found, skipping predictions.");
+ if (builder.keyNames().isEmpty()) {
+ logger.debug("Entity has no key elements, skipping predictions.");
return;
}
- PersistenceService db =
- context
- .getServiceCatalog()
- .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME);
- List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list());
- if (contextRows.size() < 2) {
- logger.debug("Not enough context rows (minimum 2), skipping predictions.");
+ if (builder.contextColumns().isEmpty()) {
+ logger.trace("No suitable context columns found, skipping predictions.");
return;
}
@@ -123,11 +119,19 @@ public void afterRead(CdsReadEventContext context, List dataList) {
return;
}
- List allRows = builder.assembleRows(contextRows, predictRow, row);
+ // Result.list() returns List; the ArrayList copy also converts it to List.
+ List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list());
+ if (contextRows.size() < 2) {
+ logger.debug("Not enough context rows (minimum 2), skipping predictions.");
+ return;
+ }
+
+ List missingPredictionElementNames =
+ builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList();
- RecommendationClient client = clientResolver.resolve(aiCoreService);
+ RecommendationClient client = clientResolver.resolve(builder.keyNames());
List predictions =
- client.predict(allRows, builder.predictionElementNames(), builder.indexColumn());
+ client.predict(predictRow, contextRows, missingPredictionElementNames);
if (predictions.isEmpty()) {
logger.warn("No predictions returned from AI client.");
@@ -138,11 +142,9 @@ public void afterRead(CdsReadEventContext context, List dataList) {
return;
}
- List missingPredictionElementNames =
- builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList();
Map recommendations =
resultParser.buildRecommendations(
db, predictions.get(0), missingPredictionElementNames, context, rowType);
- row.put("SAP_Recommendations", recommendations);
+ row.put(SAP_RECOMMENDATIONS, recommendations);
}
}
diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java
index 27498bb..c2c9157 100644
--- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java
+++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java
@@ -5,45 +5,48 @@
import com.sap.cds.CdsData;
import com.sap.cds.feature.recommendation.api.RecommendationClient;
-import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
+// Mock implementation used when no AI Core binding is present. For each prediction column that
+// is null in the predict row, it picks a random non-null value from the same column across the
+// context rows and returns it as the prediction. Columns already filled are left unchanged.
class MockRecommendationClient implements RecommendationClient {
+ // We use random here so you can see a difference in the UI. The actual value returned here is not
+ // relevant for tests.
private final Random random = new Random();
+ private final List keyNames;
+
+ MockRecommendationClient(List keyNames) {
+ this.keyNames = keyNames;
+ }
@Override
public List predict(
- List rows, List predictionColumns, String indexColumn) {
- List predictions = new ArrayList<>();
- for (CdsData row : rows) {
- Map prediction = new HashMap<>();
- boolean addPrediction = false;
- for (String col : predictionColumns) {
- if ("[PREDICT]".equals(row.get(col))) {
- addPrediction = true;
- List availableValues =
- rows.stream()
- .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col)))
- .map(r -> r.get(col))
- .toList();
- Object contextValue =
- availableValues.isEmpty()
- ? null
- : availableValues.get(random.nextInt(availableValues.size()));
- Map predictionEntry = new HashMap<>();
- predictionEntry.put("prediction", contextValue);
- prediction.put(col, List.of(predictionEntry));
- }
- }
- if (addPrediction) {
- prediction.put(indexColumn, row.get(indexColumn));
- predictions.add(CdsData.create(prediction));
+ CdsData predictionRow, List contextRows, List predictionColumns) {
+ String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow);
+ Map prediction = new HashMap<>();
+ for (String col : predictionColumns) {
+ if (predictionRow.get(col) == null) {
+ List availableValues =
+ contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList();
+ Object contextValue =
+ availableValues.isEmpty()
+ ? null
+ : availableValues.get(random.nextInt(availableValues.size()));
+ Map predictionEntry = new HashMap<>();
+ // Replace the empty entry in col with a randomly picked value of entries in the
+ // contextRows.
+ predictionEntry.put("prediction", contextValue);
+ prediction.put(col, List.of(predictionEntry));
}
}
- return predictions;
+ if (!keyNames.isEmpty()) {
+ prediction.put(indexColumn, predictionRow.get(keyNames.get(0)));
+ }
+ return List.of(CdsData.create(prediction));
}
}
diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java
index 879f0fe..773526e 100644
--- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java
+++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java
@@ -3,17 +3,22 @@
*/
package com.sap.cds.feature.recommendation;
-import com.sap.cds.feature.aicore.api.AICoreService;
-import com.sap.cds.feature.aicore.core.AbstractAICoreService;
-import com.sap.cds.feature.aicore.core.MockAICoreServiceImpl;
+import com.sap.cds.feature.aicore.api.DeploymentIdContext;
+import com.sap.cds.feature.aicore.api.InferenceClientContext;
+import com.sap.cds.feature.aicore.api.ResourceGroupContext;
+import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_;
import com.sap.cds.feature.recommendation.api.RecommendationClient;
import com.sap.cds.feature.recommendation.api.RecommendationClientResolver;
import com.sap.cds.feature.recommendation.api.RptInferenceClient;
import com.sap.cds.feature.recommendation.api.RptModelSpec;
import com.sap.cds.services.ServiceCatalog;
+import com.sap.cds.services.cds.RemoteService;
+import com.sap.cds.services.persistence.PersistenceService;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.runtime.CdsRuntimeConfiguration;
import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
+import com.sap.cds.services.utils.environment.ServiceBindingUtils;
+import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,29 +31,73 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
CdsRuntime runtime = configurer.getCdsRuntime();
ServiceCatalog serviceCatalog = runtime.getServiceCatalog();
- AICoreService aiCoreService =
- serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME);
+ RemoteService aiCoreService = serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME);
if (aiCoreService == null) {
logger.info("No AICoreService found, skipping Fiori recommendation handler registration.");
return;
}
- RecommendationClientResolver resolver =
- aiCoreService instanceof MockAICoreServiceImpl
- ? service -> new MockRecommendationClient()
- : RecommendationConfiguration::resolveRptClient;
+ PersistenceService db =
+ serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME);
- FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver);
+ if (db == null) {
+ logger.info(
+ "No PersistenceService found, skipping Fiori recommendation handler registration.");
+ return;
+ }
+
+ boolean hasBind = hasAICoreBinding(runtime);
+ // The real resolver is a lambda resolved at prediction time. That's necessary because
+ // resource group and deployment ID are tenant-specific and are only available at
+ // prediction time from the request context. The RemoteService is captured in the closure.
+ RecommendationClientResolver> clientResolver =
+ hasBind
+ ? keyNames -> resolveRptClient(aiCoreService, keyNames)
+ : keyNames -> new MockRecommendationClient(keyNames);
+
+ FioriRecommendationHandler handler = new FioriRecommendationHandler(clientResolver, db);
configurer.eventHandler(handler);
configurer.eventHandler(new RecommendationModelChangedHandler(handler));
}
- private static RecommendationClient resolveRptClient(AICoreService service) {
- String resourceGroup = service.resourceGroup();
- String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1());
- return new RptInferenceClient(
- service.inferenceClient(resourceGroup, deploymentId),
- ((AbstractAICoreService) service).getRetry());
+ private static boolean hasAICoreBinding(CdsRuntime runtime) {
+ return runtime
+ .getEnvironment()
+ .getServiceBindings()
+ .filter(b -> ServiceBindingUtils.matches(b, "aicore"))
+ .findFirst()
+ .isPresent();
+ }
+
+ private static RecommendationClient resolveRptClient(
+ RemoteService service, List keyNames) {
+ ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ service.emit(rgCtx);
+ String resourceGroup = rgCtx.getResult();
+ if (resourceGroup == null) {
+ throw new IllegalStateException("Failed to resolve resource group from AI Core service");
+ }
+
+ DeploymentIdContext depCtx = DeploymentIdContext.create();
+ depCtx.setResourceGroupId(resourceGroup);
+ depCtx.setSpec(RptModelSpec.rpt1());
+ service.emit(depCtx);
+ String deploymentId = depCtx.getResult();
+ if (deploymentId == null) {
+ throw new IllegalStateException(
+ "Failed to resolve deployment ID for resource group: " + resourceGroup);
+ }
+
+ InferenceClientContext infCtx = InferenceClientContext.create();
+ infCtx.setResourceGroupId(resourceGroup);
+ infCtx.setDeploymentId(deploymentId);
+ service.emit(infCtx);
+ if (infCtx.getResult() == null) {
+ throw new IllegalStateException(
+ "Failed to create inference client for deployment: " + deploymentId);
+ }
+
+ return new RptInferenceClient(infCtx.getResult(), keyNames);
}
}
diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java
index a158123..6843533 100644
--- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java
+++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java
@@ -14,23 +14,27 @@
import com.sap.cds.reflect.CdsSimpleType;
import com.sap.cds.reflect.CdsStructuredType;
import com.sap.cds.services.draft.Drafts;
-import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Builds the context data needed for prediction: determines which elements to predict, which
- * columns provide context, builds the context query, and prepares rows for the AI model.
+ * columns provide context and builds the context query. This class is cds-model aware, but does not
+ * know about which client will be used for the predictions.
*/
class RecommendationContextBuilder {
private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList";
private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION =
"@Common.ValueListWithFixedValues";
- private static final String SYNTHETIC_KEY_COLUMN = "SAP_RECOMMENDATIONS_ID";
+ private static final String ODATA_VALUE_LIST_ANNOTATION = "@cds.odata.valuelist";
+ private static final String RECOMMENDATION_STATE_ANNOTATION = "@UI.RecommendationState";
+ private static final String COMPUTED_ANNOTATION = "@Core.Computed";
+ private static final String READONLY_ANNOTATION = "@readonly";
private static final Set SUPPORTED_CONTEXT_TYPES =
EnumSet.of(
CdsBaseType.STRING,
@@ -64,8 +68,6 @@ class RecommendationContextBuilder {
private final List predictionElementNames;
private final List contextColumns;
private final List keyNames;
- private final boolean syntheticKeyNeeded;
- private final String indexColumn;
RecommendationContextBuilder(CdsStructuredType target, CdsStructuredType rowType, int limit) {
this.target = target;
@@ -74,10 +76,6 @@ class RecommendationContextBuilder {
this.predictionElementNames = computePredictionElements();
this.contextColumns = computeContextColumns();
this.keyNames = target.keyElements().map(CdsElement::getName).toList();
- this.syntheticKeyNeeded =
- keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0)));
- this.indexColumn =
- syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID");
}
List