feat(aicore): transparent proxy routing and BTP Destination Service mode - #271
Conversation
3e9ef43 to
ed315de
Compare
b2f8869 to
b1468fc
Compare
6fa7c3c to
efd2c18
Compare
8e547d8 to
6160f6c
Compare
SDK Module Review
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 |
| os.environ["AICORE_BASE_URL"] = base_url | ||
|
|
||
| resource_group = dest.properties.get("resource_group", "default") | ||
| os.environ["AICORE_RESOURCE_GROUP"] = resource_group |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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.
18969e3 to
3d2c2ac
Compare
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_URLsetRoutes all LiteLLM calls through an external LiteLLM proxy:
litellm.api_baseandlitellm.api_key(proxy API key) globallysap/<model>) are passed verbatim — no prefix rewrite. LiteLLM routes through the configuredapi_basenatively# Deployer injects these; agent code unchanged AICORE_PROXY_URL=https://litellm-proxy.cluster.svc AICORE_PROXY_API_KEY=sk-master-xxxDestination mode —
AICORE_DESTINATION_NAMEsetLoads AI Core credentials at startup from a named BTP Destination Service destination:
sap_cloud_sdk.destinationclient — no new dependenciesAICORE_CLIENT_SECRETnever needs to be in the K8s Secret — it lives in BTP Destination ServiceAuthenticationError, the reactive reload incompletion()callsset_aicore_config(), which re-fetches fresh credentials from the Destination ServiceDirect mode (unchanged)
Neither env var set → existing behaviour: load from mounted K8s secret or env vars.
Agent code — identical in all modes
Security coverage
CLIENT_SECRETin K8s SecretCLIENT_SECRETin process envTests
New unit tests:
TestSetAICoreConfigProxyMode(6 tests) — mode detection, litellm globals, precedence, filtering still appliedTestSetAICoreConfigDestinationMode(9 tests) — URL extraction, credentials, resource group, error casesFull suite: 156 passed, 4 skipped (integration tests requiring real BTP).