Skip to content

Review: Full prototype implementation#49

Draft
Schmarvinius wants to merge 42 commits into
review-basefrom
main
Draft

Review: Full prototype implementation#49
Schmarvinius wants to merge 42 commits into
review-basefrom
main

Conversation

@Schmarvinius

Copy link
Copy Markdown
Contributor

This PR contains the full prototype implementation for review purposes.

Note: This PR is for code review only — do not merge it. Close it once the review is complete.

lisajulia and others added 30 commits May 6, 2026 16:06
…tup.java

Co-authored-by: Matthias Braun <matthia.braun@sap.com>
Co-authored-by: Simon Engel <simon.engel01@sap.com>
Co-authored-by: Simon Engel <simon.engel01@sap.com>
Replaces the single-module srv/ codebase with the multi-module Maven
layout developed on the internal SAP fork. Adds LICENSE at root (per OSPO
finding), pom.xml SCM section, and fixes the broken .reuse/dep5 link in
CONTRIBUTING.md.

The .github/ workflows and gh_ruleset.json from this repo are preserved.

Note: integration-tests/spring tests fail on clean clone because the
build does not invoke npm install before cds build, so the @cap-js/ai
node plugin is not available when the csn is generated. Pre-existing
issue on the fork's main; tracked separately.
Co-authored-by: hyperspace-insights[bot] <209611008+hyperspace-insights[bot]@users.noreply.github.com>
* chore: bootstrap ci/cd workflows

* chore: address pr bot review findings

* fix: specify ai-core service instance and key

* rename blackduck identifier
* fix: run npm ci before cds build in integration-tests (#17)

Without `npm ci`, `cds build` ran in `integration-tests/spring/` without the
`@cap-js/ai` Node plugin, so the generated CSN was missing the synthetic
`SAP_Recommendations` association and 8 RecommendationTest cases failed on
clean clone with `JsonPath $['SAP_Recommendations']: PathNotFoundException`.

Add `cds.install-node` and `cds.npm-ci` executions to the cds-maven-plugin
block in `integration-tests/spring/pom.xml`, mirroring the pattern already
used in `samples/bookshop/srv/pom.xml`. CI continues to skip the Node
download via `-Dcds.install-node.skip`; clean local clones get a
self-contained Node + `npm ci` into `integration-tests/node_modules/`.

Commit `package-lock.json` for `integration-tests/` and `samples/bookshop/`
so `npm ci` is reproducible, and remove the `**/package-lock.json` entry
from `.gitignore`.

Closes #17

* fix: regenerate lockfiles against public npm registry

The @cap-js/ai entry in the previous lockfiles pointed at
int.repositories.cloud.sap (SAP-internal Artifactory), which is unreachable
from GitHub-hosted runners and broke `npm ci` in CI with EAI_AGAIN.

Regenerated both lockfiles with the public registry forced for the @cap-js
scope so they resolve from registry.npmjs.org.

* fix: invert integration-test modules profile and suppress AICore kinds models

Two fixes wrapped together since they both unblock CI on PR #21.

1. Maven `<modules>` is additive in profiles, so `skip-integration-tests`
   was a no-op — it re-listed the source modules instead of removing the
   integration ones. Invert the structure: default modules are source-
   only; a `with-integration-tests` profile (active by default) adds
   `integration-tests` and `coverage-report`. CI consumers switch from
   `-P skip-integration-tests` to `-P '!with-integration-tests'`.

2. In CI, `cds bind --exec` writes a `[hybrid]` profile entry to
   `.cdsrc-private.json` for the `ai-core` binding, kind `AICore-btp`.
   `cds build` then expands the kind's `model: "@cap-js/ai/srv/AICoreService"`
   into the build's model list, which collides with the `AICore` service
   shipped by `cds-feature-ai-core` (loaded via `using { AICore } from
   'com.sap.cds/ai'`). The existing `requires.AICore.model = false`
   override only suppresses the named-require model, not the kind's.
   Add `requires.kinds.AICore-btp.model = false` (and the same for
   `AICore-mocked`) so the kind never contributes a model to cds build.

* fix: build root modules before sonar scan; dispatch event handlers on registered service type

- SonarQube scan job ran `mvn clean verify` from `integration-tests/`
  without first installing the source modules, so the integration-tests
  build couldn't resolve `cds-feature-ai-core` etc. as JAR dependencies.
  Mirror the integration-tests action: install the three source modules
  with `-pl ... -am -DskipTests` first, then run the scan build.

- `AICoreServiceConfiguration.eventHandlers` recomputed `hasAICoreBinding`
  separately from `services()` and unconditionally cast the registered
  service to `AICoreServiceImpl`. If the two calls disagreed (or if
  another configurer registered a Mock first), the cast threw
  `ClassCastException: MockAICoreServiceImpl cannot be cast to
  AICoreServiceImpl`. Read the actually-registered service from the
  catalog and dispatch via `instanceof` patterns so the handler set
  always matches the live registration.
The Fiori recommendation handler used to emit a SAP_Recommendations entry
for every column in the prediction response, regardless of whether the
user had already filled that column. This worked locally because the
mock client only returns predictions for cells marked [PREDICT], but it
broke in CI where the real RPT model returns predictions for every
target column. Filter the response to columns that were null in the
input row so behaviour is consistent across both clients.

Adds a regression unit test that simulates the RPT-style response shape.
#25)

* test(itest): disable ResourceGroup mutating tests pending admin scopes (#24)

The 4 CREATE/DELETE tests in ResourceGroupTest fail in CI with HTTP 403
because /v2/admin/resourceGroups requires AI Core tenant-admin scopes
(see SAP AI Core service guide §3.2 / §6.3), which the bound CI service
key (sap-internal plan, technical user cap.calesi.munich@sap.com) does
not carry. Disable them with @disabled until the IAM situation is sorted;
read-only readAll_returnsResourceGroups stays enabled.

Refs #24

* fix(itest): repair resource-group cleanup so quota does not leak (#24)

The test extension's pre-cleanup never ran in CI because it gated on
AICORE_SERVICE_KEY, but the bound credentials are exposed via VCAP_SERVICES.
48 itest-rg-* groups had leaked into the cdsmunich tenant, hitting the
50-group hard quota and turning every CREATE into a 400/quota error.

- Remove the env-var guard from ResourceGroupCleanupExtension so the
  ResourceGroupApi auto-discovers credentials from VCAP_SERVICES (same
  pattern as production AICoreServiceImpl).
- Add cleanup-resource-groups.cjs and invoke it with if: always() in the
  integration-tests action, mirroring cap-js/ai. Survives JVM crashes.
- Re-enable the four ResourceGroupTest CREATE/DELETE tests.

* chore(itest): drop unused .cjs cleanup script

The JUnit ResourceGroupCleanupExtension already handles cleanup
exhaustively in @BeforeAll/@afterall — verified green in the previous
CI run. The .cjs script failed with 'No impl configured for
cds.requires.kinds.AICore-btp' anyway and was wrapped in `|| true`,
so it provided no real safety net.
Bumps the minor-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [com.github.ben-manes.caffeine:caffeine](https://github.com/ben-manes/caffeine) | `3.2.0` | `3.2.4` |
| [org.apache.maven.plugins:maven-enforcer-plugin](https://github.com/apache/maven-enforcer) | `3.6.2` | `3.6.3` |
| [com.diffplug.spotless:spotless-maven-plugin](https://github.com/diffplug/spotless) | `3.4.0` | `3.5.0` |
| [com.sap.ai.sdk:core](https://github.com/SAP/ai-sdk-java) | `1.18.0` | `1.19.0` |
| [com.sap.ai.sdk.foundationmodels:sap-rpt](https://github.com/SAP/ai-sdk-java) | `1.18.0` | `1.19.0` |



Updates `com.github.ben-manes.caffeine:caffeine` from 3.2.0 to 3.2.4
- [Release notes](https://github.com/ben-manes/caffeine/releases)
- [Commits](ben-manes/caffeine@v3.2.0...v3.2.4)

Updates `org.apache.maven.plugins:maven-enforcer-plugin` from 3.6.2 to 3.6.3
- [Release notes](https://github.com/apache/maven-enforcer/releases)
- [Commits](apache/maven-enforcer@enforcer-3.6.2...enforcer-3.6.3)

Updates `com.diffplug.spotless:spotless-maven-plugin` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/diffplug/spotless/releases)
- [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md)
- [Commits](diffplug/spotless@maven/3.4.0...maven/3.5.0)

Updates `com.sap.ai.sdk:core` from 1.18.0 to 1.19.0
- [Release notes](https://github.com/SAP/ai-sdk-java/releases)
- [Changelog](https://github.com/SAP/ai-sdk-java/blob/main/docs/release_notes.md)
- [Commits](SAP/ai-sdk-java@rel/1.18.0...rel/1.19.0)

Updates `com.sap.ai.sdk.foundationmodels:sap-rpt` from 1.18.0 to 1.19.0
- [Release notes](https://github.com/SAP/ai-sdk-java/releases)
- [Changelog](https://github.com/SAP/ai-sdk-java/blob/main/docs/release_notes.md)
- [Commits](SAP/ai-sdk-java@rel/1.18.0...rel/1.19.0)

Updates `com.sap.ai.sdk.foundationmodels:sap-rpt` from 1.18.0 to 1.19.0
- [Release notes](https://github.com/SAP/ai-sdk-java/releases)
- [Changelog](https://github.com/SAP/ai-sdk-java/blob/main/docs/release_notes.md)
- [Commits](SAP/ai-sdk-java@rel/1.18.0...rel/1.19.0)

---
updated-dependencies:
- dependency-name: com.github.ben-manes.caffeine:caffeine
  dependency-version: 3.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: org.apache.maven.plugins:maven-enforcer-plugin
  dependency-version: 3.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: com.diffplug.spotless:spotless-maven-plugin
  dependency-version: 3.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: com.sap.ai.sdk:core
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: com.sap.ai.sdk.foundationmodels:sap-rpt
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: com.sap.ai.sdk.foundationmodels:sap-rpt
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…#27)

* fix(mtx): wire MTX subscribe lifecycle for mock AI Core service (#26)

The local-with-tenants profile didn't set cds.requires.AICore.multiTenancy,
so AICoreServiceImpl.isMultiTenancyEnabled() returned false and seven MTX
integration tests failed. Even with the flag, the mock service had no
SubscribeEventContext handler, so the tenant resource-group cache was never
populated and the cache-based assertions still failed.

- Enable cds.requires.AICore.multiTenancy under the local-with-tenants
  profile so MT registers in mtx-local integration tests.
- Add MockAICoreSetupHandler mirroring AICoreSetupHandler's subscribe/
  unsubscribe lifecycle in-memory (no AI Core API calls).
- Register the mock setup handler from AICoreServiceConfiguration when
  the mock service runs with MT enabled.
- Use AICoreService interface in mtx-local integration tests; drop the
  AICoreServiceImpl casts and the assumeTrue skips that those casts forced.

* ci: split mtx-local into its own job without AI Core binding (#26)

Running spring + mtx-local under one cds-bind made mtx-local inherit the
AI Core binding, so AICoreServiceConfiguration registered the real
AICoreServiceImpl and AICoreSetupHandler hit the live API on every
subscribe/unsubscribe (HTTP 409 on resource-group delete in CI). The
mtx-local module is intended to run in-memory against the mock service.

- Add a Local MTX Tests job (matrix Java 17/21, no cf-bind) that runs
  mvn -pl integration-tests/mtx-local/srv -am -P mtx-integration-tests.
- Drop the mtx-integration-tests profile from the integration-tests
  composite action so its cds-bind invocation runs only the spring
  reactor (the default).

* change default rg and add provisioning to itest

* fix(itest): avoid 404 on keyed RG read by listing then filtering

A keyed READ on AICore.resourceGroups routes through
ResourceGroupHandler.onRead -> resourceGroupApi.get(rgId), which
returns 404 for a not-yet-existing RG instead of an empty list.
Use a non-keyed Select and filter client-side so the helper can
detect absence and INSERT the RG.

* fix(itest): increase RPT deployment poll budget to ~6 min

Prior 10-attempt cap (~145 s sleep budget) was right at the edge of the
observed RPT-1 deployment time (~150 s) and timed out in CI. Bumping to
18 attempts gives a ~6 min ceiling so cold deployments have headroom
without making genuinely failed runs hang indefinitely.

* fix(itest): tune RPT poll budget to 15 attempts (~4.7 min)

18 attempts gave too much headroom; 15 is enough margin over the
observed ~150 s RPT-1 deployment time without making genuinely stuck
runs hang for too long.

* fix(itest): scope cleanup to label-owned RGs to protect other CF instances

AI Core resource groups are shared across all CF service instances bound
to the same AI Core tenant, so listing every RG and stopping its
deployments before delete was destroying work owned by other instances.

Adopt cap-js/ai's pattern (srv/ai-core/resourceGroups.js) of stamping
each RG with an owner label at create time and filtering by it on
cleanup, but use a distinct key (CDS_FEATURE_AI_ITEST_OWNER) so the
itest cleanup never collides with prod tenant cleanup. Cleanup also
short-circuits when the owner is unset or equal to the local-dev RG
name, so dev runs reuse their RG across invocations.

While here, drop the test scaffold's hand-rolled config/deployment
helpers and call the existing AICoreServiceImpl.deploymentId(...) which
already does lookup-or-create-with-polling. The RG insert + provisioned
poll stay in test scope per design.

* fix(itest): drop redundant null-check on rg.getLabels()

BckndResourceGroup.getLabels() is @nonnull, so the null guard was
flagged by SpotBugs (RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE).

* fix(ci): resolve integration test and SonarQube pipeline failures

- ResourceGroupCleanupExtension: defer afterAll cleanup to end of test
  suite using CloseableResource pattern, preventing premature deletion
  of the resource group before all test classes have completed
- TenantIsolationTest: catch Throwable (not just Exception) in tearDown
  so MockMvc AssertionError does not escape the safety catch blocks
- SonarQube scan: remove -P mtx-integration-tests from the verify step;
  mtx-local tests already run in their dedicated local-mtx-tests job
  and including them under cds bind causes real AI Core API calls that
  conflict with the mock-based test design
- BaseIntegrationTest: expose clearDeploymentIdCache() so the cleanup
  extension can invalidate stale cached deployment IDs after deletion

* fix(itest): prevent beforeAll cleanup from deleting active resource group

The ResourceGroupCleanupExtension.beforeAll() was deleting the resource
group that AICoreServiceTest (which runs first) had just created, causing
ActionTest and all Recommendation tests to fail with 400/500 from AI Core.

- Remove beforeAll/cleanupOnce from ResourceGroupCleanupExtension; the
  deferred afterAll (CloseableResource at suite end) is sufficient
- Expose ensureResourceGroupProvisioned() as protected in BaseIntegrationTest
- Add @BeforeAll to ActionTest to guarantee the resource group is
  provisioned before deploymentId tests run, regardless of class order

* fix(ci): generate JaCoCo XML reports for SonarQube scan

- Add jacoco report goal to cds-feature-ai-core and integration-tests/spring
  pom.xml (they only had prepare-agent, no report generation)
- Move mtx-local dependency in coverage-report to mtx-integration-tests
  profile so the aggregate report can be built without mtx-local
- Update scan-with-sonar action: remove -DskipTests so unit tests run and
  generate coverage; add install to integration tests step; add step to
  build coverage-report aggregate; point sonar at the aggregate report

* fix(itest): prevent ResourceGroupTest from leaking resource groups

ResourceGroupTest creates itest-rg-* groups during tests but AfterEach
cleanup silently failed (409 Conflict) because the RG wasn't provisioned
yet. Additionally, the ResourceGroupCleanupExtension only cleaned
label-owned groups, missing the itest-rg-* prefix groups entirely.

- ResourceGroupTest: wait for RG to be provisioned before delete in
  AfterEach, matching the pattern used in delete_resourceGroup test
- ResourceGroupCleanupExtension: add prefix-based safety-net cleanup
  that always deletes any itest-rg-* groups at suite end, regardless
  of whether CDS_AICORE_TEST_RESOURCE_GROUP is set
- scan-with-sonar: set CDS_AICORE_TEST_RESOURCE_GROUP env var so the
  cleanup extension actually runs in the SonarQube scan job

* fix(itest): stop_deployment test must use own RG, not 'default'

The test was hardcoded to query and stop the first RUNNING deployment
in the 'default' resource group. Since AI Core is scoped at the
subaccount level, this stopped deployments from other CF spaces sharing
the same subaccount (e.g. orchestration deployments).

Use getDefaultResourceGroup() which resolves to the CI-specific
itest-* resource group, ensuring only test-owned deployments are
affected.

* fix(itest): remove all hardcoded 'default' resource group references

DeploymentTest.update_targetStatus_stopsRunningDeployment was still
hardcoded to query and stop deployments in the 'default' resource group,
which stopped the user's orchestration deployment in the shared
subaccount. Also fix ConfigurationTest to avoid creating orphan configs
in 'default'.

All test classes now use getDefaultResourceGroup() which resolves to the
CI-specific itest-* resource group via CDS_AICORE_TEST_RESOURCE_GROUP.

* fix: retry on HTTP 404 from AI Core inference endpoint

AI Core deployments report RUNNING status before the inference gateway
has fully propagated the route. This causes 404 responses for a short
window after deployment creation. The retry logic already handled 403
and 412 as transient 'not ready yet' states but was missing 404.

Adding 404 to the retryable status codes makes inference calls resilient
to this AI Core eventual-consistency window.

* fix: cap exponential backoff at 30s and increase itest retry budget

The exponential backoff with no cap resulted in extremely long final
intervals (77s, 154s) leaving only ~153s of total retry window across
10 attempts. For fresh deployments where inference needs up to 5 min
to become stable, this was insufficient.

- Cap max interval at 30s so retries stay frequent
- Increase integration test maxRetries to 15 (gives ~5.5 min window)
- Together with the 404 retry fix, this handles AI Core's deployment
  warm-up period reliably

* fix(ci): move RG cleanup to dedicated job after all itests complete

When Java 17 and Java 21 integration tests run in parallel, they create
separate deployments of the same RPT model. When the first-finishing job
cleaned up (stopped its deployment + deleted its RG), AI Core tore down
shared model serving infrastructure causing the still-running job's
inference calls to fail with 404.

- ResourceGroupCleanupExtension: afterAll now only cleans itest-rg-*
  leaked groups (safety net), no longer stops/deletes the main test RG
- pipeline.yml: add dedicated 'integration-tests-cleanup' job that runs
  with if:always() after BOTH integration test jobs complete, then
  deletes all resource groups for the current run

* fix(ci): remove dead code flagged by PMD

PMD caught unused deleteOwnedResourceGroups, deleteLabeledResourceGroups,
stopDeploymentsInResourceGroup, waitForDeploymentsStopped, and
ownsResourceGroup methods left over from moving RG cleanup to CI job.
Also remove unused clearDeploymentIdCache().

* fix(itest): disable stop_deployment tests that kill shared RPT deployment

ActionTest.stop_deployment_changesTargetStatus and DeploymentTest.
update_targetStatus_stopsRunningDeployment stop the RPT deployment
created by AICoreServiceTest. Subsequent Recommendation tests then
hit 404 from the inference endpoint for the full retry budget (~250s)
because the stopped deployment's inference route is gone and the newly
created replacement deployment needs warmup time.

Disable both tests until they are refactored to create and stop their
own isolated deployment instead of the shared one.

* fix(ci): cleanup job reads credentials from VCAP_SERVICES not AICORE_SERVICE_KEY

cds bind --exec injects credentials via VCAP_SERVICES, not
AICORE_SERVICE_KEY. The cleanup script was trying to parse
AICORE_SERVICE_KEY which was undefined, causing a JSON parse error.
…40)

- Replace string concatenation in ServiceException messages with SLF4J-style
  {} placeholders in AICoreSetupHandler (3 locations)
- Replace Collectors.toList() with .toList() in AbstractCrudHandler
- Replace new ArrayList<>() with List.of() for null case
- Remove unused imports (ArrayList, Collectors)

Closes #32
…ler with record (#41)

* fix: use idiomatic patterns for error messages and stream collection

- Replace string concatenation in ServiceException messages with SLF4J-style
  {} placeholders in AICoreSetupHandler (3 locations)
- Replace Collectors.toList() with .toList() in AbstractCrudHandler
- Replace new ArrayList<>() with List.of() for null case
- Remove unused imports (ArrayList, Collectors)

Closes #32

* refactor: use cds-maven-plugin generated interfaces for type-safe entity access

Replace manual AICoreElements constants class with generated typed
interfaces from cds-maven-plugin (following cds-feature-attachments pattern):

- Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml
- Generated interfaces: Deployments, Configurations, ResourceGroups,
  BckndResourceGroupLabel (with constants + typed getters/setters)
- Update all handlers to use generated constants (e.g., Deployments.ID)
  and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id))
- Remove manual AICoreElements.java (replaced by code generation)
- DeploymentHandler: use Deployments.create() with typed setters instead
  of 17-parameter buildDeploymentData method

Closes #33

* fix: use explicit types and remove unnecessary cast in ConfigurationHandler

- Use List<ParameterArgumentBinding> instead of var for bindings
- Use ParameterArgumentBinding instead of var for bm
- Remove unnecessary (CdsData) cast since ParameterArgumentBinding extends CdsData

* fix: use ArtifactArgumentBinding generated interface for inputArtifactBindings

Replace raw CdsData with typed ArtifactArgumentBinding.create() and
setters for the inputArtifactBindings mapping in ConfigurationHandler.

* refactor: use typed data parameters in handler method signatures

Use CAP Java's handler data parameter injection for onCreate/onUpdate:
- DeploymentHandler.onCreate(ctx, List<Deployments>)
- DeploymentHandler.onUpdate(ctx, List<Deployments>)
- ConfigurationHandler.onCreate(ctx, List<Configurations>)
- ResourceGroupHandler.onCreate(ctx, List<ResourceGroups>)

This eliminates manual CQN entry extraction (context.getCqn().entries())
and enables typed getters (entry.getName() vs entry.get("name")).

Update tests to pass data as the second parameter directly.

* fix: remove unused CdsData import in ConfigurationHandler
* fix: use idiomatic patterns for error messages and stream collection

- Replace string concatenation in ServiceException messages with SLF4J-style
  {} placeholders in AICoreSetupHandler (3 locations)
- Replace Collectors.toList() with .toList() in AbstractCrudHandler
- Replace new ArrayList<>() with List.of() for null case
- Remove unused imports (ArrayList, Collectors)

Closes #32

* refactor: use cds-maven-plugin generated interfaces for type-safe entity access

Replace manual AICoreElements constants class with generated typed
interfaces from cds-maven-plugin (following cds-feature-attachments pattern):

- Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml
- Generated interfaces: Deployments, Configurations, ResourceGroups,
  BckndResourceGroupLabel (with constants + typed getters/setters)
- Update all handlers to use generated constants (e.g., Deployments.ID)
  and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id))
- Remove manual AICoreElements.java (replaced by code generation)
- DeploymentHandler: use Deployments.create() with typed setters instead
  of 17-parameter buildDeploymentData method

Closes #33

* refactor: slim AICoreService interface to public API surface

Remove implementation-detail methods from AICoreService interface:
- getDefaultResourceGroup()
- getResourceGroupPrefix()
- getTenantResourceGroupCache()
- getResourceGroupDeploymentCache()
- clearTenantCache()
- resolveResourceGroupFromKeys()

These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl
but are no longer part of the service contract. Only domain-level API
(resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled)
and getRetry() (cross-module consumer) remain on the interface.

Closes #34

* refactor: introduce AbstractAICoreService base class for shared internal methods

Extract getDefaultResourceGroup(), getResourceGroupPrefix(),
getTenantResourceGroupCache(), getResourceGroupDeploymentCache(),
clearTenantCache(), and resolveResourceGroupFromKeys() into an abstract
base class that both AICoreServiceImpl and MockAICoreServiceImpl extend.

This keeps the public AICoreService interface slim (domain API only)
while providing a typed common base for tests and internal consumers
that need access to cache/config methods.

Update all integration and MTX test files to use the new
getAICoreServiceImpl() helper that returns AbstractAICoreService.

Closes #34
The integration-tests-cleanup job now:
- Waits for both integration-tests and sonarqube-scan to finish
- Deletes resource groups with both itest- and sonar- prefixes

Closes #38
)

* fix: use idiomatic patterns for error messages and stream collection

- Replace string concatenation in ServiceException messages with SLF4J-style
  {} placeholders in AICoreSetupHandler (3 locations)
- Replace Collectors.toList() with .toList() in AbstractCrudHandler
- Replace new ArrayList<>() with List.of() for null case
- Remove unused imports (ArrayList, Collectors)

Closes #32

* refactor: use cds-maven-plugin generated interfaces for type-safe entity access

Replace manual AICoreElements constants class with generated typed
interfaces from cds-maven-plugin (following cds-feature-attachments pattern):

- Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml
- Generated interfaces: Deployments, Configurations, ResourceGroups,
  BckndResourceGroupLabel (with constants + typed getters/setters)
- Update all handlers to use generated constants (e.g., Deployments.ID)
  and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id))
- Remove manual AICoreElements.java (replaced by code generation)
- DeploymentHandler: use Deployments.create() with typed setters instead
  of 17-parameter buildDeploymentData method

Closes #33

* refactor: slim AICoreService interface to public API surface

Remove implementation-detail methods from AICoreService interface:
- getDefaultResourceGroup()
- getResourceGroupPrefix()
- getTenantResourceGroupCache()
- getResourceGroupDeploymentCache()
- clearTenantCache()
- resolveResourceGroupFromKeys()

These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl
but are no longer part of the service contract. Only domain-level API
(resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled)
and getRetry() (cross-module consumer) remain on the interface.

Closes #34

* refactor: decompose FioriRecommendationHandler into focused helpers

Extract two helper classes from the 400-line handler:

- RecommendationContextBuilder: entity analysis, prediction element
  discovery, context query building, synthetic key computation, and
  row assembly
- RecommendationResultParser: prediction value type coercion, text path
  resolution from annotations, description DB lookups, and final
  recommendations map assembly

The handler is now a slim orchestrator (~110 lines) that delegates
complex logic to the helpers.

Also replace unbounded ConcurrentHashMap.newKeySet() with a bounded
Caffeine cache (max 10,000 entries) for the entity negative-result cache.

Closes #35

* fix: add explicit caffeine dependency; make computeSyntheticKey private

- Add caffeine dependency to cds-feature-recommendations/pom.xml
  (previously relied on transitive from cds-feature-ai-core)
- Make computeSyntheticKey private in RecommendationContextBuilder
  (only called internally from assembleRows)
* fix: use idiomatic patterns for error messages and stream collection

- Replace string concatenation in ServiceException messages with SLF4J-style
  {} placeholders in AICoreSetupHandler (3 locations)
- Replace Collectors.toList() with .toList() in AbstractCrudHandler
- Replace new ArrayList<>() with List.of() for null case
- Remove unused imports (ArrayList, Collectors)

Closes #32

* refactor: use cds-maven-plugin generated interfaces for type-safe entity access

Replace manual AICoreElements constants class with generated typed
interfaces from cds-maven-plugin (following cds-feature-attachments pattern):

- Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml
- Generated interfaces: Deployments, Configurations, ResourceGroups,
  BckndResourceGroupLabel (with constants + typed getters/setters)
- Update all handlers to use generated constants (e.g., Deployments.ID)
  and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id))
- Remove manual AICoreElements.java (replaced by code generation)
- DeploymentHandler: use Deployments.create() with typed setters instead
  of 17-parameter buildDeploymentData method

Closes #33

* refactor: slim AICoreService interface to public API surface

Remove implementation-detail methods from AICoreService interface:
- getDefaultResourceGroup()
- getResourceGroupPrefix()
- getTenantResourceGroupCache()
- getResourceGroupDeploymentCache()
- clearTenantCache()
- resolveResourceGroupFromKeys()

These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl
but are no longer part of the service contract. Only domain-level API
(resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled)
and getRetry() (cross-module consumer) remain on the interface.

Closes #34

* refactor: decompose FioriRecommendationHandler into focused helpers

Extract two helper classes from the 400-line handler:

- RecommendationContextBuilder: entity analysis, prediction element
  discovery, context query building, synthetic key computation, and
  row assembly
- RecommendationResultParser: prediction value type coercion, text path
  resolution from annotations, description DB lookups, and final
  recommendations map assembly

The handler is now a slim orchestrator (~110 lines) that delegates
complex logic to the helpers.

Also replace unbounded ConcurrentHashMap.newKeySet() with a bounded
Caffeine cache (max 10,000 entries) for the entity negative-result cache.

Closes #35

* refactor: accept SDK API clients as constructor parameters

Add overloaded constructor to AICoreServiceImpl that accepts
DeploymentApi, ConfigurationApi, ResourceGroupApi, and AiCoreService
as parameters. The existing convenience constructor delegates to it
with default instances.

This enables direct injection of mocked clients in unit tests without
needing to mock getter methods on the service itself.

Closes #36

* refactor: remove convenience constructor, pass API clients explicitly

Remove the delegating convenience constructor from AICoreServiceImpl.
The only caller (AICoreServiceConfiguration) now passes DeploymentApi,
ConfigurationApi, ResourceGroupApi, and AiCoreService explicitly,
making the dependency wiring fully transparent at the creation site.

Closes #36
- Add -am flag to coverage-report generation so dependency modules are
  in the reactor (required by report-aggregate to resolve classes)
- Add -DskipTests to avoid re-running tests
- Include individual per-module JaCoCo reports in sonar paths as fallback
@hyperspace-insights

Copy link
Copy Markdown

Summary

The following content is AI-generated and provides a summary of the pull request:


Feat: Initial Prototype for CAP Java AI Integration

New Feature

This pull request introduces the initial prototype for a comprehensive AI feature integration for CAP Java applications. It includes two main libraries, a full testing suite, CI/CD pipelines, and a sample application.

cds-feature-ai-core: A foundational module that provides a seamless bridge to SAP AI Core.

  • Exposes AI Core resources (Resource Groups, Deployments, Configurations) as a native CAP service (AICore).
  • Supports full CRUD operations and custom actions via CQN and OData.
  • Includes robust multi-tenancy support with automatic resource group management during tenant onboarding/offboarding.
  • Features a resilient client with caching, retry logic, and exponential backoff for AI Core API calls.
  • Provides a mock implementation for local development without needing an AI Core binding.

cds-feature-recommendations: A powerful feature that delivers AI-powered field recommendations in Fiori UIs.

  • Automatically hooks into draft-enabled entities.
  • Uses historical data and the RPT-1 foundation model to predict values for fields with value lists.
  • Integrates with Fiori Elements to display suggestions directly in the UI.

Note: This PR is for code review only and should be closed once the review is complete.

Changes

This is a large initial commit that scaffolds the entire project. The changes are summarized by module:

  • Project Scaffolding (/, .github/):

    • pom.xml: Established a multi-module Maven project structure.
    • README.md, LICENSE, CONTRIBUTING.md: Added standard project documentation and legal files.
    • .github/workflows/: Set up a comprehensive CI/CD pipeline using GitHub Actions for PR validation, security scanning (CodeQL, SonarQube, BlackDuck), and release deployment.
    • .github/actions/: Created reusable composite actions for build, test, scan, and deployment steps.
  • Core AI Integration (cds-feature-ai-core/):

    • Implemented the AICoreService to interact with the SAP AI SDK, manage deployments, and handle predictions.
    • Added AICoreServiceImpl with caching and retry logic, and MockAICoreServiceImpl for local testing.
    • Created AICoreSetupHandler to manage multi-tenancy lifecycle events.
    • Defined the AICore CDS service model in cds/com.sap.cds/ai/index.cds.
  • Field Recommendations (cds-feature-recommendations/):

    • FioriRecommendationHandler.java: Implemented the main handler to intercept read events on draft entities, build context, and trigger predictions.
    • RptInferenceClient.java: A client to communicate with the RPT-1 model.
    • RecommendationContextBuilder.java & RecommendationResultParser.java: Helpers to prepare data for the AI model and parse its response for the UI.
  • Starter & Aggregators (cds-starter-ai/, coverage-report/):

    • cds-starter-ai/: Created a starter module for easy dependency management.
    • coverage-report/: Added a module to aggregate JaCoCo code coverage reports.
  • Testing (integration-tests/):

    • Developed a full suite of integration tests for a Spring Boot application.
    • spring/: Contains tests for core service functionality, OData delegation, and the recommendation feature.
    • mtx-local/: Contains tests for multi-tenancy scenarios, including tenant subscription and isolation.
  • Sample Application (samples/bookshop/):

    • Included a complete CAP Java bookshop application to demonstrate the AI features.
    • The AdminService is draft-enabled to showcase field recommendations.
    • The AICoreShowcaseService provides examples of how to programmatically interact with the AICoreService.

  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.21.0 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback

  • Output Template: Default Template
  • File Content Strategy: Diffs only
  • Summary Prompt: Default Prompt
  • Event Trigger: pull_request.opened
  • Correlation ID: ea28e160-ae3d-46b6-8a69-51c04ea3ba2b
  • LLM: gemini-2.5-pro

💌 Have ideas or want to contribute? Create an issue and share your thoughts with us!
📑 Check out the documentation for more information.
📬 Subscribe to the Hyperspace PR Bot DL to get the latest announcements and pilot features!

Made with ❤️ by Hyperspace.

@hyperspace-insights hyperspace-insights Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request is a large but well-structured initial implementation of a new feature, including comprehensive CI/CD pipelines and documentation. I've identified a few areas for improvement regarding maintainability and a minor bug in the cache-clearing logic. Overall, the quality is high.

PR Bot Information

Version: 1.21.0 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback

  • File Content Strategy: Diffs only
  • Event Trigger: pull_request.opened
  • Correlation ID: ea28e160-ae3d-46b6-8a69-51c04ea3ba2b
  • LLM: gemini-2.5-pro

Comment thread .github/workflows/pipeline.yml Outdated
Comment thread .github/actions/scan-with-sonar/action.yml
@Schmarvinius Schmarvinius marked this pull request as draft May 28, 2026 11:39
Schmarvinius and others added 8 commits May 28, 2026 13:44
* fix: add assertion to delete_resourceGroup test case

SonarQube flagged the test for having no assertions.
After deletion, verify the resource group is either removed
or no longer in PROVISIONED status.

* fix: use assertThatCode for async delete operation

The AI Core delete is asynchronous - the resource group remains
in PROVISIONED status immediately after deletion. Assert that the
delete call itself completes without throwing instead.
* fix: add assertion to delete_resourceGroup test case

SonarQube flagged the test for having no assertions.
After deletion, verify the resource group is either removed
or no longer in PROVISIONED status.

* fix: use assertThatCode for async delete operation

The AI Core delete is asynchronous - the resource group remains
in PROVISIONED status immediately after deletion. Assert that the
delete call itself completes without throwing instead.

* chore: update repo references from cds-feature-ai to cds-ai

Rename all standalone references to the old repository name
'cds-feature-ai' to the new name 'cds-ai'. This includes:
- GitHub URLs (SCM, issues, security policy)
- REUSE.toml package metadata
- Copyright headers in all source files
- License header template in pom.xml

Submodule artifact names (cds-feature-ai-core, etc.) are
intentionally left unchanged.
Signed-off-by: Marvin L <marvin.lindner@sap.com>
…ates (#48)

Bumps the minor-patch group with 2 updates in the / directory: [org.junit:junit-bom](https://github.com/junit-team/junit-framework) and [com.diffplug.spotless:spotless-maven-plugin](https://github.com/diffplug/spotless).


Updates `org.junit:junit-bom` from 6.0.3 to 6.1.0
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](junit-team/junit-framework@r6.0.3...r6.1.0)

Updates `com.diffplug.spotless:spotless-maven-plugin` from 3.5.0 to 3.5.1
- [Release notes](https://github.com/diffplug/spotless/releases)
- [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md)
- [Commits](diffplug/spotless@maven/3.5.0...maven/3.5.1)

---
updated-dependencies:
- dependency-name: com.diffplug.spotless:spotless-maven-plugin
  dependency-version: 3.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: org.junit:junit-bom
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marvin L <marvin.lindner@sap.com>
Bumps the minor-patch group with 1 update: [actions/stale](https://github.com/actions/stale).


Updates `actions/stale` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](actions/stale@b5d41d4...eb5cf3a)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marvin L <marvin.lindner@sap.com>
Replace local composite actions (build, deploy-release, scan-with-codeql,
scan-with-blackduck) and reusable workflows (issue, stale, prevent-issue-labeling)
with their shared equivalents from cap-java/.github.

Refactor cf-bind to delegate CF CLI install and login to the shared
cf-login action.

Closes #54
* chore: code review cleanup before senior review

- fix(AICoreServiceImpl): move deploymentLocks from a Caffeine cache to
  ConcurrentHashMap so two threads asking for the same key always
  synchronize on the same monitor; eviction could otherwise cause
  duplicate AI Core deployments.
- fix(AICoreServiceImpl): wrap deploymentApi.get on cached deployment
  in try/catch so a 404 (deployment deleted out-of-band) invalidates
  the stale cache entry instead of failing forever.
- refactor(DeploymentHandler): extract a shared applyTo + DeploymentValues
  record so the two toDeployments overloads share the field-set logic;
  remaining duplication is the unavoidable getter-chain on two SDK types
  with no common interface.
- docs: README updates - replace nonexistent AIClient / AICoreClient /
  fetchPredictions / rpt1DeploymentId references with the real API,
  align recommendations contextRowLimit config key with the code.
- docs: full Javadoc on AICoreService, ModelDeploymentSpec,
  AICoreServiceConfiguration, and class-level Javadoc on AICoreServiceImpl.
- chore: add @OverRide annotations across AICoreServiceImpl and
  MockAICoreServiceImpl; promote getRuntime() to AbstractAICoreService.
- chore(cds-starter-ai): keep packaging=jar (matches CAP starter
  convention) but document the empty-jar starter pattern explicitly.
- docs(RecommendationResultParser): explain the placeholder 0.5 score
  emitted to Fiori Elements until the AI SDK exposes prediction_proba.
- test: new AICoreServiceImplDeploymentIdTest covering cache-hit,
  stale-cache 404 invalidation, existing-deployment reuse, second-call
  caching, and create-when-config-exists happy paths.
- chore: mvn spotless:apply (Google Java Format + sort-pom).

* build: make mvn install hermetic via npm ci (no global @sap/cds-dk)

Introduce a package.json + package-lock.json at cds-feature-ai-core
root pinning @sap/cds-dk@9.9.1, and bind a new cds-maven-plugin
'cds.npm-ci' execution that runs 'npm ci' before the cds.build goal.
A fresh checkout can now run 'mvn install' without any prerequisite
beyond Maven + JDK; cds-maven-plugin's bundled Node downloads
@sap/cds-dk into a local node_modules at module root, outside the
cds source tree (so 'cds build --src ./ --dest ../../../../../../gen/srv'
no longer self-copies node_modules).

Drop -Dcds.install-node.skip from every Maven invocation in CI
(.github/workflows/pipeline.yml, .github/actions/integration-tests,
.github/actions/scan-with-sonar) since the bundled Node is now the
canonical execution environment for the cds CLI inside Maven.

Drop the standalone 'npm i -g @sap/cds-dk' steps from the 'tests'
and 'local-mtx-tests' jobs (Maven-only paths). The cf-bind composite
action keeps its global cds-dk install because cds bind --exec is
invoked as a shell command in subsequent steps.

* fix: narrow stale-cache catch to 404 only; use deploymentCacheKey helper in tests

Address two of three review comments from the PR bot on PR #57:

1. (AICoreServiceImpl#deploymentId) The previous catch on
   OpenApiRequestException swallowed all OpenApi failures — including
   transient 5xx and network errors — and unconditionally invalidated
   the cache entry. That would silently discard a still-valid cached
   deployment and trigger a duplicate creation. Narrow the catch to
   only treat HTTP 404 as the 'deployment deleted out-of-band' signal;
   propagate everything else so the caller's retry/backoff policy
   handles it.

2. (AICoreServiceImplDeploymentIdTest) The tests seeded the cache with
   the raw string 'RG + "::" + CONFIG_NAME', duplicating the cache-key
   format with the production code. A future change to the
   deploymentCacheKey() format would silently make the tests pass for
   the wrong reason. Promote deploymentCacheKey() from private to
   package-private and have the tests derive the key through it.

3. Add a regression test (cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry)
   that exercises the narrowed catch: a 503 from deploymentApi.get must
   propagate AND leave the cache entry intact.

* build: add hermetic npm ci for cds-feature-recommendations

The previous commit (67da453) made cds-feature-ai-core hermetic by
binding 'npm ci' to its Maven build, but cds-feature-recommendations
also runs 'cds compile' (via cds.compile execution) and was still
relying on a globally installed @sap/cds-dk that we removed from CI.

Mirror the same pattern: introduce a package.json + package-lock.json
at the cds-feature-recommendations module root pinning @sap/cds-dk@9.9.1,
and add 'cds.install-node' + 'cds.npm-ci' executions before 'cds.compile'.

@agoerler agoerler left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some feedback. (I have not looked at the ai core feature in detail so far).

Some general findings:

  • I like the separation into two plugins very much :-)
  • please move the APIs to dedicated api packages
  • looks very tidy overall

AI Core

  • the AICoreService interface could be possibly segragated into
    • the part to manage resource groups and deployment IDs
    • the inference API - maybe you don't need this part at all but could directly register ApiClient as a technical service or Spring bean.
  • the tenant must not appear in the AICoreService API. The user of the AICoreService should be tenant-agnostic but the implementation should be tenant-aware
  • you should provide a mock implementation of the APIClient that can use langchain4j to use local models
  • ideally you externalize the mapping of symbolic inference model names to actual model names so that we can use differently expensive models for testing and in production

Recommendations

  • make sure the annotations can also be added manually
  • consider to register the RecommendationClient as a service so that the application could overwrite the RecommendationClient's on-handler to compute recommendations somehow differently (could be done later)
  • design the RecommendationClient API so that it's not too tightly coupled to RPT-1

Comment thread cds-feature-recommendations/README.md
Comment thread cds-feature-recommendations/README.md
3. Returns predictions as `SAP_Recommendations` in the OData response
4. Fiori Elements renders the recommendations as suggestions in form fields

## Setup

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add a cds add flavor

Comment thread cds-feature-recommendations/README.md

Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. Without this plugin, predictions will be computed but not serialized in OData responses.

Since the Java module `cds-feature-ai-core` already provides the `AICore` service CDS model, disable the duplicate model from `@cap-js/ai` in your `.cdsrc.json`:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider to offer a slimmed-down version of @cap-js/ai that does not contain the model artifacts. Or exclude the model artifacts from cds-feature-recommendations.


| Entity | Operations | Description |
| ----------------------- | -------------------- | ---------------------------------------------------------------- |
| `AICore.resourceGroups` | READ, CREATE, DELETE | Resource group lifecycle, supports label filtering by `tenantId` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| `AICore.resourceGroups` | READ, CREATE, DELETE | Resource group lifecycle, supports label filtering by `tenantId` |
| `AICore.resourceGroups` | READ, CREATE, DELETE | Resource group lifecycle, supports label filtering |

make sure that the API is always restricted to the current tenant and does not expose other tenants' resource groups

Comment thread cds-feature-ai-core/README.md Outdated
Comment on lines +67 to +68
// Get the resource group ID for a CDS tenant
String rgId = aiCoreService.resourceGroupForTenant(tenantId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Get the resource group ID for a CDS tenant
String rgId = aiCoreService.resourceGroupForTenant(tenantId);
// Get the resource group ID for the current tenant
String rgId = aiCoreService.resourceGroup();

Comment thread cds-feature-ai-core/README.md Outdated

## Multi-Tenancy

When `cds.requires.AICore.multiTenancy=true`:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally you do without this config but evaluate the properties under https://pages.github.tools.sap/cap/docs/java/developing-applications/properties#cds-multiTenancy

Comment on lines +94 to +97
// Resolve a deployment and obtain a configured ApiClient for it
String resourceGroupId = aiCore.resourceGroupForTenant(tenantId);
String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1());
ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation can obtain the tenantId from the tenant provider service. As there seems to be exactly one resource group per tenant we could simplify this:

// Resolve a deployment and obtain a configured ApiClient for it
String resourceGroupId = aiCore.resourceGroup();
String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1());
ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId);

the only variable appears to the model for which there appears to be exactly one deployment -> we can further simplify the API to

AICoreService aiCore = runtime.getServiceCatalog()
    .getService(AICoreService.class, AICoreService.DEFAULT_NAME);

ApiClient client = aiCore.inferenceClient(RptModelSpec.rpt1());

We could also use dependency injection:

@Autowired
AICoreService aiCore;

ApiClient client = aiCore.inferenceClient(RptModelSpec.rpt1());

and if we add an option to configure service instances for the ApiClient in the application.yaml (sketchy!):

cds.api.core.client:
  -name : myRpt1Client
    model: RPT1

then we could use qualified dependency injection

@Autowired
@Qualifier("myRpt1Client")
ApiClient rpt1;

If we externalize the model config for the inference client we can have different models in production as well as on localhost/CI so that we don't have to burn expensive tokens for testing but can rather use something cheap for this purpose.

Comment on lines +86 to +90
/**
* Returns the shared {@link Retry} used internally for transient AI Core errors. Exposed so
* downstream inference clients can reuse the same backoff policy.
*/
Retry getRetry();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit unexpected to find this here

@agoerler agoerler left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some feedback. (I have not looked at the ai core feature in detail so far).

Some general findings:

  • I like the separation into two plugins very much :-)
  • please move the APIs to dedicated api packages
  • looks very tidy overall

AI Core

  • the AICoreService interface could be possibly segragated into
    • the part to manage resource groups and deployment IDs
    • the inference API - maybe you don't need this part at all but could directly register ApiClient as a technical service or Spring bean.
  • the tenant must not appear in the AICoreService API. The user of the AICoreService should be tenant-agnostic but the implementation should be tenant-aware
  • you should provide a mock implementation of the APIClient that can use langchain4j to use local models
  • ideally you externalize the mapping of symbolic inference model names to actual model names so that we can use differently expensive models for testing and in production

Recommendations

  • make sure the annotations can also be added manually
  • consider to register the RecommendationClient as a service so that the application could overwrite the RecommendationClient's on-handler to compute recommendations somehow differently (could be done later)
  • design the RecommendationClient API so that it's not too tightly coupled to RPT-1

@lisajulia

Copy link
Copy Markdown
Contributor

Some feedback. (I have not looked at the ai core feature in detail so far).

Some general findings:

  • I like the separation into two plugins very much :-)
  • please move the APIs to dedicated api packages
  • looks very tidy overall

AI Core

  • the AICoreService interface could be possibly segragated into

    • the part to manage resource groups and deployment IDs
    • the inference API - maybe you don't need this part at all but could directly register ApiClient as a technical service or Spring bean.
  • the tenant must not appear in the AICoreService API. The user of the AICoreService should be tenant-agnostic but the implementation should be tenant-aware

  • you should provide a mock implementation of the APIClient that can use langchain4j to use local models

  • ideally you externalize the mapping of symbolic inference model names to actual model names so that we can use differently expensive models for testing and in production

Recommendations

  • make sure the annotations can also be added manually
  • consider to register the RecommendationClient as a service so that the application could overwrite the RecommendationClient's on-handler to compute recommendations somehow differently (could be done later)
  • design the RecommendationClient API so that it's not too tightly coupled to RPT-1

Thanks!

* refactor(ai-core): move public API to dedicated api package

Move AICoreService and ModelDeploymentSpec from
com.sap.cds.feature.aicore.core to com.sap.cds.feature.aicore.api so the
public contract is clearly separated from the internal implementation.
Add a package-info.java describing the api package.

Internal classes (AbstractAICoreService, AICoreServiceImpl,
MockAICoreServiceImpl, AICoreServiceConfiguration, handlers) and the
unit test for AICoreServiceImpl get explicit imports for the relocated
types. Implementation references in AICoreService javadoc use FQCN
@link to avoid coupling the interface to internals.

Addresses review feedback on PR #49 (move APIs to api package).

* refactor(recommendations): move public API to dedicated api package

Move RecommendationClient, RecommendationClientResolver, RptInferenceClient
and RptModelSpec from com.sap.cds.feature.recommendation to
com.sap.cds.feature.recommendation.api so the public contract is clearly
separated from the internal implementation. Add a package-info.java for
the api package.

RecommendationClient and RecommendationClientResolver are promoted from
package-private to public to be usable from outside the plugin.

Internal classes (FioriRecommendationHandler, MockRecommendationClient,
RecommendationConfiguration) and tests get explicit imports for the
relocated types.

Addresses review feedback on PR #49 (move APIs to api package).

* test(integration): update imports for relocated api packages

Update test imports in integration-tests/spring and
integration-tests/mtx-local to reference AICoreService, ModelDeploymentSpec
and RptModelSpec from their new api packages.

Addresses review feedback on PR #49 (move APIs to api package).

* samples(bookshop): update imports for relocated api packages

Update AICoreShowcaseHandler imports to reference AICoreService,
RptInferenceClient and RptModelSpec from their new api packages.

Addresses review feedback on PR #49 (move APIs to api package).
Schmarvinius added a commit that referenced this pull request Jun 10, 2026
- Replace resourceGroupForTenant(String) with resourceGroup() on the
  public AICoreService interface. The implementation reads the tenant
  from the current RequestContext internally.
- Remove isMultiTenancyEnabled() and getRetry() from the public
  interface; they remain accessible on AbstractAICoreService for
  internal callers.
- Remove the CDS function 'resourceGroupForTenant' from index.cds
  and its action handler.
- Detect multi-tenancy via standard CAP Java cds.multiTenancy.sidecar.url
  property and DeploymentService presence instead of custom flag.
- Update RecommendationClientResolver to drop tenantId parameter.
- Update samples, tests, and javadoc accordingly.

Addresses review comments from PR #49 (Issue 2).
Schmarvinius added a commit that referenced this pull request Jun 10, 2026
- Add tenant ownership verification on ResourceGroupHandler for
  READ (by-key), UPDATE, and DELETE operations. Returns 404 if the
  resource group belongs to a different tenant.
- Scope list queries (READ without key) to the current tenant's
  resource groups via the tenant label filter in multi-tenancy mode.
- Add ensureResourceGroupAccessible() guard to DeploymentHandler and
  ConfigurationHandler, validating the addressed resource group
  belongs to the current tenant before forwarding to AI Core.
- Provider/system users are exempt from tenant restrictions and can
  access all resource groups (useful for ops/debug scenarios).
- Add isProviderUser() and currentTenantId() as public helpers on
  AbstractAICoreService for use by handler classes.

Addresses review comments from PR #49 (Issue 3a).
Schmarvinius added a commit that referenced this pull request Jun 10, 2026
- Rename all configuration properties from cds.requires.AICore.* to
  cds.ai.core.* to align with CAP Java property naming conventions.
- Rename cds.requires.recommendations.contextRowLimit to
  cds.ai.recommendations.contextRowLimit.
- Drop the cds.requires.AICore.multiTenancy flag entirely; multi-
  tenancy is now auto-detected from standard CAP Java properties.
- Update README with new configuration namespace and examples.

Addresses review comments from PR #49 (Issue 3b).
public interface AICoreService extends CqnService {

/** Default service name under which an instance is registered in the service catalog. */
String DEFAULT_NAME = "AICore";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
String DEFAULT_NAME = "AICore";
String DEFAULT_NAME = "AICoreService$Default";

*Service$Default is standard pattern for CAP provided services


private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class);

public ActionHandler(AICoreServiceImpl service) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handlers are registered to concrete services and the called service is always always propagated via EventContext. Hence there is no need to store the ServiceImpl as reference. Also the handler should be fully decoupled from the concrete service implementation.

super(service);
}

@On(event = "resourceGroupForTenant")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the constant is comple-safe

context.setCompleted();
}

@On(event = "stop", entity = AICoreService.DEPLOYMENTS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the constant is comple-safe

}

@On(event = "resourceGroupForTenant")
public void onResourceGroupForTenant(EventContext context) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.filter(q -> q.from().isRef())
.map(q -> CqnAnalyzer.create(model).analyze(q).targetEntity())
.map(CdsEntity::getQualifiedName)
.filter(name -> name.startsWith("AICore."))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be the indicator rather be an annotation instead of a string prefix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed: AICoreApplicationServiceHandler will be deleted since it introduces too many edge cases
Consumers should write the projection use case themselves!

this.multiTenancyEnabled = multiTenancyEnabled;
CdsEnvironment env = runtime.getEnvironment();
this.maxRetries =
env.getProperty("cds.requires.AICore.maxRetries", Integer.class, DEFAULT_MAX_RETRIES);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proposal:

"cds.AICore.client.maxRetries"
"cds.AICore.client.initialDelayMs"
"cds.AICore.resourceGroup.default"
"cds.AICore.resourceGroup.prefix"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I named them cds.ai.core.* in my branch
for recommendation, it then would be cds.ai.recommendations.*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For embeddings I could imagine cds.ai.embeddings with different profiles, e.g. using HANA NLP or AI Core models:

  cds.ai:
    embeddings:
        # Local HANA model - no remote source
        default:
          model: SAP_NEB.20240715

        large:
          model: text-embedding-3-large
          remoteSource: AICore
          options:
            execution_params:
              timeout: 60000

We could (later) add cds-feature-embeddings that uses feature-ai-core to provide a CDS4j EmbeddingService integration and does the HANA setup for using embedding models from AI Core.

this.service = service;
}

@After(event = DeploymentService.EVENT_SUBSCRIBE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@After(event = DeploymentService.EVENT_SUBSCRIBE)
@After

}

@Override
public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd expect all this implemented actions in handlers (same for mock variant)


private static final long MAX_INTERVAL_MS = 30_000L;

private static Retry buildRetry(int maxAttempts, long initialDelayMs) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a circuit breaker?

this.multiTenancyEnabled = multiTenancyEnabled;
CdsEnvironment env = runtime.getEnvironment();
this.maxRetries =
env.getProperty("cds.requires.AICore.maxRetries", Integer.class, DEFAULT_MAX_RETRIES);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For embeddings I could imagine cds.ai.embeddings with different profiles, e.g. using HANA NLP or AI Core models:

  cds.ai:
    embeddings:
        # Local HANA model - no remote source
        default:
          model: SAP_NEB.20240715

        large:
          model: text-embedding-3-large
          remoteSource: AICore
          options:
            execution_params:
              timeout: 60000

We could (later) add cds-feature-embeddings that uses feature-ai-core to provide a CDS4j EmbeddingService integration and does the HANA setup for using embedding models from AI Core.

}

@Override
public ApiClient inferenceClient(String resourceGroupId, String deploymentId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to have an integration point for the ai-sdk-java and other higher-level APIs like LangChain4j? How would this look like?

Comment on lines +56 to +57
throw new UnsupportedOperationException(
"MockAICoreServiceImpl does not provide an inference client; tests should stub inference.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApiClient seems to be a low level technical API. On top of that we could (later) have integrations with the ai-sdk and other higher level APIs like LangChain4j.

* Make cache for entitiesWithoutPredictionsPerTenant tenant specific

* refactor(ai-core): make AICoreService tenant-agnostic and DI-friendly

- Replace resourceGroupForTenant(String) with resourceGroup() on the
  public AICoreService interface. The implementation reads the tenant
  from the current RequestContext internally.
- Remove isMultiTenancyEnabled() and getRetry() from the public
  interface; they remain accessible on AbstractAICoreService for
  internal callers.
- Remove the CDS function 'resourceGroupForTenant' from index.cds
  and its action handler.
- Detect multi-tenancy via standard CAP Java cds.multiTenancy.sidecar.url
  property and DeploymentService presence instead of custom flag.
- Update RecommendationClientResolver to drop tenantId parameter.
- Update samples, tests, and javadoc accordingly.

Addresses review comments from PR #49 (Issue 2).

* feat(ai-core): restrict AICore entity APIs to current tenant

- Add tenant ownership verification on ResourceGroupHandler for
  READ (by-key), UPDATE, and DELETE operations. Returns 404 if the
  resource group belongs to a different tenant.
- Scope list queries (READ without key) to the current tenant's
  resource groups via the tenant label filter in multi-tenancy mode.
- Add ensureResourceGroupAccessible() guard to DeploymentHandler and
  ConfigurationHandler, validating the addressed resource group
  belongs to the current tenant before forwarding to AI Core.
- Provider/system users are exempt from tenant restrictions and can
  access all resource groups (useful for ops/debug scenarios).
- Add isProviderUser() and currentTenantId() as public helpers on
  AbstractAICoreService for use by handler classes.

Addresses review comments from PR #49 (Issue 3a).

* chore(ai-core): rename config namespace to cds.ai.core

- Rename all configuration properties from cds.requires.AICore.* to
  cds.ai.core.* to align with CAP Java property naming conventions.
- Rename cds.requires.recommendations.contextRowLimit to
  cds.ai.recommendations.contextRowLimit.
- Drop the cds.requires.AICore.multiTenancy flag entirely; multi-
  tenancy is now auto-detected from standard CAP Java properties.
- Update README with new configuration namespace and examples.

Addresses review comments from PR #49 (Issue 3b).

* fix(ai-core): handle null tenant in resourceGroupForTenant

When resourceGroupForTenant is called with a null tenantId (which
happens when currentTenantId() returns null in single-tenant or
non-tenant-scoped RequestContexts), fall back to the default resource
group instead of passing null to the Caffeine cache (which throws NPE).

This fixes integration test failures in the CI pipeline where the
ApplicationServiceDelegation and Recommendation tests run without an
explicit tenant in the RequestContext.

* fix(ci): cleanup all run attempts and cds-itest resource groups

The cleanup step previously only deleted resource groups matching the
exact current run_id AND run_attempt. When a run failed and was re-run,
the previous attempt's resource groups were never cleaned up, eventually
hitting the AI Core resource group limit (50).

Changes:
- Match prefix 'itest-{run_id}-' (all attempts) instead of the exact
  'itest-{run_id}-{run_attempt}' string.
- Same for 'sonar-{run_id}-' prefix.
- Also delete 'cds-itest-' prefixed resource groups which are created
  by the multi-tenancy integration tests via resourceGroupForTenant()
  and were never cleaned up by the pipeline.

* fix(itest): align config namespace with cds.ai.core rename

The source code (commit c30080b) renamed properties from
cds.requires.AICore.* to cds.ai.core.*, but the integration test
application.yaml files were not updated. This meant the
CDS_AICORE_TEST_RESOURCE_GROUP env var set by CI was silently ignored
and tests always ran against the literal default resource group.

- spring/application.yaml: cds.requires.AICore -> cds.ai.core
- mtx-local/application.yaml: remove obsolete cds.requires.AICore.multiTenancy
  (now auto-detected from cds.multi-tenancy.sidecar.url)

* test(ai-core): add unit tests for tenant scoping and mock service

Cover new code paths introduced by the tenant-scoping branch:

- TenantScopingTest (7 tests): exercises every branch of
  AbstractCrudHandler.ensureResourceGroupAccessible() — provider bypass,
  single-tenancy bypass, null tenant, matching/non-matching labels, 404.

- MockAICoreServiceImplTest (9 tests): both constructors,
  MT enabled/disabled, resourceGroupForTenant, cache isolation,
  clearTenantCache, getRetry, config property reads.

- AICoreServiceImplDeploymentIdTest (+2 tests):
  resourceGroupForTenant(null) returns default even with MT enabled;
  single-tenancy always returns default.

* chore(recommendations): add TODO for model-changed integration test

Document the missing E2E coverage for RecommendationModelChangedHandler.
The proper test requires an extensibility-enabled sidecar with extension
JSON that adds prediction columns — not yet set up in mtx-local.

The cache-invalidation logic itself is covered by the existing unit test
FioriRecommendationHandlerTest.invalidateTenant_removesOnlyThatTenantsEntries.

* update cleanup

* fix(ci): scope resource group cleanup to own job only

Each parallel CI job (Java 17, Java 21, SonarQube) was using broad
prefixes in its cleanup step, deleting resource groups belonging to
sibling jobs still in progress. This caused intermittent 403 Forbidden
errors when the affected jobs tried to use their now-deleted resource
groups.

Narrow the cleanup prefixes so each job only deletes its own:
- integration-tests: itest-{run_id}-{attempt}-j{version}*
- scan-with-sonar: sonar-{run_id}-{attempt}*
Both still clean up itest-rg-* (ResourceGroupTest leftovers).

* test(ai-core): add unit tests for uncovered code paths

- DeploymentHandler: test onCreate (with/without TTL) and onUpdate
  happy path (targetStatus and configurationId branches)
- ResourceGroupHandler: test onUpdate with/without labels,
  buildTenantLabelSelector branches (tenantId filter, MT non-provider,
  MT null tenant, single tenancy), ensureOwnedByCurrentTenant branches
  (provider, single tenant, wrong tenant, matching tenant)
- AICoreServiceConfiguration: test eventHandlers() MockAICoreServiceImpl
  branch (with and without multi-tenancy), test detectMultiTenancy via
  services() for sidecarUrl branch and no-MT fallback

* fix(ci): include cds-itest- prefix in resource group cleanup

The MultiTenancyTest creates per-tenant resource groups with names like
cds-itest-mt-a-{timestamp} (from resourceGroupPrefix 'cds-' + tenant
name 'itest-*'). These are unique per test run (timestamped) and safe
to clean up from any job without cross-job interference.

* refactor: extract inline cleanup script into shared JS file

---------

Co-authored-by: Marvin L <marvin.lindner@sap.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants