Skip to content

feat(aicore): transparent proxy routing and BTP Destination Service mode - #271

Merged
tiagoek merged 14 commits into
mainfrom
feat/aicore-proxy-routing
Sep 2, 2026
Merged

feat(aicore): transparent proxy routing and BTP Destination Service mode#271
tiagoek merged 14 commits into
mainfrom
feat/aicore-proxy-routing

Conversation

@tiagoek

@tiagoek tiagoek commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds transparent proxy routing and BTP Destination Service mode to set_aicore_config() — agent code is identical in all environments; the deployer controls routing by choosing which env vars to inject.

Neither proxy mode nor destination mode requires a LiteLLM upstream change. Both work with the current public litellm package.


What changed

set_aicore_config() now detects the routing mode from environment variables (priority: proxy > destination > direct):

Proxy mode — AICORE_PROXY_URL set

Routes all LiteLLM calls through an external LiteLLM proxy:

  • Sets litellm.api_base and litellm.api_key (proxy API key) globally
  • Model strings (e.g. sap/<model>) are passed verbatim — no prefix rewrite. LiteLLM routes through the configured api_base natively
  • No AI Core credentials written to the process environment
  • JWT never reaches the agent process — the proxy holds the OAuth client secret
# Deployer injects these; agent code unchanged
AICORE_PROXY_URL=https://litellm-proxy.cluster.svc
AICORE_PROXY_API_KEY=sk-master-xxx

Destination mode — AICORE_DESTINATION_NAME set

Loads AI Core credentials at startup from a named BTP Destination Service destination:

  • Uses the existing sap_cloud_sdk.destination client — no new dependencies
  • Deployer only needs to inject Destination Service binding credentials into the K8s Secret
  • AICORE_CLIENT_SECRET never needs to be in the K8s Secret — it lives in BTP Destination Service
  • On AuthenticationError, the reactive reload in completion() calls set_aicore_config(), which re-fetches fresh credentials from the Destination Service
# Deployer injects these; agent code unchanged
AICORE_DESTINATION_NAME=aicore-instance
# + Destination Service service binding (clientid/clientsecret/url/uri)

Direct mode (unchanged)

Neither env var set → existing behaviour: load from mounted K8s secret or env vars.


Agent code — identical in all modes

# Same regardless of proxy, destination, or direct mode
set_aicore_config()
response = completion(model="sap/gpt-4o", messages=[{"role": "user", "content": "Hello"}])

Security coverage

Threat Proxy mode Destination mode Direct mode
CLIENT_SECRET in K8s Secret ✅ not needed ✅ not needed ❌ required
CLIENT_SECRET in process env ✅ never ⚠️ present during session ⚠️ present during session
JWT in agent process memory ✅ never (proxy holds it) ❌ agent receives JWT ❌ agent receives JWT
LiteLLM upstream change required ✅ no ✅ no ✅ no
Zero agent code changes

Tests

New unit tests:

  • TestSetAICoreConfigProxyMode (6 tests) — mode detection, litellm globals, precedence, filtering still applied
  • TestSetAICoreConfigDestinationMode (9 tests) — URL extraction, credentials, resource group, error cases

Full suite: 156 passed, 4 skipped (integration tests requiring real BTP).

@tiagoek
tiagoek force-pushed the feat/aicore-clear-client-secret branch from 3e9ef43 to ed315de Compare August 26, 2026 17:37
@tiagoek
tiagoek changed the base branch from feat/aicore-clear-client-secret to feat/aicore-transparent-tls August 26, 2026 20:28
@tiagoek
tiagoek force-pushed the feat/aicore-proxy-routing branch from b2f8869 to b1468fc Compare August 27, 2026 14:40
@tiagoek
tiagoek changed the base branch from feat/aicore-transparent-tls to main August 28, 2026 14:34
@tiagoek tiagoek changed the title feat(aicore): transparent proxy routing and BTP Destination Service mode (Option 3) feat(aicore): transparent proxy routing and BTP Destination Service mode Aug 28, 2026
@tiagoek
tiagoek marked this pull request as ready for review August 28, 2026 15:00
@tiagoek
tiagoek requested a review from a team as a code owner August 28, 2026 15:00
@tiagoek
tiagoek changed the base branch from main to feat/aicore-transparent-tls August 28, 2026 15:09
@tiagoek
tiagoek force-pushed the feat/aicore-proxy-routing branch 2 times, most recently from 6fa7c3c to efd2c18 Compare August 28, 2026 19:55
Base automatically changed from feat/aicore-transparent-tls to main August 31, 2026 20:14
@tiagoek
tiagoek force-pushed the feat/aicore-proxy-routing branch 2 times, most recently from 8e547d8 to 6160f6c Compare September 1, 2026 19:26
Comment thread pyproject.toml Outdated
Comment thread src/sap_cloud_sdk/aicore/__init__.py Outdated
@tiagoek

tiagoek commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

SDK Module Review

Check Status Findings
bdd ✅ PASS 0
binding-shape ✅ PASS 0
commits ✅ PASS 0
concurrency ✅ PASS 0
constants ✅ PASS 0
deps-supply ✅ PASS 0
disclosure ✅ PASS 0
docs ✅ PASS 0
errors-logging ✅ PASS 0
hardcode ✅ PASS 0
http-hygiene ✅ PASS 0
license-spdx ✅ PASS 0
patterns ✅ PASS 0
pr-size ✅ PASS 0
quality-gate-parity ✅ PASS 0
secrets ✅ PASS 0
telemetry ✅ PASS 0
testing-depth ✅ PASS 0
versioning ✅ PASS 0

0 finding(s): 0 posted as inline comment(s) on the affected lines, 0 not tied to a code line (listed above).


Generated by sdk-review-skill · v1

Comment thread src/sap_cloud_sdk/aicore/__init__.py
Comment thread src/sap_cloud_sdk/aicore/__init__.py
Comment thread src/sap_cloud_sdk/aicore/__init__.py
Comment thread src/sap_cloud_sdk/aicore/__init__.py
os.environ["AICORE_BASE_URL"] = base_url

resource_group = dest.properties.get("resource_group", "default")
os.environ["AICORE_RESOURCE_GROUP"] = resource_group

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.

Intentional — same write pattern as _configure_direct_mode (lines 275–291). These writes initialise LiteLLM env vars from service binding credentials; secret_resolver is downstream of this layer. Skill updated (FP-Q-01) to skip write assignments.

"and the calling app has the Destination Service technical-user scope."
)

os.environ["AICORE_CLIENT_ID"] = client_id

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.

Intentional — same write pattern as _configure_direct_mode (lines 275–291). These writes initialise LiteLLM env vars from service binding credentials; secret_resolver is downstream of this layer. Skill updated (FP-Q-01) to skip write assignments.

)

os.environ["AICORE_CLIENT_ID"] = client_id
os.environ["AICORE_CLIENT_SECRET"] = client_secret

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.

Intentional — same write pattern as _configure_direct_mode (lines 275–291). These writes initialise LiteLLM env vars from service binding credentials; secret_resolver is downstream of this layer. Skill updated (FP-Q-01) to skip write assignments.

if token_service_url:
if not token_service_url.endswith("/oauth/token"):
token_service_url = token_service_url.rstrip("/") + "/oauth/token"
os.environ["AICORE_AUTH_URL"] = token_service_url

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.

Intentional — same write pattern as _configure_direct_mode (lines 275–291). These writes initialise LiteLLM env vars from service binding credentials; secret_resolver is downstream of this layer. Skill updated (FP-Q-01) to skip write assignments.

"AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name
)
if client_secret:
os.environ["AICORE_CLIENT_SECRET"] = client_secret

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.

Intentional — same write pattern as _configure_direct_mode (lines 275–291). These writes initialise LiteLLM env vars from service binding credentials; secret_resolver is downstream of this layer. Skill updated (FP-Q-01) to skip write assignments.

tiagoek added a commit that referenced this pull request Sep 2, 2026
PY-CON-01 (FP-K-01→02): require ≥3 occurrences on PR-added lines
(not just ≥1) so pre-existing strings don't trigger the check when
only one nearby line was touched.

BND-02 (FP-P-01): skip rstrip+"/oauth/token" concat when the
preceding added line contains an endswith("/oauth/token") guard —
this is the established SDK URL-normalisation pattern used by
_configure_direct_mode.

HC-04 (FP-Q-01): skip os.environ["KEY"] = value (write assignment)
which is intentional in SDK config-init functions; only flag reads.

Adds 6 bats regression tests (FP-CON-01/02, FP-BND-01/02, FP-HC-01/02)
and updates FP-K-01 → FP-K-02 to match new correct semantics.
Introduces two security improvements for AI Core credential handling:

1. Transparent TLS mode (AICORE_TRANSPARENT_TLS=true): when active,
   set_aicore_config() skips writing AICORE_CLIENT_SECRET to os.environ
   and removes any stale value. The infrastructure sidecar proxy adds
   the mTLS certificate transparently on the SDK's behalf — no secret
   material needed in the agent process. Addresses HASI2026203 /
   SEC-309 (credentials exposed as env vars with excessive scope).

2. Reactive credential reload on AuthenticationError: completion() and
   acompletion() now intercept litellm.AuthenticationError, re-read
   credentials from the mounted secret volume, and retry once. Covers
   client_secret rotation and mTLS certificate rotation (cert-manager
   updates the volume file; the next failed token refresh triggers the
   reload) without requiring a pod restart.

Relates-to: AFSDK-4306
…ature

Address reviewer feedback on PR #256:

1. Remove reload_aicore_credentials() wrapper — inline set_aicore_config()
   directly in the except AuthenticationError blocks. The wrapper added a
   named function for a single call; inlining is simpler and clearer.

2. Remove transparent TLS feature (AICORE_TRANSPARENT_TLS env var,
   _is_transparent_tls(), conditional client_secret handling in
   set_aicore_config()). This feature is blocked on an upstream LiteLLM PR
   and is not needed for the credential rotation fix. Nicole flagged that
   it belongs in a future secrets-resolver refactor.

Behavior unchanged: AuthenticationError still triggers set_aicore_config()
+ retry, completely transparent to callers.
- Remove __wrapped__ introspection in test_credential_rotation_flow
  that caused ty call-non-callable error; watcher call is already
  verified via reloaded.wait()
- Fix trailing blank lines in test_aicore.py (end-of-file-fixer)
- Bump version 0.38.0 → 0.41.0 (new public API: watch_aicore_config)
…ce mode

Implements Option 3 from the AFSDK-4306 security alignment meeting: SDK
absorbs all routing complexity so agent code is identical in all environments.
The deployer controls routing by choosing which env vars to inject.

Two new modes in set_aicore_config():

Proxy mode (AICORE_PROXY_URL set):
- Routes all LiteLLM calls through an external LiteLLM proxy
- Sets litellm.api_base / litellm.api_key globally
- Rewrites sap/<model> → litellm_proxy/<model> transparently in
  completion() and acompletion() wrappers (including on auth-error retry)
- No AI Core credentials written to the process environment
- JWT never reaches the agent process (proxy handles OAuth)

Destination mode (AICORE_DESTINATION_NAME set):
- Loads AI Core credentials at startup from a named BTP Destination Service
  destination via the existing sap_cloud_sdk.destination client
- Deployer only injects Destination Service binding — AI Core client_secret
  is never in the K8s Secret, only in BTP Destination Service
- Combined with _clear_client_secret() (PR #257), the secret is removed
  from env after the first successful LiteLLM call

Direct mode (neither set): existing behaviour unchanged, including
transparent TLS (AICORE_TRANSPARENT_TLS).

Adds 30 unit tests covering both new modes and all edge cases.

AFSDK-4306
… mode

Model strings (e.g. sap/<model>) are now passed verbatim to LiteLLM in all
routing modes. LiteLLM natively routes sap/<model> through the configured
litellm.api_base without a prefix rewrite. Removes _rewrite_model_for_proxy(),
_set_proxy_active(), and all associated proxy-aliasing tests (6 unit tests).

Aligns with ADR 0039 which explicitly documents the litellm_proxy/ prefix
approach as a rejected alternative.
The previous name was misleading — the SDK reads the LiteLLM proxy master
API key, not a virtual (per-user/per-team) key. AICORE_PROXY_API_KEY is
accurate for both master key and virtual key usage.

Aligned with Sam Garland (CAD) feedback on ADR 0039 review.
…A tests

Add @record_metrics to _configure_proxy_mode and _configure_destination_mode
(Operations.AICORE_PROXY_MODE / AICORE_DESTINATION_MODE) — fills the same
telemetry gap Jean flagged on PR #256 for the two new routing mode helpers.

Add 5 unit tests in TestProxyModeWithLangGraph covering:
- patch_litellm_for_credential_rotation + proxy mode: 401 reload preserves
  AICORE_PROXY_URL and does not inject AICORE_CLIENT_SECRET
- watcher reload re-enters proxy mode, litellm.api_base stays set
- A2A / ChatLiteLLM direct litellm.completion path via proxy gets reactive
  reload after patching
- destination mode: successive set_aicore_config() calls re-fetch credentials
  from Destination Service (mirrors AFSDK-4306 Val 3, auto-doc-dev-eu12)
- proxy mode never writes AICORE_CLIENT_SECRET to env, even after reload

Update test_operation.py count 161 → 163 (9 aicore ops).
… helpers

_configure_proxy_mode and _configure_destination_mode are internal helpers
called exactly once from set_aicore_config(), which already has its own
@record_metrics decorator. Recording at internal dispatch level adds noise
without observability value.
@tiagoek
tiagoek merged commit 235b585 into main Sep 2, 2026
11 checks passed
@tiagoek
tiagoek deleted the feat/aicore-proxy-routing branch September 2, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants