Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a110860
Make cache for entitiesWithoutPredictionsPerTenant tenant specific
lisajulia Jun 9, 2026
a8140a8
Merge branch 'main' into final-review-tenant-scoping
Schmarvinius Jun 10, 2026
2231d61
refactor(ai-core): make AICoreService tenant-agnostic and DI-friendly
Schmarvinius Jun 10, 2026
de4f637
feat(ai-core): restrict AICore entity APIs to current tenant
Schmarvinius Jun 10, 2026
c30080b
chore(ai-core): rename config namespace to cds.ai.core
Schmarvinius Jun 10, 2026
1eb8c33
fix(ai-core): handle null tenant in resourceGroupForTenant
Schmarvinius Jun 10, 2026
e730fa8
fix(ci): cleanup all run attempts and cds-itest resource groups
Schmarvinius Jun 10, 2026
6ebc3fd
fix(itest): align config namespace with cds.ai.core rename
Schmarvinius Jun 10, 2026
8c0197e
test(ai-core): add unit tests for tenant scoping and mock service
Schmarvinius Jun 10, 2026
7512273
chore(recommendations): add TODO for model-changed integration test
Schmarvinius Jun 10, 2026
b105dde
update cleanup
Schmarvinius Jun 11, 2026
d539f21
fix(ci): scope resource group cleanup to own job only
Schmarvinius Jun 11, 2026
cdb3967
test(ai-core): add unit tests for uncovered code paths
Schmarvinius Jun 11, 2026
6657df8
fix(ci): include cds-itest- prefix in resource group cleanup
Schmarvinius Jun 11, 2026
6e28a9d
refactor(ai-core): migrate AICoreService from CqnService to RemoteSer…
Schmarvinius Jun 11, 2026
42bcdf5
refactor(ai-core): delete AICoreApplicationServiceHandler
Schmarvinius Jun 11, 2026
fa28cda
fix(ai-core): guard service registration on AICore model presence
Schmarvinius Jun 11, 2026
ae85288
fix(test): mock CdsModel in unit tests and restore AICore model import
Schmarvinius Jun 11, 2026
62ba1d7
fix(ai-core): extend AbstractCdsDefinedService for proper RemoteServi…
Schmarvinius Jun 11, 2026
96452bd
fix(recommendations): promote cds-services-impl to compile scope
Schmarvinius Jun 11, 2026
2f60ca8
refactor(test): use real CdsRuntime in AICoreServiceConfigurationTest
Schmarvinius Jun 12, 2026
57d0d72
refactor(ai-core): remove AICORE_SERVICE_KEY env var check from bindi…
Schmarvinius Jun 12, 2026
cdd70d5
chore: exclude Mock* classes from SonarQube coverage
Schmarvinius Jun 12, 2026
c50ced3
refactor(test): use real CdsRuntime in AICoreServiceImplDeploymentIdTest
Schmarvinius Jun 12, 2026
acb2137
Merge remote-tracking branch 'origin/main' into refactor/remote-servi…
Schmarvinius Jun 12, 2026
70724c5
fix: remove duplicate detectMultiTenancy method from merge
Schmarvinius Jun 12, 2026
7adcce0
refactor(ai-core): rename DEFAULT_NAME to AICoreService$Default
Schmarvinius Jun 12, 2026
168aa71
refactor(ai-core): define EventContext subinterfaces for programmatic…
Schmarvinius Jun 12, 2026
f85100a
refactor(ai-core): make service API methods emit events; move logic t…
Schmarvinius Jun 12, 2026
d8a7466
refactor(ai-core): decouple CRUD handlers from service impl; use type…
Schmarvinius Jun 12, 2026
265a667
refactor(ai-core): remove redundant event params from handler annotat…
Schmarvinius Jun 12, 2026
b525ca5
test(ai-core): rewrite handler tests to use real CdsRuntime
Schmarvinius Jun 12, 2026
148e344
refactor(ai-core): extract AICoreConfig and AICoreClients
Schmarvinius Jun 15, 2026
446b11f
refactor(ai-core): extract DeploymentResolver
Schmarvinius Jun 15, 2026
b3495dd
refactor(ai-core): inject components into handlers
Schmarvinius Jun 15, 2026
802ec49
refactor(ai-core): slim AICoreServiceImpl to pure delegation
Schmarvinius Jun 15, 2026
8516d85
refactor(ai-core): rewire configuration and setup handlers
Schmarvinius Jun 15, 2026
6514e14
refactor(recommendations): RptInferenceClient owns its retry
Schmarvinius Jun 15, 2026
ee1bda7
test(ai-core): update tests for new component architecture
Schmarvinius Jun 15, 2026
7d8d2f7
for pipeline
Schmarvinius Jun 15, 2026
7966da8
fix(ai-core): separate retry boundary to prevent orphaned deployments
Schmarvinius Jun 15, 2026
a136659
refactor(ai-core): remove all service references from handlers
Schmarvinius Jun 15, 2026
9a71a2a
fix(ai-core): add handler ordering and wire mock cleanup
Schmarvinius Jun 15, 2026
e047910
fix(ai-core): validate config at startup, document impl coupling
Schmarvinius Jun 15, 2026
56b093e
test(ai-core): update tests for DeploymentResolver expansion
Schmarvinius Jun 15, 2026
f11d776
Merge remote-tracking branch 'origin/review-fixes' into refactor/type…
Schmarvinius Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -50,6 +50,17 @@ public interface AICoreService extends RemoteService {
*/
String resourceGroup();

/**
* Returns the AI Core resource group ID associated with the given tenant.
*
* <p>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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,14 +30,17 @@
*
* <p>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();
}
Expand All @@ -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
Expand All @@ -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");
}
}
}
Loading
Loading