From 2ede2ab843459cd5b22e5e5ba0bafbba3240dc32 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Tue, 4 Aug 2026 16:01:34 -0300 Subject: [PATCH 01/14] feat(aicore): add transparent TLS mode and reactive credential reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/sap_cloud_sdk/aicore/__init__.py | 35 ++++++-- tests/aicore/unit/test_aicore.py | 125 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index e335a31d..e715527c 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -31,6 +31,16 @@ logger = logging.getLogger(__name__) +# When set, the infrastructure sidecar adds the mTLS certificate transparently. +# The SDK calls the XSUAA token endpoint over plain HTTPS with only client_id. +# No client_secret or certificate material is required in the service binding. +TRANSPARENT_TLS_ENV_VAR = "AICORE_TRANSPARENT_TLS" + + +def _is_transparent_tls() -> bool: + """Return True when transparent TLS proxy mode is active.""" + return os.environ.get(TRANSPARENT_TLS_ENV_VAR, "").strip().lower() in ("1", "true", "yes") + def _get_secret( env_var_name: str, @@ -124,10 +134,15 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: File mappings based on the Kubernetes secret structure: clientid → AICORE_CLIENT_ID - clientsecret → AICORE_CLIENT_SECRET + clientsecret → AICORE_CLIENT_SECRET (skipped in transparent TLS mode) url → AICORE_AUTH_URL serviceurls (JSON with AI_API_URL) → AICORE_BASE_URL + When ``AICORE_TRANSPARENT_TLS=true`` is set, the infrastructure sidecar + adds the mTLS certificate on the SDK's behalf. In this mode the SDK omits + ``AICORE_CLIENT_SECRET`` from the environment — LiteLLM will use plain + HTTPS to the token endpoint and the sidecar will attach the certificate. + After credentials are loaded, content filtering is activated on every ``sap/*`` LiteLLM call at the configured thresholds (default: severity ``MEDIUM`` on all categories + prompt shield enabled). Override via @@ -136,11 +151,10 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: to turn filtering off at runtime, or set ``AICORE_FILTER_ENABLED=false`` to keep it off entirely. """ + transparent_tls = _is_transparent_tls() + # Load secrets client_id = _get_secret("AICORE_CLIENT_ID", "clientid", instance_name=instance_name) - client_secret = _get_secret( - "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name - ) auth_url = _get_secret("AICORE_AUTH_URL", "url", instance_name=instance_name) base_url = _get_aicore_base_url(instance_name) resource_group = _get_secret( @@ -157,8 +171,6 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: # Set environment variables for LiteLLM if client_id: os.environ["AICORE_CLIENT_ID"] = client_id - if client_secret: - os.environ["AICORE_CLIENT_SECRET"] = client_secret if auth_url: os.environ["AICORE_AUTH_URL"] = auth_url if base_url: @@ -166,6 +178,17 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: if resource_group: os.environ["AICORE_RESOURCE_GROUP"] = resource_group + if transparent_tls: + # Remove any stale client_secret — the sidecar provides the mTLS cert. + os.environ.pop("AICORE_CLIENT_SECRET", None) + logger.info("AI Core transparent TLS mode active — client_secret not required") + else: + client_secret = _get_secret( + "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name + ) + if client_secret: + os.environ["AICORE_CLIENT_SECRET"] = client_secret + # Log configuration completion (excluding sensitive information) logger.info("AI Core configuration has been set successfully") diff --git a/tests/aicore/unit/test_aicore.py b/tests/aicore/unit/test_aicore.py index 5439329c..50acb264 100644 --- a/tests/aicore/unit/test_aicore.py +++ b/tests/aicore/unit/test_aicore.py @@ -10,6 +10,7 @@ _get_secret, set_aicore_config, ) +from sap_cloud_sdk.aicore import _is_transparent_tls class TestGetSecret: @@ -710,3 +711,127 @@ def test_set_config_decorated_with_record_metrics(self): # Function should complete without errors even with decorator # The actual telemetry recording is tested in telemetry tests + + +class TestIsTransparentTls: + """Test suite for _is_transparent_tls helper.""" + + def test_returns_true_for_value_true(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}): + assert _is_transparent_tls() is True + + def test_returns_true_for_value_1(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "1"}): + assert _is_transparent_tls() is True + + def test_returns_true_for_value_yes(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "yes"}): + assert _is_transparent_tls() is True + + def test_returns_true_case_insensitive(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "TRUE"}): + assert _is_transparent_tls() is True + + def test_returns_false_when_absent(self): + with patch.dict("os.environ", {}, clear=True): + assert _is_transparent_tls() is False + + def test_returns_false_for_value_false(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "false"}): + assert _is_transparent_tls() is False + + +class TestSetAICoreConfigTransparentTls: + """Test suite for set_aicore_config in transparent TLS mode.""" + + def _base_secrets(self): + return { + "AICORE_CLIENT_ID": "test-client-id", + "AICORE_AUTH_URL": "https://auth.example.com", + "AICORE_RESOURCE_GROUP": "default", + } + + def test_transparent_tls_does_not_set_client_secret(self): + """In transparent TLS mode, AICORE_CLIENT_SECRET must not be written to env.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value="https://api.example.com"), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + self._base_secrets().get(name, default) + ) + + set_aicore_config() + + assert "AICORE_CLIENT_SECRET" not in os.environ + + def test_transparent_tls_removes_stale_client_secret(self): + """Any pre-existing AICORE_CLIENT_SECRET is cleared in transparent TLS mode.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict( + "os.environ", + {"AICORE_TRANSPARENT_TLS": "true", "AICORE_CLIENT_SECRET": "stale-secret"}, + clear=True, + ), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + self._base_secrets().get(name, default) + ) + + set_aicore_config() + + assert "AICORE_CLIENT_SECRET" not in os.environ + + def test_transparent_tls_sets_other_credentials(self): + """Non-secret credentials are still set in transparent TLS mode.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value="https://api.example.com"), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + self._base_secrets().get(name, default) + ) + + set_aicore_config() + + assert os.environ["AICORE_CLIENT_ID"] == "test-client-id" + assert os.environ["AICORE_AUTH_URL"] == "https://auth.example.com/oauth/token" + assert os.environ["AICORE_BASE_URL"] == "https://api.example.com/v2" + + def test_standard_mode_still_sets_client_secret(self): + """Regression: without transparent TLS, client_secret is still written.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {}, clear=True), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + {**self._base_secrets(), "AICORE_CLIENT_SECRET": "my-secret"}.get(name, default) + ) + + set_aicore_config() + + assert os.environ["AICORE_CLIENT_SECRET"] == "my-secret" + + def test_transparent_tls_does_not_call_get_secret_for_client_secret(self): + """_get_secret should not be called for clientsecret in transparent TLS mode.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), + ): + mock_get_secret.return_value = "" + + set_aicore_config() + + called_names = [c.args[0] for c in mock_get_secret.call_args_list] + assert "AICORE_CLIENT_SECRET" not in called_names From 304accdfeba9c75409473754ed924a5841a4610b Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Tue, 25 Aug 2026 15:00:02 -0300 Subject: [PATCH 02/14] refactor(aicore): inline credential reload, remove transparent TLS feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/sap_cloud_sdk/aicore/__init__.py | 35 ++------ tests/aicore/unit/test_aicore.py | 123 --------------------------- 2 files changed, 6 insertions(+), 152 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index e715527c..14e7c819 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -31,16 +31,6 @@ logger = logging.getLogger(__name__) -# When set, the infrastructure sidecar adds the mTLS certificate transparently. -# The SDK calls the XSUAA token endpoint over plain HTTPS with only client_id. -# No client_secret or certificate material is required in the service binding. -TRANSPARENT_TLS_ENV_VAR = "AICORE_TRANSPARENT_TLS" - - -def _is_transparent_tls() -> bool: - """Return True when transparent TLS proxy mode is active.""" - return os.environ.get(TRANSPARENT_TLS_ENV_VAR, "").strip().lower() in ("1", "true", "yes") - def _get_secret( env_var_name: str, @@ -134,15 +124,10 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: File mappings based on the Kubernetes secret structure: clientid → AICORE_CLIENT_ID - clientsecret → AICORE_CLIENT_SECRET (skipped in transparent TLS mode) + clientsecret → AICORE_CLIENT_SECRET url → AICORE_AUTH_URL serviceurls (JSON with AI_API_URL) → AICORE_BASE_URL - When ``AICORE_TRANSPARENT_TLS=true`` is set, the infrastructure sidecar - adds the mTLS certificate on the SDK's behalf. In this mode the SDK omits - ``AICORE_CLIENT_SECRET`` from the environment — LiteLLM will use plain - HTTPS to the token endpoint and the sidecar will attach the certificate. - After credentials are loaded, content filtering is activated on every ``sap/*`` LiteLLM call at the configured thresholds (default: severity ``MEDIUM`` on all categories + prompt shield enabled). Override via @@ -151,8 +136,6 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: to turn filtering off at runtime, or set ``AICORE_FILTER_ENABLED=false`` to keep it off entirely. """ - transparent_tls = _is_transparent_tls() - # Load secrets client_id = _get_secret("AICORE_CLIENT_ID", "clientid", instance_name=instance_name) auth_url = _get_secret("AICORE_AUTH_URL", "url", instance_name=instance_name) @@ -160,6 +143,9 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: resource_group = _get_secret( "AICORE_RESOURCE_GROUP", default="default", instance_name=instance_name ) + client_secret = _get_secret( + "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name + ) # Ensure AICORE_AUTH_URL has /oauth/token suffix if auth_url and not auth_url.endswith("/oauth/token"): @@ -177,17 +163,8 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: os.environ["AICORE_BASE_URL"] = base_url if resource_group: os.environ["AICORE_RESOURCE_GROUP"] = resource_group - - if transparent_tls: - # Remove any stale client_secret — the sidecar provides the mTLS cert. - os.environ.pop("AICORE_CLIENT_SECRET", None) - logger.info("AI Core transparent TLS mode active — client_secret not required") - else: - client_secret = _get_secret( - "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name - ) - if client_secret: - os.environ["AICORE_CLIENT_SECRET"] = client_secret + if client_secret: + os.environ["AICORE_CLIENT_SECRET"] = client_secret # Log configuration completion (excluding sensitive information) logger.info("AI Core configuration has been set successfully") diff --git a/tests/aicore/unit/test_aicore.py b/tests/aicore/unit/test_aicore.py index 50acb264..03248c62 100644 --- a/tests/aicore/unit/test_aicore.py +++ b/tests/aicore/unit/test_aicore.py @@ -10,7 +10,6 @@ _get_secret, set_aicore_config, ) -from sap_cloud_sdk.aicore import _is_transparent_tls class TestGetSecret: @@ -713,125 +712,3 @@ def test_set_config_decorated_with_record_metrics(self): # The actual telemetry recording is tested in telemetry tests -class TestIsTransparentTls: - """Test suite for _is_transparent_tls helper.""" - - def test_returns_true_for_value_true(self): - with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}): - assert _is_transparent_tls() is True - - def test_returns_true_for_value_1(self): - with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "1"}): - assert _is_transparent_tls() is True - - def test_returns_true_for_value_yes(self): - with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "yes"}): - assert _is_transparent_tls() is True - - def test_returns_true_case_insensitive(self): - with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "TRUE"}): - assert _is_transparent_tls() is True - - def test_returns_false_when_absent(self): - with patch.dict("os.environ", {}, clear=True): - assert _is_transparent_tls() is False - - def test_returns_false_for_value_false(self): - with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "false"}): - assert _is_transparent_tls() is False - - -class TestSetAICoreConfigTransparentTls: - """Test suite for set_aicore_config in transparent TLS mode.""" - - def _base_secrets(self): - return { - "AICORE_CLIENT_ID": "test-client-id", - "AICORE_AUTH_URL": "https://auth.example.com", - "AICORE_RESOURCE_GROUP": "default", - } - - def test_transparent_tls_does_not_set_client_secret(self): - """In transparent TLS mode, AICORE_CLIENT_SECRET must not be written to env.""" - with ( - patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, - patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value="https://api.example.com"), - patch("sap_cloud_sdk.aicore.set_filtering"), - patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), - ): - mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( - self._base_secrets().get(name, default) - ) - - set_aicore_config() - - assert "AICORE_CLIENT_SECRET" not in os.environ - - def test_transparent_tls_removes_stale_client_secret(self): - """Any pre-existing AICORE_CLIENT_SECRET is cleared in transparent TLS mode.""" - with ( - patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, - patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), - patch("sap_cloud_sdk.aicore.set_filtering"), - patch.dict( - "os.environ", - {"AICORE_TRANSPARENT_TLS": "true", "AICORE_CLIENT_SECRET": "stale-secret"}, - clear=True, - ), - ): - mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( - self._base_secrets().get(name, default) - ) - - set_aicore_config() - - assert "AICORE_CLIENT_SECRET" not in os.environ - - def test_transparent_tls_sets_other_credentials(self): - """Non-secret credentials are still set in transparent TLS mode.""" - with ( - patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, - patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value="https://api.example.com"), - patch("sap_cloud_sdk.aicore.set_filtering"), - patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), - ): - mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( - self._base_secrets().get(name, default) - ) - - set_aicore_config() - - assert os.environ["AICORE_CLIENT_ID"] == "test-client-id" - assert os.environ["AICORE_AUTH_URL"] == "https://auth.example.com/oauth/token" - assert os.environ["AICORE_BASE_URL"] == "https://api.example.com/v2" - - def test_standard_mode_still_sets_client_secret(self): - """Regression: without transparent TLS, client_secret is still written.""" - with ( - patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, - patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), - patch("sap_cloud_sdk.aicore.set_filtering"), - patch.dict("os.environ", {}, clear=True), - ): - mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( - {**self._base_secrets(), "AICORE_CLIENT_SECRET": "my-secret"}.get(name, default) - ) - - set_aicore_config() - - assert os.environ["AICORE_CLIENT_SECRET"] == "my-secret" - - def test_transparent_tls_does_not_call_get_secret_for_client_secret(self): - """_get_secret should not be called for clientsecret in transparent TLS mode.""" - with ( - patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, - patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), - patch("sap_cloud_sdk.aicore.set_filtering"), - patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), - ): - mock_get_secret.return_value = "" - - set_aicore_config() - - called_names = [c.args[0] for c in mock_get_secret.call_args_list] - assert "AICORE_CLIENT_SECRET" not in called_names From c4bcb2d704c84943de634351ac68031ba6fb49bc Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 26 Aug 2026 11:02:07 -0300 Subject: [PATCH 03/14] fix(aicore): fix ty type error, trailing newlines, version bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- tests/aicore/unit/test_aicore.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/aicore/unit/test_aicore.py b/tests/aicore/unit/test_aicore.py index 03248c62..5439329c 100644 --- a/tests/aicore/unit/test_aicore.py +++ b/tests/aicore/unit/test_aicore.py @@ -710,5 +710,3 @@ def test_set_config_decorated_with_record_metrics(self): # Function should complete without errors even with decorator # The actual telemetry recording is tested in telemetry tests - - From 9f3aac03cc25028cd0c8c015383d42927b4827bf Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 26 Aug 2026 11:03:57 -0300 Subject: [PATCH 04/14] refactor(aicore): restore original client_secret ordering in set_aicore_config --- src/sap_cloud_sdk/aicore/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 14e7c819..e335a31d 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -138,14 +138,14 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: """ # Load secrets client_id = _get_secret("AICORE_CLIENT_ID", "clientid", instance_name=instance_name) + client_secret = _get_secret( + "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name + ) auth_url = _get_secret("AICORE_AUTH_URL", "url", instance_name=instance_name) base_url = _get_aicore_base_url(instance_name) resource_group = _get_secret( "AICORE_RESOURCE_GROUP", default="default", instance_name=instance_name ) - client_secret = _get_secret( - "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name - ) # Ensure AICORE_AUTH_URL has /oauth/token suffix if auth_url and not auth_url.endswith("/oauth/token"): @@ -157,14 +157,14 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: # Set environment variables for LiteLLM if client_id: os.environ["AICORE_CLIENT_ID"] = client_id + if client_secret: + os.environ["AICORE_CLIENT_SECRET"] = client_secret if auth_url: os.environ["AICORE_AUTH_URL"] = auth_url if base_url: os.environ["AICORE_BASE_URL"] = base_url if resource_group: os.environ["AICORE_RESOURCE_GROUP"] = resource_group - if client_secret: - os.environ["AICORE_CLIENT_SECRET"] = client_secret # Log configuration completion (excluding sensitive information) logger.info("AI Core configuration has been set successfully") From 627a2b154acd815c47a2e19bd92d7eac4cb46227 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Mon, 17 Aug 2026 10:24:31 -0300 Subject: [PATCH 05/14] feat(aicore): add transparent proxy routing and BTP Destination Service mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ → litellm_proxy/ 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 --- src/sap_cloud_sdk/aicore/__init__.py | 160 +++++++++-- src/sap_cloud_sdk/aicore/completion.py | 39 ++- tests/aicore/unit/test_aicore.py | 355 +++++++++++++++++++++++++ tests/aicore/unit/test_completion.py | 134 ++++++++++ 4 files changed, 661 insertions(+), 27 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index e335a31d..666467c0 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -14,7 +14,7 @@ from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics from sap_cloud_sdk.core.telemetry.module import Module from sap_cloud_sdk.core.telemetry.operation import Operation -from .completion import acompletion, completion +from .completion import acompletion, completion, _set_proxy_active from .filtering import ( AzureContentFilter, ContentFilter, @@ -31,6 +31,22 @@ logger = logging.getLogger(__name__) +# When set, the infrastructure sidecar adds the mTLS certificate transparently. +# The SDK calls the XSUAA token endpoint over plain HTTPS with only client_id. +# No client_secret or certificate material is required in the service binding. +TRANSPARENT_TLS_ENV_VAR = "AICORE_TRANSPARENT_TLS" + +# Option 3 — transparent proxy routing. +# Deployer injects these; agent code is identical in all environments. +_PROXY_URL_ENV = "AICORE_PROXY_URL" +_PROXY_VIRTUAL_KEY_ENV = "AICORE_PROXY_VIRTUAL_KEY" +_DESTINATION_NAME_ENV = "AICORE_DESTINATION_NAME" + + +def _is_transparent_tls() -> bool: + """Return True when transparent TLS proxy mode is active.""" + return os.environ.get(TRANSPARENT_TLS_ENV_VAR, "").strip().lower() in ("1", "true", "yes") + def _get_secret( env_var_name: str, @@ -119,14 +135,25 @@ def _get_aicore_base_url(instance_name: str = "aicore-instance") -> str: def set_aicore_config(instance_name: str = "aicore-instance") -> None: """Load AI Core credentials and activate content filtering. - Loads secrets from files or environment variables and sets them as - process env vars so ``litellm`` picks them up. + Detects which routing mode is active based on environment variables: + + - ``AICORE_PROXY_URL`` set → **proxy mode**: routes all LiteLLM calls + through a LiteLLM proxy; ``sap/`` is aliased to + ``litellm_proxy/`` transparently. No AI Core credentials + are written to the process environment. + + - ``AICORE_DESTINATION_NAME`` set → **destination mode**: loads AI Core + credentials from a BTP Destination Service destination at startup. + The deployer only needs to inject Destination Service binding credentials; + the AI Core ``client_secret`` never needs to be in the K8s Secret. - File mappings based on the Kubernetes secret structure: - clientid → AICORE_CLIENT_ID - clientsecret → AICORE_CLIENT_SECRET - url → AICORE_AUTH_URL - serviceurls (JSON with AI_API_URL) → AICORE_BASE_URL + - Neither set → **direct mode** (existing behaviour): credentials are + loaded from a mounted K8s secret volume or environment variables. + ``AICORE_TRANSPARENT_TLS=true`` suppresses ``client_secret`` and + relies on an mTLS sidecar. + + Agent code is identical in all three modes — the deployer controls + routing by choosing which env vars to inject. After credentials are loaded, content filtering is activated on every ``sap/*`` LiteLLM call at the configured thresholds (default: severity @@ -136,29 +163,113 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: to turn filtering off at runtime, or set ``AICORE_FILTER_ENABLED=false`` to keep it off entirely. """ - # Load secrets + proxy_url = os.environ.get(_PROXY_URL_ENV, "") + destination_name = os.environ.get(_DESTINATION_NAME_ENV, "") + + if proxy_url: + _configure_proxy_mode(proxy_url) + elif destination_name: + _configure_destination_mode(destination_name) + else: + _configure_direct_mode(instance_name) + + set_filtering() + + +def _configure_proxy_mode(proxy_url: str) -> None: + """Configure LiteLLM to route calls through an external proxy. + + Sets ``litellm.api_base`` / ``litellm.api_key`` globally and activates + the ``sap/`` → ``litellm_proxy/`` model alias rewrite in the + completion wrappers. No AI Core credentials are written to env. + """ + import litellm as _litellm + + virtual_key = os.environ.get(_PROXY_VIRTUAL_KEY_ENV, "") + _litellm.api_base = proxy_url + if virtual_key: + _litellm.api_key = virtual_key + _set_proxy_active(True) + logger.info("AI Core proxy mode active — routing via %s", proxy_url) + + +def _configure_destination_mode(name: str) -> None: + """Load AI Core credentials from a BTP Destination Service destination. + + Calls the Destination Service at startup to resolve the named destination + and extracts ``clientId``, ``clientSecret``, ``tokenServiceURL``, and the + AI Core ``URL`` from the destination configuration properties. These are + written to the standard ``AICORE_*`` env vars so that LiteLLM can fetch + an OAuth token from XSUAA as usual. + + Security: The deployer does NOT need to inject ``AICORE_CLIENT_SECRET`` + directly — only Destination Service binding credentials are required in + the agent environment. + + Raises ``RuntimeError`` if the destination is not found or does not + return ``clientId`` / ``clientSecret``. + """ + from sap_cloud_sdk.destination import create_client # lazy import + + client = create_client() + dest = client.get_destination(name) + + if dest is None: + raise RuntimeError( + f"AI Core destination '{name}' not found in Destination Service. " + "Check that the destination exists and the binding has access." + ) + + base_url = dest.url or "" + if base_url and not base_url.endswith("/v2"): + base_url = base_url.rstrip("/") + "/v2" + if base_url: + os.environ["AICORE_BASE_URL"] = base_url + + resource_group = dest.properties.get("resource_group", "default") + os.environ["AICORE_RESOURCE_GROUP"] = resource_group + + client_id = dest.properties.get("clientId", "") + client_secret = dest.properties.get("clientSecret", "") + token_service_url = dest.properties.get("tokenServiceURL", "") + + if not client_id or not client_secret: + raise RuntimeError( + f"Destination '{name}' did not return clientId/clientSecret. " + "Ensure the destination uses OAuth2ClientCredentials authentication " + "and the calling app has the Destination Service technical-user scope." + ) + + os.environ["AICORE_CLIENT_ID"] = client_id + os.environ["AICORE_CLIENT_SECRET"] = client_secret + + 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 + + logger.info("AI Core destination mode active — credentials loaded from '%s'", name) + + +def _configure_direct_mode(instance_name: str) -> None: + """Load AI Core credentials directly from mounted secrets or env vars.""" + transparent_tls = _is_transparent_tls() + client_id = _get_secret("AICORE_CLIENT_ID", "clientid", instance_name=instance_name) - client_secret = _get_secret( - "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name - ) auth_url = _get_secret("AICORE_AUTH_URL", "url", instance_name=instance_name) base_url = _get_aicore_base_url(instance_name) resource_group = _get_secret( "AICORE_RESOURCE_GROUP", default="default", instance_name=instance_name ) - # Ensure AICORE_AUTH_URL has /oauth/token suffix if auth_url and not auth_url.endswith("/oauth/token"): auth_url = auth_url.rstrip("/") + "/oauth/token" if base_url and not base_url.endswith("/v2"): base_url = base_url.rstrip("/") + "/v2" - # Set environment variables for LiteLLM if client_id: os.environ["AICORE_CLIENT_ID"] = client_id - if client_secret: - os.environ["AICORE_CLIENT_SECRET"] = client_secret if auth_url: os.environ["AICORE_AUTH_URL"] = auth_url if base_url: @@ -166,14 +277,17 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: if resource_group: os.environ["AICORE_RESOURCE_GROUP"] = resource_group - # Log configuration completion (excluding sensitive information) - logger.info("AI Core configuration has been set successfully") + if transparent_tls: + os.environ.pop("AICORE_CLIENT_SECRET", None) + logger.info("AI Core transparent TLS mode active — client_secret not required") + else: + client_secret = _get_secret( + "AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name + ) + if client_secret: + os.environ["AICORE_CLIENT_SECRET"] = client_secret - # Activate content filtering for all sap/* LiteLLM model calls. - # AICORE_FILTER_ENABLED=false disables; AICORE_FILTER_* tune thresholds. - # Errors propagate — filtering misconfiguration should surface at startup - # rather than be swallowed silently. - set_filtering() + logger.info("AI Core configuration has been set successfully") def _get_secret_dir_mtime(instance_name: str = "aicore-instance") -> float: diff --git a/src/sap_cloud_sdk/aicore/completion.py b/src/sap_cloud_sdk/aicore/completion.py index 62a1fe87..293f9cf8 100644 --- a/src/sap_cloud_sdk/aicore/completion.py +++ b/src/sap_cloud_sdk/aicore/completion.py @@ -50,6 +50,7 @@ from __future__ import annotations import logging +import threading from typing import Any import litellm @@ -61,6 +62,26 @@ logger = logging.getLogger(__name__) +# Proxy mode state — set by _configure_proxy_mode() in __init__.py. +# When active, completion() rewrites sap/ → litellm_proxy/. +_proxy_lock = threading.Lock() +_proxy_active: bool = False + + +def _set_proxy_active(value: bool) -> None: + """Activate or deactivate proxy model aliasing (called by set_aicore_config).""" + global _proxy_active + with _proxy_lock: + _proxy_active = value + + +def _rewrite_model_for_proxy(kwargs: dict) -> dict: + """Rewrite sap/ to litellm_proxy/ when proxy mode is active.""" + model = kwargs.get("model", "") + if isinstance(model, str) and model.startswith("sap/"): + return {**kwargs, "model": "litellm_proxy/" + model[4:]} + return kwargs + @record_metrics(Module.AICORE, Operation.AICORE_REACTIVE_RELOAD) def _reload_reactive() -> None: @@ -88,10 +109,16 @@ def completion(*args: Any, **kwargs: Any) -> Any: """Wrapper around :func:`litellm.completion` that normalises filter errors and handles credential rotation transparently. - On ``AuthenticationError`` (e.g. rotated client_secret), reloads - credentials from the mounted secret volume and retries once. - All other exceptions surface verbatim after the filter-error translation. + On ``AuthenticationError`` (e.g. rotated client_secret or mTLS cert), + reloads credentials from the mounted secret volume and retries once. + + When proxy mode is active (``AICORE_PROXY_URL`` set), rewrites + ``sap/`` to ``litellm_proxy/`` transparently. """ + with _proxy_lock: + proxy = _proxy_active + if proxy: + kwargs = _rewrite_model_for_proxy(kwargs) try: return litellm.completion(*args, **kwargs) except litellm.AuthenticationError: @@ -107,8 +134,12 @@ def completion(*args: Any, **kwargs: Any) -> Any: async def acompletion(*args: Any, **kwargs: Any) -> Any: """Async wrapper around :func:`litellm.acompletion`. - Same translation and credential-rotation semantics as :func:`completion`. + Same credential-rotation and proxy aliasing semantics as :func:`completion`. """ + with _proxy_lock: + proxy = _proxy_active + if proxy: + kwargs = _rewrite_model_for_proxy(kwargs) try: return await litellm.acompletion(*args, **kwargs) except litellm.AuthenticationError: diff --git a/tests/aicore/unit/test_aicore.py b/tests/aicore/unit/test_aicore.py index 5439329c..9a3fee49 100644 --- a/tests/aicore/unit/test_aicore.py +++ b/tests/aicore/unit/test_aicore.py @@ -4,6 +4,7 @@ import os from unittest.mock import mock_open, patch +import pytest from sap_cloud_sdk.aicore import ( _get_aicore_base_url, @@ -710,3 +711,357 @@ def test_set_config_decorated_with_record_metrics(self): # Function should complete without errors even with decorator # The actual telemetry recording is tested in telemetry tests + + +class TestIsTransparentTls: + """Test suite for _is_transparent_tls helper.""" + + def test_returns_true_for_value_true(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}): + assert _is_transparent_tls() is True + + def test_returns_true_for_value_1(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "1"}): + assert _is_transparent_tls() is True + + def test_returns_true_for_value_yes(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "yes"}): + assert _is_transparent_tls() is True + + def test_returns_true_case_insensitive(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "TRUE"}): + assert _is_transparent_tls() is True + + def test_returns_false_when_absent(self): + with patch.dict("os.environ", {}, clear=True): + assert _is_transparent_tls() is False + + def test_returns_false_for_value_false(self): + with patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "false"}): + assert _is_transparent_tls() is False + + +class TestSetAICoreConfigTransparentTls: + """Test suite for set_aicore_config in transparent TLS mode.""" + + def _base_secrets(self): + return { + "AICORE_CLIENT_ID": "test-client-id", + "AICORE_AUTH_URL": "https://auth.example.com", + "AICORE_RESOURCE_GROUP": "default", + } + + def test_transparent_tls_does_not_set_client_secret(self): + """In transparent TLS mode, AICORE_CLIENT_SECRET must not be written to env.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value="https://api.example.com"), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + self._base_secrets().get(name, default) + ) + + set_aicore_config() + + assert "AICORE_CLIENT_SECRET" not in os.environ + + def test_transparent_tls_removes_stale_client_secret(self): + """Any pre-existing AICORE_CLIENT_SECRET is cleared in transparent TLS mode.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict( + "os.environ", + {"AICORE_TRANSPARENT_TLS": "true", "AICORE_CLIENT_SECRET": "stale-secret"}, + clear=True, + ), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + self._base_secrets().get(name, default) + ) + + set_aicore_config() + + assert "AICORE_CLIENT_SECRET" not in os.environ + + def test_transparent_tls_sets_other_credentials(self): + """Non-secret credentials are still set in transparent TLS mode.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value="https://api.example.com"), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + self._base_secrets().get(name, default) + ) + + set_aicore_config() + + assert os.environ["AICORE_CLIENT_ID"] == "test-client-id" + assert os.environ["AICORE_AUTH_URL"] == "https://auth.example.com/oauth/token" + assert os.environ["AICORE_BASE_URL"] == "https://api.example.com/v2" + + def test_standard_mode_still_sets_client_secret(self): + """Regression: without transparent TLS, client_secret is still written.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {}, clear=True), + ): + mock_get_secret.side_effect = lambda name, file_name=None, default="", instance_name="aicore-instance": ( + {**self._base_secrets(), "AICORE_CLIENT_SECRET": "my-secret"}.get(name, default) + ) + + set_aicore_config() + + assert os.environ["AICORE_CLIENT_SECRET"] == "my-secret" + + def test_transparent_tls_does_not_call_get_secret_for_client_secret(self): + """_get_secret should not be called for clientsecret in transparent TLS mode.""" + with ( + patch("sap_cloud_sdk.aicore._get_secret") as mock_get_secret, + patch("sap_cloud_sdk.aicore._get_aicore_base_url", return_value=""), + patch("sap_cloud_sdk.aicore.set_filtering"), + patch.dict("os.environ", {"AICORE_TRANSPARENT_TLS": "true"}, clear=True), + ): + mock_get_secret.return_value = "" + + set_aicore_config() + + called_names = [c.args[0] for c in mock_get_secret.call_args_list] + assert "AICORE_CLIENT_SECRET" not in called_names + + +# --------------------------------------------------------------------------- +# Proxy mode — set_aicore_config() with AICORE_PROXY_URL +# --------------------------------------------------------------------------- + + +class TestSetAICoreConfigProxyMode: + """set_aicore_config() routes via proxy when AICORE_PROXY_URL is present.""" + + def _base_proxy_env(self, **extra): + return {"AICORE_PROXY_URL": "https://proxy.example.com", **extra} + + def test_proxy_mode_sets_litellm_api_base(self): + import litellm + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.aicore._set_proxy_active"), + patch.dict("os.environ", self._base_proxy_env(), clear=True), + ): + set_aicore_config() + assert litellm.api_base == "https://proxy.example.com" + litellm.api_base = None # cleanup + + def test_proxy_mode_sets_litellm_api_key_when_virtual_key_present(self): + import litellm + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.aicore._set_proxy_active"), + patch.dict( + "os.environ", + self._base_proxy_env(AICORE_PROXY_VIRTUAL_KEY="sk-virt-123"), + clear=True, + ), + ): + set_aicore_config() + assert litellm.api_key == "sk-virt-123" + litellm.api_key = None # cleanup + + def test_proxy_mode_activates_proxy_flag(self): + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.aicore._set_proxy_active") as mock_set_proxy, + patch.dict("os.environ", self._base_proxy_env(), clear=True), + ): + set_aicore_config() + mock_set_proxy.assert_called_once_with(True) + + def test_proxy_mode_does_not_write_aicore_credentials(self): + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.aicore._set_proxy_active"), + patch.dict("os.environ", self._base_proxy_env(), clear=True), + ): + set_aicore_config() + for var in ("AICORE_CLIENT_ID", "AICORE_CLIENT_SECRET", "AICORE_AUTH_URL"): + assert var not in os.environ, f"{var} must not be written in proxy mode" + + def test_proxy_mode_takes_precedence_over_destination(self): + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.aicore._set_proxy_active") as mock_set_proxy, + patch("sap_cloud_sdk.aicore._configure_destination_mode") as mock_dest, + patch.dict( + "os.environ", + self._base_proxy_env(AICORE_DESTINATION_NAME="aicore"), + clear=True, + ), + ): + set_aicore_config() + mock_set_proxy.assert_called_once_with(True) + mock_dest.assert_not_called() + + def test_proxy_mode_still_calls_set_filtering(self): + with ( + patch("sap_cloud_sdk.aicore.set_filtering") as mock_filter, + patch("sap_cloud_sdk.aicore._set_proxy_active"), + patch.dict("os.environ", self._base_proxy_env(), clear=True), + ): + set_aicore_config() + mock_filter.assert_called_once() + + def test_direct_mode_used_when_neither_proxy_nor_destination_set(self): + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.aicore._configure_direct_mode") as mock_direct, + patch.dict("os.environ", {}, clear=True), + ): + set_aicore_config() + mock_direct.assert_called_once() + + +# --------------------------------------------------------------------------- +# Destination mode — set_aicore_config() with AICORE_DESTINATION_NAME +# --------------------------------------------------------------------------- + + +class TestSetAICoreConfigDestinationMode: + """set_aicore_config() loads credentials from BTP Destination Service.""" + + def _mock_destination( + self, + url="https://api.ai.prod.example.com", + properties=None, + auth_tokens=None, + ): + from unittest.mock import MagicMock + dest = MagicMock() + dest.url = url + dest.properties = properties or { + "clientId": "sb-client-id", + "clientSecret": "client-secret-value", + "tokenServiceURL": "https://auth.example.com/oauth/token", + } + dest.auth_tokens = auth_tokens or [] + return dest + + def test_destination_mode_sets_base_url_with_v2_suffix(self): + dest = self._mock_destination(url="https://api.ai.prod.example.com") + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch( + "sap_cloud_sdk.destination.create_client" + ) as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + set_aicore_config() + assert os.environ["AICORE_BASE_URL"] == "https://api.ai.prod.example.com/v2" + + def test_destination_mode_does_not_double_v2(self): + dest = self._mock_destination(url="https://api.ai.prod.example.com/v2") + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + set_aicore_config() + assert os.environ["AICORE_BASE_URL"] == "https://api.ai.prod.example.com/v2" + + def test_destination_mode_sets_resource_group_from_properties(self): + dest = self._mock_destination( + properties={ + "clientId": "id", + "clientSecret": "sec", + "tokenServiceURL": "https://auth.example.com/oauth/token", + "resource_group": "production", + } + ) + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + set_aicore_config() + assert os.environ["AICORE_RESOURCE_GROUP"] == "production" + + def test_destination_mode_defaults_resource_group_to_default(self): + dest = self._mock_destination() # no resource_group in properties + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + set_aicore_config() + assert os.environ["AICORE_RESOURCE_GROUP"] == "default" + + def test_destination_mode_sets_client_credentials(self): + dest = self._mock_destination() + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + set_aicore_config() + assert os.environ["AICORE_CLIENT_ID"] == "sb-client-id" + assert os.environ["AICORE_CLIENT_SECRET"] == "client-secret-value" + + def test_destination_mode_appends_oauth_token_suffix(self): + dest = self._mock_destination( + properties={ + "clientId": "id", + "clientSecret": "sec", + "tokenServiceURL": "https://auth.example.com", # no /oauth/token + } + ) + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + set_aicore_config() + assert os.environ["AICORE_AUTH_URL"] == "https://auth.example.com/oauth/token" + + def test_destination_mode_raises_when_destination_not_found(self): + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "missing"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = None + with pytest.raises(RuntimeError, match="not found"): + set_aicore_config() + + def test_destination_mode_raises_when_no_client_credentials(self): + dest = self._mock_destination(properties={"resource_group": "default"}) + with ( + patch("sap_cloud_sdk.aicore.set_filtering"), + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + with pytest.raises(RuntimeError, match="clientId/clientSecret"): + set_aicore_config() + + def test_destination_mode_still_calls_set_filtering(self): + dest = self._mock_destination() + with ( + patch("sap_cloud_sdk.aicore.set_filtering") as mock_filter, + patch("sap_cloud_sdk.destination.create_client") as mock_create, + patch.dict("os.environ", {"AICORE_DESTINATION_NAME": "aicore"}, clear=True), + ): + mock_create.return_value.get_destination.return_value = dest + set_aicore_config() + mock_filter.assert_called_once() diff --git a/tests/aicore/unit/test_completion.py b/tests/aicore/unit/test_completion.py index 8238e922..ecaebd3f 100644 --- a/tests/aicore/unit/test_completion.py +++ b/tests/aicore/unit/test_completion.py @@ -317,3 +317,137 @@ async def fake_acompletion(*args, **kwargs): ): with pytest.raises(litellm.AuthenticationError): asyncio.run(acompletion(model="sap/x", messages=[])) + + +# --------------------------------------------------------------------------- +# Proxy mode — model aliasing in completion() / acompletion() +# --------------------------------------------------------------------------- + + +class TestCompletionProxyModeAliasing: + """completion() rewrites sap/ → litellm_proxy/ when proxy is active.""" + + def setup_method(self): + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(False) + + def teardown_method(self): + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(False) + + def test_sap_model_rewritten_when_proxy_active(self): + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(True) + sentinel = object() + captured = {} + + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + side_effect=lambda *a, **kw: captured.update(kw) or sentinel, + ): + result = completion(model="sap/gpt-4o", messages=[]) + + assert result is sentinel + assert captured["model"] == "litellm_proxy/gpt-4o" + + def test_model_unchanged_when_proxy_not_active(self): + sentinel = object() + captured = {} + + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + side_effect=lambda *a, **kw: captured.update(kw) or sentinel, + ): + result = completion(model="sap/gpt-4o", messages=[]) + + assert result is sentinel + assert captured["model"] == "sap/gpt-4o" + + def test_non_sap_model_unchanged_even_when_proxy_active(self): + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(True) + sentinel = object() + captured = {} + + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + side_effect=lambda *a, **kw: captured.update(kw) or sentinel, + ): + result = completion(model="openai/gpt-4o", messages=[]) + + assert result is sentinel + assert captured["model"] == "openai/gpt-4o" + + def test_proxy_rewrite_on_auth_error_retry(self): + """Model aliasing is applied on both the initial call and the retry.""" + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(True) + + sentinel = object() + auth_err = litellm.AuthenticationError( + message="401", llm_provider="sap", model="sap/x" + ) + call_models = [] + + def fake_completion(*args, **kwargs): + call_models.append(kwargs.get("model")) + if len(call_models) == 1: + raise auth_err + return sentinel + + with ( + patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=fake_completion), + patch("sap_cloud_sdk.aicore.set_aicore_config"), + ): + result = completion(model="sap/gpt-4o", messages=[]) + + assert result is sentinel + assert call_models == ["litellm_proxy/gpt-4o", "litellm_proxy/gpt-4o"] + + +class TestACompletionProxyModeAliasing: + """acompletion() proxy aliasing — async path.""" + + def setup_method(self): + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(False) + + def teardown_method(self): + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(False) + + def test_sap_model_rewritten_when_proxy_active(self): + from sap_cloud_sdk.aicore.completion import _set_proxy_active + _set_proxy_active(True) + sentinel = object() + captured = {} + + async def fake_acompletion(*args, **kwargs): + captured.update(kwargs) + return sentinel + + with patch( + "sap_cloud_sdk.aicore.completion.litellm.acompletion", + side_effect=fake_acompletion, + ): + result = asyncio.run(acompletion(model="sap/gpt-4o", messages=[])) + + assert result is sentinel + assert captured["model"] == "litellm_proxy/gpt-4o" + + def test_model_unchanged_when_proxy_not_active(self): + sentinel = object() + captured = {} + + async def fake_acompletion(*args, **kwargs): + captured.update(kwargs) + return sentinel + + with patch( + "sap_cloud_sdk.aicore.completion.litellm.acompletion", + side_effect=fake_acompletion, + ): + result = asyncio.run(acompletion(model="sap/gpt-4o", messages=[])) + + assert result is sentinel + assert captured["model"] == "sap/gpt-4o" From 9ce93ec6e827bf03f0772deab6f77ef218bf9902 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 21 Aug 2026 10:33:33 -0300 Subject: [PATCH 06/14] =?UTF-8?q?refactor(aicore):=20remove=20sap/=20?= =?UTF-8?q?=E2=86=92=20litellm=5Fproxy/=20model=20rewrite=20in=20proxy=20m?= =?UTF-8?q?ode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model strings (e.g. sap/) are now passed verbatim to LiteLLM in all routing modes. LiteLLM natively routes sap/ 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. --- src/sap_cloud_sdk/aicore/__init__.py | 9 +- src/sap_cloud_sdk/aicore/completion.py | 37 +------ tests/aicore/unit/test_aicore.py | 15 --- tests/aicore/unit/test_completion.py | 132 ------------------------- 4 files changed, 9 insertions(+), 184 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 666467c0..69ac4222 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -14,7 +14,7 @@ from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics from sap_cloud_sdk.core.telemetry.module import Module from sap_cloud_sdk.core.telemetry.operation import Operation -from .completion import acompletion, completion, _set_proxy_active +from .completion import acompletion, completion from .filtering import ( AzureContentFilter, ContentFilter, @@ -179,9 +179,9 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: def _configure_proxy_mode(proxy_url: str) -> None: """Configure LiteLLM to route calls through an external proxy. - Sets ``litellm.api_base`` / ``litellm.api_key`` globally and activates - the ``sap/`` → ``litellm_proxy/`` model alias rewrite in the - completion wrappers. No AI Core credentials are written to env. + Sets ``litellm.api_base`` / ``litellm.api_key`` globally. + Model strings (e.g. ``sap/``) are passed verbatim — no rewrite. + No AI Core credentials are written to env. """ import litellm as _litellm @@ -189,7 +189,6 @@ def _configure_proxy_mode(proxy_url: str) -> None: _litellm.api_base = proxy_url if virtual_key: _litellm.api_key = virtual_key - _set_proxy_active(True) logger.info("AI Core proxy mode active — routing via %s", proxy_url) diff --git a/src/sap_cloud_sdk/aicore/completion.py b/src/sap_cloud_sdk/aicore/completion.py index 293f9cf8..70dc6266 100644 --- a/src/sap_cloud_sdk/aicore/completion.py +++ b/src/sap_cloud_sdk/aicore/completion.py @@ -50,7 +50,6 @@ from __future__ import annotations import logging -import threading from typing import Any import litellm @@ -62,26 +61,6 @@ logger = logging.getLogger(__name__) -# Proxy mode state — set by _configure_proxy_mode() in __init__.py. -# When active, completion() rewrites sap/ → litellm_proxy/. -_proxy_lock = threading.Lock() -_proxy_active: bool = False - - -def _set_proxy_active(value: bool) -> None: - """Activate or deactivate proxy model aliasing (called by set_aicore_config).""" - global _proxy_active - with _proxy_lock: - _proxy_active = value - - -def _rewrite_model_for_proxy(kwargs: dict) -> dict: - """Rewrite sap/ to litellm_proxy/ when proxy mode is active.""" - model = kwargs.get("model", "") - if isinstance(model, str) and model.startswith("sap/"): - return {**kwargs, "model": "litellm_proxy/" + model[4:]} - return kwargs - @record_metrics(Module.AICORE, Operation.AICORE_REACTIVE_RELOAD) def _reload_reactive() -> None: @@ -112,13 +91,10 @@ def completion(*args: Any, **kwargs: Any) -> Any: On ``AuthenticationError`` (e.g. rotated client_secret or mTLS cert), reloads credentials from the mounted secret volume and retries once. - When proxy mode is active (``AICORE_PROXY_URL`` set), rewrites - ``sap/`` to ``litellm_proxy/`` transparently. + Model strings (e.g. ``sap/``) are passed verbatim to LiteLLM in all + routing modes — proxy routing is handled by ``litellm.api_base`` configured + in :func:`set_aicore_config`, not by rewriting the model name. """ - with _proxy_lock: - proxy = _proxy_active - if proxy: - kwargs = _rewrite_model_for_proxy(kwargs) try: return litellm.completion(*args, **kwargs) except litellm.AuthenticationError: @@ -134,12 +110,9 @@ def completion(*args: Any, **kwargs: Any) -> Any: async def acompletion(*args: Any, **kwargs: Any) -> Any: """Async wrapper around :func:`litellm.acompletion`. - Same credential-rotation and proxy aliasing semantics as :func:`completion`. + Same credential-rotation semantics as :func:`completion`. + Model strings are passed verbatim to LiteLLM in all routing modes. """ - with _proxy_lock: - proxy = _proxy_active - if proxy: - kwargs = _rewrite_model_for_proxy(kwargs) try: return await litellm.acompletion(*args, **kwargs) except litellm.AuthenticationError: diff --git a/tests/aicore/unit/test_aicore.py b/tests/aicore/unit/test_aicore.py index 9a3fee49..381425af 100644 --- a/tests/aicore/unit/test_aicore.py +++ b/tests/aicore/unit/test_aicore.py @@ -852,7 +852,6 @@ def test_proxy_mode_sets_litellm_api_base(self): import litellm with ( patch("sap_cloud_sdk.aicore.set_filtering"), - patch("sap_cloud_sdk.aicore._set_proxy_active"), patch.dict("os.environ", self._base_proxy_env(), clear=True), ): set_aicore_config() @@ -863,7 +862,6 @@ def test_proxy_mode_sets_litellm_api_key_when_virtual_key_present(self): import litellm with ( patch("sap_cloud_sdk.aicore.set_filtering"), - patch("sap_cloud_sdk.aicore._set_proxy_active"), patch.dict( "os.environ", self._base_proxy_env(AICORE_PROXY_VIRTUAL_KEY="sk-virt-123"), @@ -874,19 +872,9 @@ def test_proxy_mode_sets_litellm_api_key_when_virtual_key_present(self): assert litellm.api_key == "sk-virt-123" litellm.api_key = None # cleanup - def test_proxy_mode_activates_proxy_flag(self): - with ( - patch("sap_cloud_sdk.aicore.set_filtering"), - patch("sap_cloud_sdk.aicore._set_proxy_active") as mock_set_proxy, - patch.dict("os.environ", self._base_proxy_env(), clear=True), - ): - set_aicore_config() - mock_set_proxy.assert_called_once_with(True) - def test_proxy_mode_does_not_write_aicore_credentials(self): with ( patch("sap_cloud_sdk.aicore.set_filtering"), - patch("sap_cloud_sdk.aicore._set_proxy_active"), patch.dict("os.environ", self._base_proxy_env(), clear=True), ): set_aicore_config() @@ -896,7 +884,6 @@ def test_proxy_mode_does_not_write_aicore_credentials(self): def test_proxy_mode_takes_precedence_over_destination(self): with ( patch("sap_cloud_sdk.aicore.set_filtering"), - patch("sap_cloud_sdk.aicore._set_proxy_active") as mock_set_proxy, patch("sap_cloud_sdk.aicore._configure_destination_mode") as mock_dest, patch.dict( "os.environ", @@ -905,13 +892,11 @@ def test_proxy_mode_takes_precedence_over_destination(self): ), ): set_aicore_config() - mock_set_proxy.assert_called_once_with(True) mock_dest.assert_not_called() def test_proxy_mode_still_calls_set_filtering(self): with ( patch("sap_cloud_sdk.aicore.set_filtering") as mock_filter, - patch("sap_cloud_sdk.aicore._set_proxy_active"), patch.dict("os.environ", self._base_proxy_env(), clear=True), ): set_aicore_config() diff --git a/tests/aicore/unit/test_completion.py b/tests/aicore/unit/test_completion.py index ecaebd3f..026bd173 100644 --- a/tests/aicore/unit/test_completion.py +++ b/tests/aicore/unit/test_completion.py @@ -319,135 +319,3 @@ async def fake_acompletion(*args, **kwargs): asyncio.run(acompletion(model="sap/x", messages=[])) -# --------------------------------------------------------------------------- -# Proxy mode — model aliasing in completion() / acompletion() -# --------------------------------------------------------------------------- - - -class TestCompletionProxyModeAliasing: - """completion() rewrites sap/ → litellm_proxy/ when proxy is active.""" - - def setup_method(self): - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(False) - - def teardown_method(self): - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(False) - - def test_sap_model_rewritten_when_proxy_active(self): - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(True) - sentinel = object() - captured = {} - - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - side_effect=lambda *a, **kw: captured.update(kw) or sentinel, - ): - result = completion(model="sap/gpt-4o", messages=[]) - - assert result is sentinel - assert captured["model"] == "litellm_proxy/gpt-4o" - - def test_model_unchanged_when_proxy_not_active(self): - sentinel = object() - captured = {} - - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - side_effect=lambda *a, **kw: captured.update(kw) or sentinel, - ): - result = completion(model="sap/gpt-4o", messages=[]) - - assert result is sentinel - assert captured["model"] == "sap/gpt-4o" - - def test_non_sap_model_unchanged_even_when_proxy_active(self): - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(True) - sentinel = object() - captured = {} - - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - side_effect=lambda *a, **kw: captured.update(kw) or sentinel, - ): - result = completion(model="openai/gpt-4o", messages=[]) - - assert result is sentinel - assert captured["model"] == "openai/gpt-4o" - - def test_proxy_rewrite_on_auth_error_retry(self): - """Model aliasing is applied on both the initial call and the retry.""" - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(True) - - sentinel = object() - auth_err = litellm.AuthenticationError( - message="401", llm_provider="sap", model="sap/x" - ) - call_models = [] - - def fake_completion(*args, **kwargs): - call_models.append(kwargs.get("model")) - if len(call_models) == 1: - raise auth_err - return sentinel - - with ( - patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=fake_completion), - patch("sap_cloud_sdk.aicore.set_aicore_config"), - ): - result = completion(model="sap/gpt-4o", messages=[]) - - assert result is sentinel - assert call_models == ["litellm_proxy/gpt-4o", "litellm_proxy/gpt-4o"] - - -class TestACompletionProxyModeAliasing: - """acompletion() proxy aliasing — async path.""" - - def setup_method(self): - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(False) - - def teardown_method(self): - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(False) - - def test_sap_model_rewritten_when_proxy_active(self): - from sap_cloud_sdk.aicore.completion import _set_proxy_active - _set_proxy_active(True) - sentinel = object() - captured = {} - - async def fake_acompletion(*args, **kwargs): - captured.update(kwargs) - return sentinel - - with patch( - "sap_cloud_sdk.aicore.completion.litellm.acompletion", - side_effect=fake_acompletion, - ): - result = asyncio.run(acompletion(model="sap/gpt-4o", messages=[])) - - assert result is sentinel - assert captured["model"] == "litellm_proxy/gpt-4o" - - def test_model_unchanged_when_proxy_not_active(self): - sentinel = object() - captured = {} - - async def fake_acompletion(*args, **kwargs): - captured.update(kwargs) - return sentinel - - with patch( - "sap_cloud_sdk.aicore.completion.litellm.acompletion", - side_effect=fake_acompletion, - ): - result = asyncio.run(acompletion(model="sap/gpt-4o", messages=[])) - - assert result is sentinel - assert captured["model"] == "sap/gpt-4o" From 1d3778cea1ac365b43ee388374fa2c20219c660d Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Mon, 24 Aug 2026 10:19:31 -0300 Subject: [PATCH 07/14] =?UTF-8?q?refactor(aicore):=20rename=20AICORE=5FPRO?= =?UTF-8?q?XY=5FVIRTUAL=5FKEY=20=E2=86=92=20AICORE=5FPROXY=5FAPI=5FKEY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/sap_cloud_sdk/aicore/__init__.py | 14 +++++++------- tests/aicore/unit/test_aicore.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 69ac4222..4fefac87 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -39,7 +39,7 @@ # Option 3 — transparent proxy routing. # Deployer injects these; agent code is identical in all environments. _PROXY_URL_ENV = "AICORE_PROXY_URL" -_PROXY_VIRTUAL_KEY_ENV = "AICORE_PROXY_VIRTUAL_KEY" +_PROXY_API_KEY_ENV = "AICORE_PROXY_API_KEY" _DESTINATION_NAME_ENV = "AICORE_DESTINATION_NAME" @@ -138,9 +138,9 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: Detects which routing mode is active based on environment variables: - ``AICORE_PROXY_URL`` set → **proxy mode**: routes all LiteLLM calls - through a LiteLLM proxy; ``sap/`` is aliased to - ``litellm_proxy/`` transparently. No AI Core credentials - are written to the process environment. + through a LiteLLM proxy via ``litellm.api_base``; model strings are + passed verbatim. No AI Core credentials are written to the process + environment. - ``AICORE_DESTINATION_NAME`` set → **destination mode**: loads AI Core credentials from a BTP Destination Service destination at startup. @@ -185,10 +185,10 @@ def _configure_proxy_mode(proxy_url: str) -> None: """ import litellm as _litellm - virtual_key = os.environ.get(_PROXY_VIRTUAL_KEY_ENV, "") + api_key = os.environ.get(_PROXY_API_KEY_ENV, "") _litellm.api_base = proxy_url - if virtual_key: - _litellm.api_key = virtual_key + if api_key: + _litellm.api_key = api_key logger.info("AI Core proxy mode active — routing via %s", proxy_url) diff --git a/tests/aicore/unit/test_aicore.py b/tests/aicore/unit/test_aicore.py index 381425af..ad09c40f 100644 --- a/tests/aicore/unit/test_aicore.py +++ b/tests/aicore/unit/test_aicore.py @@ -864,7 +864,7 @@ def test_proxy_mode_sets_litellm_api_key_when_virtual_key_present(self): patch("sap_cloud_sdk.aicore.set_filtering"), patch.dict( "os.environ", - self._base_proxy_env(AICORE_PROXY_VIRTUAL_KEY="sk-virt-123"), + self._base_proxy_env(AICORE_PROXY_API_KEY="sk-virt-123"), clear=True, ), ): From 062976fcdd9ac22c70c5a8bece33e371cf3563da Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 28 Aug 2026 11:39:53 -0300 Subject: [PATCH 08/14] fix(aicore): import _is_transparent_tls in test_aicore --- tests/aicore/unit/test_aicore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/aicore/unit/test_aicore.py b/tests/aicore/unit/test_aicore.py index ad09c40f..b0103eba 100644 --- a/tests/aicore/unit/test_aicore.py +++ b/tests/aicore/unit/test_aicore.py @@ -9,6 +9,7 @@ from sap_cloud_sdk.aicore import ( _get_aicore_base_url, _get_secret, + _is_transparent_tls, set_aicore_config, ) From 9e24d28da180003caf6d9fedf5c8d720bfefafce Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 28 Aug 2026 11:44:04 -0300 Subject: [PATCH 09/14] style(aicore): reformat _is_transparent_tls tuple for ruff-format --- src/sap_cloud_sdk/aicore/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 4fefac87..69c22510 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -45,7 +45,11 @@ def _is_transparent_tls() -> bool: """Return True when transparent TLS proxy mode is active.""" - return os.environ.get(TRANSPARENT_TLS_ENV_VAR, "").strip().lower() in ("1", "true", "yes") + return os.environ.get(TRANSPARENT_TLS_ENV_VAR, "").strip().lower() in ( + "1", + "true", + "yes", + ) def _get_secret( From b916d2fa050976999b1813692912c2154a1bb2db Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 28 Aug 2026 11:50:39 -0300 Subject: [PATCH 10/14] style(aicore): fix trailing newlines in test_completion.py --- tests/aicore/unit/test_completion.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/aicore/unit/test_completion.py b/tests/aicore/unit/test_completion.py index 026bd173..8238e922 100644 --- a/tests/aicore/unit/test_completion.py +++ b/tests/aicore/unit/test_completion.py @@ -317,5 +317,3 @@ async def fake_acompletion(*args, **kwargs): ): with pytest.raises(litellm.AuthenticationError): asyncio.run(acompletion(model="sap/x", messages=[])) - - From 995aa6b3bc9f084059b8da5084afe21539a641f9 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Tue, 1 Sep 2026 15:13:34 -0300 Subject: [PATCH 11/14] feat(aicore): add telemetry for proxy/destination mode + LangGraph/A2A tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/sap_cloud_sdk/aicore/__init__.py | 2 + src/sap_cloud_sdk/core/telemetry/operation.py | 2 + tests/aicore/unit/test_langgraph_compat.py | 159 ++++++++++++++++++ tests/core/unit/telemetry/test_operation.py | 6 +- uv.lock | 38 +++-- 5 files changed, 192 insertions(+), 15 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 69c22510..2419df70 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -180,6 +180,7 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: set_filtering() +@record_metrics(Module.AICORE, Operation.AICORE_PROXY_MODE) def _configure_proxy_mode(proxy_url: str) -> None: """Configure LiteLLM to route calls through an external proxy. @@ -196,6 +197,7 @@ def _configure_proxy_mode(proxy_url: str) -> None: logger.info("AI Core proxy mode active — routing via %s", proxy_url) +@record_metrics(Module.AICORE, Operation.AICORE_DESTINATION_MODE) def _configure_destination_mode(name: str) -> None: """Load AI Core credentials from a BTP Destination Service destination. diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 7836e20e..54893521 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -150,6 +150,8 @@ class Operation(str, Enum): AICORE_AUTO_INSTRUMENT = "auto_instrument" AICORE_SET_FILTERING = "set_filtering" AICORE_DISABLE_FILTERING = "disable_filtering" + AICORE_PROXY_MODE = "aicore_proxy_mode" + AICORE_DESTINATION_MODE = "aicore_destination_mode" # Print Operations PRINT_LIST_QUEUES = "list_queues" diff --git a/tests/aicore/unit/test_langgraph_compat.py b/tests/aicore/unit/test_langgraph_compat.py index 181fbfaa..6996bf20 100644 --- a/tests/aicore/unit/test_langgraph_compat.py +++ b/tests/aicore/unit/test_langgraph_compat.py @@ -464,3 +464,162 @@ def test_full_langgraph_startup_pattern(self, tmp_path, monkeypatch, clean_litel stop.set() t.join(timeout=1.0) + + +# --------------------------------------------------------------------------- +# 5. Proxy mode + LangGraph/A2A — credential rotation and routing invariants +# --------------------------------------------------------------------------- + + +class TestProxyModeWithLangGraph: + """Validates that proxy routing and credential rotation interact correctly. + + AFSDK-4306 live validation (auto-doc-dev-eu12, 2026-08-20) confirmed: + - destination mode reactive reload works end-to-end. + - proxy mode never injects AICORE_CLIENT_SECRET into the env. + + These tests cover the unit-level equivalents of those scenarios plus + the A2A/LangGraph path (direct litellm.completion with proxy active). + """ + + def test_patch_works_with_proxy_mode_active(self, monkeypatch, clean_litellm_patch): + """patch_litellm_for_credential_rotation + proxy mode: 401 triggers reload, + proxy URL env var stays set after reload. + """ + monkeypatch.setenv("AICORE_PROXY_URL", "https://proxy.example.com") + monkeypatch.delenv("AICORE_CLIENT_SECRET", raising=False) + + sentinel = object() + auth_err = litellm.AuthenticationError( + message="401", llm_provider="sap", model="sap/x" + ) + call_results = [auth_err, sentinel] + + def _fake(*args, **kwargs): + r = call_results.pop(0) + if isinstance(r, Exception): + raise r + return r + + with ( + patch("litellm.completion", side_effect=_fake), + patch("sap_cloud_sdk.aicore.set_aicore_config") as reload_mock, + ): + patch_litellm_for_credential_rotation() + result = litellm.completion(model="sap/x", messages=[]) + + assert result is sentinel + reload_mock.assert_called_once() + assert os.environ.get("AICORE_PROXY_URL") == "https://proxy.example.com" + + def test_watcher_preserves_proxy_mode_after_rotation(self, tmp_path, monkeypatch): + """Watcher reload calls set_aicore_config(), which re-enters _configure_proxy_mode + when AICORE_PROXY_URL is present — litellm.api_base stays set. + """ + import litellm as _litellm + + monkeypatch.setenv("AICORE_PROXY_URL", "https://proxy.example.com") + monkeypatch.delenv("AICORE_CLIENT_SECRET", raising=False) + monkeypatch.setenv("SERVICE_BINDING_ROOT", str(tmp_path)) + secret_dir = _write_secret_files(tmp_path, secret="unused-in-proxy-mode") + + stop = threading.Event() + + with patch("sap_cloud_sdk.aicore.set_filtering"): + set_aicore_config() + initial_api_base = _litellm.api_base + t = watch_aicore_config(interval=0.05, stop_event=stop) + + new_time = time.time() + 10 + os.utime(secret_dir, (new_time, new_time)) + time.sleep(0.2) + + assert _litellm.api_base == initial_api_base, ( + "litellm.api_base must remain set after a watcher-triggered reload in proxy mode" + ) + stop.set() + t.join(timeout=1.0) + + def test_a2a_direct_litellm_routes_via_proxy_after_patch( + self, monkeypatch, clean_litellm_patch + ): + """A2A / ChatLiteLLM path: direct litellm.completion with proxy active + gets reactive reload on 401 after patch_litellm_for_credential_rotation(). + """ + import litellm as _litellm + + monkeypatch.setenv("AICORE_PROXY_URL", "https://proxy.example.com") + + with patch("sap_cloud_sdk.aicore.set_filtering"): + set_aicore_config() + + assert _litellm.api_base == "https://proxy.example.com" + + sentinel = object() + auth_err = litellm.AuthenticationError( + message="401", llm_provider="sap", model="sap/x" + ) + calls = [auth_err, sentinel] + + def _fake_completion(*args, **kwargs): + r = calls.pop(0) + if isinstance(r, Exception): + raise r + return r + + with ( + patch("litellm.completion", side_effect=_fake_completion), + patch("sap_cloud_sdk.aicore.set_aicore_config") as reload_mock, + ): + patch_litellm_for_credential_rotation() + result = litellm.completion(model="sap/x", messages=[]) + + assert result is sentinel + reload_mock.assert_called_once() + + def test_destination_mode_credential_rotation_reload(self, monkeypatch): + """Destination mode: reload re-calls _configure_destination_mode() fetching + fresh credentials — mirrors AFSDK-4306 Val 3 (auto-doc-dev-eu12, 2026-08-20). + """ + monkeypatch.setenv("AICORE_DESTINATION_NAME", "aicore-destination") + monkeypatch.delenv("AICORE_CLIENT_SECRET", raising=False) + + call_count = [0] + + def _fake_destination_mode(name): + call_count[0] += 1 + os.environ["AICORE_CLIENT_SECRET"] = f"rotated-secret-v{call_count[0]}" + + with ( + patch( + "sap_cloud_sdk.aicore._configure_destination_mode", + side_effect=_fake_destination_mode, + ), + patch("sap_cloud_sdk.aicore.set_filtering"), + ): + set_aicore_config() + assert os.environ["AICORE_CLIENT_SECRET"] == "rotated-secret-v1" + assert call_count[0] == 1 + + set_aicore_config() # simulates reload after 401 + assert os.environ["AICORE_CLIENT_SECRET"] == "rotated-secret-v2" + assert call_count[0] == 2 + + def test_proxy_credential_snapshot_not_cleared_by_reload(self, monkeypatch): + """In proxy mode, AICORE_CLIENT_SECRET is never written to env by set_aicore_config(). + + A reload must not introduce a stale secret — proxy auth uses litellm.api_key, + not the AICORE_CLIENT_SECRET env var. + """ + monkeypatch.setenv("AICORE_PROXY_URL", "https://proxy.example.com") + monkeypatch.delenv("AICORE_CLIENT_SECRET", raising=False) + + with patch("sap_cloud_sdk.aicore.set_filtering"): + set_aicore_config() + + assert "AICORE_CLIENT_SECRET" not in os.environ + + with patch("sap_cloud_sdk.aicore.set_filtering"): + set_aicore_config() # reload + + assert "AICORE_CLIENT_SECRET" not in os.environ diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index c9bdbeb2..e2444ae3 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -215,6 +215,6 @@ def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore - # + 2 extensibility + 7 aicore + 23 dms + 6 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management = 161 - assert len(all_operations) == 161 + # + 2 extensibility + 9 aicore + 23 dms + 6 agentgateway + 13 agent_memory + # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management = 163 + assert len(all_operations) == 163 diff --git a/uv.lock b/uv.lock index c6a54ef2..8a116925 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -1013,7 +1013,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -1021,7 +1023,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -1029,7 +1033,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -1037,7 +1043,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -1045,14 +1053,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -1060,7 +1072,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -3925,7 +3939,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.48.2" +version = "0.47.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From e77d16c1e2f11a673340711e1d3120c784dde211 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Tue, 1 Sep 2026 16:30:10 -0300 Subject: [PATCH 12/14] chore: version bump to 0.49.0 --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ce51441e..52ece028 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [project] name = "sap-cloud-sdk" -version = "0.48.3" + +version = "0.49.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From 81668027bfdec3283c6f6e183900dbba66ced0c5 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Tue, 1 Sep 2026 16:38:59 -0300 Subject: [PATCH 13/14] docs(aicore): document proxy mode and destination mode routing in user-guide --- src/sap_cloud_sdk/aicore/user-guide.md | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/sap_cloud_sdk/aicore/user-guide.md b/src/sap_cloud_sdk/aicore/user-guide.md index d8df5c85..ad37e300 100644 --- a/src/sap_cloud_sdk/aicore/user-guide.md +++ b/src/sap_cloud_sdk/aicore/user-guide.md @@ -47,6 +47,60 @@ set_aicore_config(instance_name="aicore-production") --- +## Routing Modes + +`set_aicore_config()` detects which routing mode to activate based on environment variables. +**Agent code is identical in all three modes** — the deployer controls routing by choosing which +env vars to inject. + +### Direct mode (default) + +No extra env vars required. Credentials are loaded from the mounted K8s secret volume +(`/etc/secrets/appfnd/aicore//`) or from `AICORE_*` environment variables. +This is the standard mode for agents deployed on BTP managed runtime. + +```python +set_aicore_config() # reads mounted secret or AICORE_* env vars +``` + +### Proxy mode (`AICORE_PROXY_URL`) + +Set `AICORE_PROXY_URL` to route all LiteLLM calls through a LiteLLM-compatible proxy. +No AI Core credentials are written to the process environment — the proxy handles +authentication. Optionally set `AICORE_PROXY_API_KEY` for proxy-level auth. + +```bash +# Injected by the deployer (e.g. K8s ConfigMap / Helm values) +AICORE_PROXY_URL=https://your-litellm-proxy.example.com +AICORE_PROXY_API_KEY=sk-... # optional +``` + +```python +set_aicore_config() # detects AICORE_PROXY_URL, sets litellm.api_base +# AICORE_CLIENT_SECRET is NOT written to env in this mode +``` + +Model strings (`sap/`) are passed verbatim — no rewriting. + +### Destination mode (`AICORE_DESTINATION_NAME`) + +Set `AICORE_DESTINATION_NAME` to load AI Core credentials at startup from a BTP Destination +Service destination. The deployer only needs to inject Destination Service binding credentials; +the AI Core `client_secret` never needs to be in the K8s Secret directly. + +```bash +AICORE_DESTINATION_NAME=aicore-destination # name of the BTP destination +``` + +```python +set_aicore_config() # calls Destination Service, writes AICORE_* env vars +``` + +The destination must use **OAuth2ClientCredentials** authentication with `clientId`, +`clientSecret`, and `tokenServiceURL` as destination configuration properties. + +--- + ## Credential Rotation BTP rotates AI Core service binding credentials automatically. The SDK From 3d2c2aca0e38681fae39ba43daad124910127bb3 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 2 Sep 2026 11:17:51 -0300 Subject: [PATCH 14/14] refactor(aicore): remove @record_metrics from internal configure mode 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. --- src/sap_cloud_sdk/aicore/__init__.py | 2 -- src/sap_cloud_sdk/core/telemetry/operation.py | 2 -- tests/core/unit/telemetry/test_operation.py | 6 +++--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 2419df70..69c22510 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -180,7 +180,6 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: set_filtering() -@record_metrics(Module.AICORE, Operation.AICORE_PROXY_MODE) def _configure_proxy_mode(proxy_url: str) -> None: """Configure LiteLLM to route calls through an external proxy. @@ -197,7 +196,6 @@ def _configure_proxy_mode(proxy_url: str) -> None: logger.info("AI Core proxy mode active — routing via %s", proxy_url) -@record_metrics(Module.AICORE, Operation.AICORE_DESTINATION_MODE) def _configure_destination_mode(name: str) -> None: """Load AI Core credentials from a BTP Destination Service destination. diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 54893521..7836e20e 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -150,8 +150,6 @@ class Operation(str, Enum): AICORE_AUTO_INSTRUMENT = "auto_instrument" AICORE_SET_FILTERING = "set_filtering" AICORE_DISABLE_FILTERING = "disable_filtering" - AICORE_PROXY_MODE = "aicore_proxy_mode" - AICORE_DESTINATION_MODE = "aicore_destination_mode" # Print Operations PRINT_LIST_QUEUES = "list_queues" diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index e2444ae3..c9bdbeb2 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -215,6 +215,6 @@ def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore - # + 2 extensibility + 9 aicore + 23 dms + 6 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management = 163 - assert len(all_operations) == 163 + # + 2 extensibility + 7 aicore + 23 dms + 6 agentgateway + 13 agent_memory + # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management = 161 + assert len(all_operations) == 161