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
index 2955b33..8a2e24c 100644
--- 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
@@ -28,7 +28,7 @@
public interface AICoreService extends RemoteService {
/** Default service name under which an instance is registered in the service catalog. */
- String DEFAULT_NAME = "AICore$Default";
+ String DEFAULT_NAME = "AICoreService$Default";
/** Qualified name of the {@code resourceGroups} entity exposed by this service. */
String RESOURCE_GROUPS = "AICore.resourceGroups";
@@ -50,6 +50,17 @@ public interface AICoreService extends RemoteService {
*/
String resourceGroup();
+ /**
+ * Returns the AI Core resource group ID associated with the given tenant.
+ *
+ *
This variant is used during subscribe/unsubscribe flows where the tenant ID is explicitly
+ * available from the context rather than the current request.
+ *
+ * @param tenantId the CDS tenant identifier
+ * @return the AI Core resource group ID for the specified tenant
+ */
+ String resourceGroupForTenant(String tenantId);
+
/**
* Returns the deployment ID for the given model spec inside the given resource group.
*
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..4fa04d9
--- /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 by {@link AICoreService#deploymentId(String, ModelDeploymentSpec)} 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..1b3095e
--- /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 by {@link AICoreService#inferenceClient(String, String)} 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/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java
new file mode 100644
index 0000000..19a2a82
--- /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 by {@link AICoreService#resourceGroup()} 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 43d9127..2b94030 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
@@ -8,9 +8,11 @@
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.AICoreApiHandler;
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.MockEntityHandler;
import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler;
import com.sap.cds.services.environment.CdsProperties;
@@ -28,14 +30,17 @@
*
*
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.
+ * the appropriate handlers. Picked up automatically through {@code ServiceLoader}; applications do
+ * not need to instantiate this class directly.
*/
public class AICoreServiceConfiguration implements CdsRuntimeConfiguration {
private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class);
+ private AICoreConfig config;
+ private AICoreClients clients;
+ private DeploymentResolver resolver;
+
private static boolean hasAICoreModel(CdsRuntime runtime) {
return runtime.getCdsModel().findService("AICore").isPresent();
}
@@ -60,7 +65,10 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) {
if (sidecarUrl != null && !sidecarUrl.isBlank()) {
return true;
}
- return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null;
+ return runtime
+ .getServiceCatalog()
+ .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME)
+ != null;
}
@Override
@@ -73,54 +81,58 @@ public void services(CdsRuntimeConfigurer configurer) {
}
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);
+ 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("Registered AICoreService backed by AI Core binding.");
} else {
- MockAICoreServiceImpl mockService =
- new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled);
- configurer.service(mockService);
- logger.info("Registered MockAICoreService (no AI Core binding found).");
+ logger.info(
+ "Registered AICoreService (no AI Core binding found — mock handlers will be used).");
}
+
+ configurer.service(new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime));
}
@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 registration
+ }
- 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));
- logger.debug("Registered Prod AI-Core Implementation");
+ if (clients != null) {
+ // Production path: real AI Core binding
+ 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 {
+ // Mock path: no AI Core binding
+ MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config);
configurer.eventHandler(new MockEntityHandler());
- 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");
}
}
}
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
index 52eeb4f..b1c226c 100644
--- 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
@@ -3,424 +3,68 @@
*/
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.DeploymentIdContext;
+import com.sap.cds.feature.aicore.api.InferenceClientContext;
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.feature.aicore.api.ResourceGroupContext;
+import com.sap.cds.services.impl.cds.AbstractCdsDefinedService;
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.
+ * Production implementation of {@link AICoreService}.
*
- *
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.
+ *
This class is a pure delegation layer: each API method creates a typed {@link
+ * com.sap.cds.services.EventContext EventContext}, emits it via the CAP event mechanism, and
+ * returns the handler's result. All business logic (caching, locking, API calls) lives in the
+ * registered ON handlers which receive their dependencies via constructor injection.
*
- *
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.
+ *
Implementation note: This class extends {@code AbstractCdsDefinedService}
+ * from the CAP Java runtime's internal {@code impl} package. This is necessary because the public
+ * API ({@code ServiceDelegator}) does not provide CQN execution capabilities or CDS model binding.
+ * The semi-public {@code AbstractCqnService} (from {@code cds-services-utils}) provides CQN but not
+ * {@code getDefinition()}. Until a public API alternative exists, this coupling is accepted and
+ * version-compatibility is verified through integration tests against the CAP Java runtime.
*/
-public class AICoreServiceImpl extends AbstractAICoreService {
+public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService {
- private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class);
+ private static final String CDS_DEFINITION_NAME = "AICore";
- 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;
+ public AICoreServiceImpl(String name, CdsRuntime runtime) {
+ super(name, CDS_DEFINITION_NAME, runtime);
}
- private static Cache newCache() {
- return Caffeine.newBuilder()
- .maximumSize(DEFAULT_CACHE_MAX_SIZE)
- .expireAfterAccess(DEFAULT_CACHE_EXPIRY)
- .build();
+ @Override
+ public String resourceGroup() {
+ ResourceGroupContext ctx = ResourceGroupContext.create();
+ emit(ctx);
+ return ctx.getResult();
}
@Override
public String resourceGroupForTenant(String tenantId) {
- if (!multiTenancyEnabled || tenantId == null) {
- logger.debug("Using default resource group {}", defaultResourceGroup);
- return defaultResourceGroup;
- }
- return getOrCreateResourceGroupForTenant(tenantId);
+ ResourceGroupContext ctx = ResourceGroupContext.create();
+ ctx.setTenantId(tenantId);
+ emit(ctx);
+ return ctx.getResult();
}
@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);
- }
+ DeploymentIdContext ctx = DeploymentIdContext.create();
+ ctx.setResourceGroupId(resourceGroupId);
+ ctx.setSpec(spec);
+ emit(ctx);
+ return ctx.getResult();
}
@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);
+ InferenceClientContext ctx = InferenceClientContext.create();
+ ctx.setResourceGroupId(resourceGroupId);
+ ctx.setDeploymentId(deploymentId);
+ emit(ctx);
+ return ctx.getResult();
}
}
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/AICoreSetupHandler.java
index e4fd983..57a56ac 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/AICoreSetupHandler.java
@@ -3,7 +3,6 @@
*/
package com.sap.cds.feature.aicore.core;
-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.services.ErrorStatuses;
@@ -11,6 +10,7 @@
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 +25,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)
+ @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,
@@ -48,6 +51,7 @@ public void afterSubscribe(SubscribeEventContext context) {
}
@Before(event = DeploymentService.EVENT_UNSUBSCRIBE)
+ @HandlerOrder(HandlerOrder.EARLY)
public void beforeUnsubscribe(UnsubscribeEventContext context) {
String tenantId = context.getTenant();
logger.debug("Deleting AI Core resources for tenant {}", tenantId);
@@ -55,7 +59,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 +72,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 +96,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/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java
deleted file mode 100644
index 2e4fe7a..0000000
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java
+++ /dev/null
@@ -1,98 +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.impl.cds.AbstractCdsDefinedService;
-import com.sap.cds.services.request.RequestContext;
-import com.sap.cds.services.request.UserInfo;
-import com.sap.cds.services.runtime.CdsRuntime;
-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 AbstractCdsDefinedService
- implements AICoreService {
-
- /** The qualified CDS service definition name. */
- private static final String CDS_DEFINITION_NAME = "AICore";
-
- protected AbstractAICoreService(String name, CdsRuntime runtime) {
- super(name, CDS_DEFINITION_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/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java
index fe152fd..77a0983 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/MockAICoreSetupHandler.java
@@ -3,9 +3,11 @@
*/
package com.sap.cds.feature.aicore.core;
+import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler;
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 +20,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)
+ @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.getTenantResourceGroupCache().computeIfAbsent(tenantId, id -> "cds-" + id);
+ logger.info("Mock created in-memory resource group for tenant {}", tenantId);
}
@Before(event = DeploymentService.EVENT_UNSUBSCRIBE)
+ @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/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..3ecf8e7
--- /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.AICoreService;
+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.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 {@link AICoreService} 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(AICoreService.DEFAULT_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/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..2c9d1e7 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,13 +3,16 @@
*/
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.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;
@@ -21,29 +24,22 @@ 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..d7a60df 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.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;
@@ -36,14 +37,12 @@ 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