From d762ef2c01553f955d6e2c7a52fa912f9f929cc0 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Mon, 17 Aug 2026 10:24:31 -0300 Subject: [PATCH 1/6] 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 | 47 +++- tests/aicore/unit/test_aicore.py | 355 +++++++++++++++++++++++++ tests/aicore/unit/test_completion.py | 134 ++++++++++ 4 files changed, 669 insertions(+), 27 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 1e795980..b9a425f3 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 a2d72ad7..078f98ca 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 @@ -58,6 +59,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 + def _maybe_translate_filter_error(exc: BaseException) -> BaseException: """Return a :class:`ContentFilteredError` if ``exc`` is a wrapped @@ -76,10 +97,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: @@ -88,6 +115,10 @@ def completion(*args: Any, **kwargs: Any) -> Any: logger.info("AI Core credentials reloading after authentication failure") set_aicore_config() + with _proxy_lock: + proxy = _proxy_active + if proxy: + kwargs = _rewrite_model_for_proxy(kwargs) return litellm.completion(*args, **kwargs) except Exception as exc: translated = _maybe_translate_filter_error(exc) @@ -99,8 +130,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: @@ -108,6 +143,10 @@ async def acompletion(*args: Any, **kwargs: Any) -> Any: logger.info("AI Core credentials reloading after authentication failure") set_aicore_config() + with _proxy_lock: + proxy = _proxy_active + if proxy: + kwargs = _rewrite_model_for_proxy(kwargs) return await litellm.acompletion(*args, **kwargs) except Exception as exc: translated = _maybe_translate_filter_error(exc) 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 a3c3e9b52a1ac56dc0ea31560d1e2bee24c02072 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 21 Aug 2026 10:33:33 -0300 Subject: [PATCH 2/6] =?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 | 45 +-------- tests/aicore/unit/test_aicore.py | 15 --- tests/aicore/unit/test_completion.py | 132 ------------------------- 4 files changed, 9 insertions(+), 192 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index b9a425f3..d4994a57 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 078f98ca..b8e6b645 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 @@ -59,26 +58,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 - def _maybe_translate_filter_error(exc: BaseException) -> BaseException: """Return a :class:`ContentFilteredError` if ``exc`` is a wrapped @@ -100,13 +79,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: @@ -115,10 +91,6 @@ def completion(*args: Any, **kwargs: Any) -> Any: logger.info("AI Core credentials reloading after authentication failure") set_aicore_config() - with _proxy_lock: - proxy = _proxy_active - if proxy: - kwargs = _rewrite_model_for_proxy(kwargs) return litellm.completion(*args, **kwargs) except Exception as exc: translated = _maybe_translate_filter_error(exc) @@ -130,12 +102,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: @@ -143,10 +112,6 @@ async def acompletion(*args: Any, **kwargs: Any) -> Any: logger.info("AI Core credentials reloading after authentication failure") set_aicore_config() - with _proxy_lock: - proxy = _proxy_active - if proxy: - kwargs = _rewrite_model_for_proxy(kwargs) return await litellm.acompletion(*args, **kwargs) except Exception as exc: translated = _maybe_translate_filter_error(exc) 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 f0f274134ded0ffea7dab0ee879cb324e2426574 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Mon, 24 Aug 2026 10:19:31 -0300 Subject: [PATCH 3/6] =?UTF-8?q?refactor(aicore):=20rename=20AICORE=5FPROXY?= =?UTF-8?q?=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 d4994a57..9eb11d0f 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 2e74fe4f14368a1448baa56ffb4c22d0b1999f95 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 28 Aug 2026 11:39:53 -0300 Subject: [PATCH 4/6] 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 a8153fa04e94bbf6249e3260fb3d5b8fbdc30c4b Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 28 Aug 2026 11:44:04 -0300 Subject: [PATCH 5/6] 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 9eb11d0f..68ebab50 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 efd2c18b844b83c2f20f59e8182c8bc49d76288c Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 28 Aug 2026 11:50:39 -0300 Subject: [PATCH 6/6] 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=[])) - -