Skip to content

Commit d8cb6df

Browse files
committed
feat(aicore): add transparent proxy routing and BTP Destination Service mode
Implements Option 3 from the AFSDK-4306 security alignment meeting: SDK absorbs all routing complexity so agent code is identical in all environments. The deployer controls routing by choosing which env vars to inject. Two new modes in set_aicore_config(): Proxy mode (AICORE_PROXY_URL set): - Routes all LiteLLM calls through an external LiteLLM proxy - Sets litellm.api_base / litellm.api_key globally - Rewrites sap/<model> → litellm_proxy/<model> transparently in completion() and acompletion() wrappers (including on auth-error retry) - No AI Core credentials written to the process environment - JWT never reaches the agent process (proxy handles OAuth) Destination mode (AICORE_DESTINATION_NAME set): - Loads AI Core credentials at startup from a named BTP Destination Service destination via the existing sap_cloud_sdk.destination client - Deployer only injects Destination Service binding — AI Core client_secret is never in the K8s Secret, only in BTP Destination Service - Combined with _clear_client_secret() (PR #257), the secret is removed from env after the first successful LiteLLM call Direct mode (neither set): existing behaviour unchanged, including transparent TLS (AICORE_TRANSPARENT_TLS). Adds 30 unit tests covering both new modes and all edge cases. AFSDK-4306
1 parent 4b8b336 commit d8cb6df

4 files changed

Lines changed: 669 additions & 27 deletions

File tree

src/sap_cloud_sdk/aicore/__init__.py

Lines changed: 137 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics
1515
from sap_cloud_sdk.core.telemetry.module import Module
1616
from sap_cloud_sdk.core.telemetry.operation import Operation
17-
from .completion import acompletion, completion
17+
from .completion import acompletion, completion, _set_proxy_active
1818
from .filtering import (
1919
AzureContentFilter,
2020
ContentFilter,
@@ -31,6 +31,22 @@
3131

3232
logger = logging.getLogger(__name__)
3333

34+
# When set, the infrastructure sidecar adds the mTLS certificate transparently.
35+
# The SDK calls the XSUAA token endpoint over plain HTTPS with only client_id.
36+
# No client_secret or certificate material is required in the service binding.
37+
TRANSPARENT_TLS_ENV_VAR = "AICORE_TRANSPARENT_TLS"
38+
39+
# Option 3 — transparent proxy routing.
40+
# Deployer injects these; agent code is identical in all environments.
41+
_PROXY_URL_ENV = "AICORE_PROXY_URL"
42+
_PROXY_VIRTUAL_KEY_ENV = "AICORE_PROXY_VIRTUAL_KEY"
43+
_DESTINATION_NAME_ENV = "AICORE_DESTINATION_NAME"
44+
45+
46+
def _is_transparent_tls() -> bool:
47+
"""Return True when transparent TLS proxy mode is active."""
48+
return os.environ.get(TRANSPARENT_TLS_ENV_VAR, "").strip().lower() in ("1", "true", "yes")
49+
3450

3551
def _get_secret(
3652
env_var_name: str,
@@ -119,14 +135,25 @@ def _get_aicore_base_url(instance_name: str = "aicore-instance") -> str:
119135
def set_aicore_config(instance_name: str = "aicore-instance") -> None:
120136
"""Load AI Core credentials and activate content filtering.
121137
122-
Loads secrets from files or environment variables and sets them as
123-
process env vars so ``litellm`` picks them up.
138+
Detects which routing mode is active based on environment variables:
139+
140+
- ``AICORE_PROXY_URL`` set → **proxy mode**: routes all LiteLLM calls
141+
through a LiteLLM proxy; ``sap/<model>`` is aliased to
142+
``litellm_proxy/<model>`` transparently. No AI Core credentials
143+
are written to the process environment.
144+
145+
- ``AICORE_DESTINATION_NAME`` set → **destination mode**: loads AI Core
146+
credentials from a BTP Destination Service destination at startup.
147+
The deployer only needs to inject Destination Service binding credentials;
148+
the AI Core ``client_secret`` never needs to be in the K8s Secret.
124149
125-
File mappings based on the Kubernetes secret structure:
126-
clientid → AICORE_CLIENT_ID
127-
clientsecret → AICORE_CLIENT_SECRET
128-
url → AICORE_AUTH_URL
129-
serviceurls (JSON with AI_API_URL) → AICORE_BASE_URL
150+
- Neither set → **direct mode** (existing behaviour): credentials are
151+
loaded from a mounted K8s secret volume or environment variables.
152+
``AICORE_TRANSPARENT_TLS=true`` suppresses ``client_secret`` and
153+
relies on an mTLS sidecar.
154+
155+
Agent code is identical in all three modes — the deployer controls
156+
routing by choosing which env vars to inject.
130157
131158
After credentials are loaded, content filtering is activated on every
132159
``sap/*`` LiteLLM call at the configured thresholds (default: severity
@@ -136,44 +163,131 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None:
136163
to turn filtering off at runtime, or set ``AICORE_FILTER_ENABLED=false``
137164
to keep it off entirely.
138165
"""
139-
# Load secrets
166+
proxy_url = os.environ.get(_PROXY_URL_ENV, "")
167+
destination_name = os.environ.get(_DESTINATION_NAME_ENV, "")
168+
169+
if proxy_url:
170+
_configure_proxy_mode(proxy_url)
171+
elif destination_name:
172+
_configure_destination_mode(destination_name)
173+
else:
174+
_configure_direct_mode(instance_name)
175+
176+
set_filtering()
177+
178+
179+
def _configure_proxy_mode(proxy_url: str) -> None:
180+
"""Configure LiteLLM to route calls through an external proxy.
181+
182+
Sets ``litellm.api_base`` / ``litellm.api_key`` globally and activates
183+
the ``sap/`` → ``litellm_proxy/`` model alias rewrite in the
184+
completion wrappers. No AI Core credentials are written to env.
185+
"""
186+
import litellm as _litellm
187+
188+
virtual_key = os.environ.get(_PROXY_VIRTUAL_KEY_ENV, "")
189+
_litellm.api_base = proxy_url
190+
if virtual_key:
191+
_litellm.api_key = virtual_key
192+
_set_proxy_active(True)
193+
logger.info("AI Core proxy mode active — routing via %s", proxy_url)
194+
195+
196+
def _configure_destination_mode(name: str) -> None:
197+
"""Load AI Core credentials from a BTP Destination Service destination.
198+
199+
Calls the Destination Service at startup to resolve the named destination
200+
and extracts ``clientId``, ``clientSecret``, ``tokenServiceURL``, and the
201+
AI Core ``URL`` from the destination configuration properties. These are
202+
written to the standard ``AICORE_*`` env vars so that LiteLLM can fetch
203+
an OAuth token from XSUAA as usual.
204+
205+
Security: The deployer does NOT need to inject ``AICORE_CLIENT_SECRET``
206+
directly — only Destination Service binding credentials are required in
207+
the agent environment.
208+
209+
Raises ``RuntimeError`` if the destination is not found or does not
210+
return ``clientId`` / ``clientSecret``.
211+
"""
212+
from sap_cloud_sdk.destination import create_client # lazy import
213+
214+
client = create_client()
215+
dest = client.get_destination(name)
216+
217+
if dest is None:
218+
raise RuntimeError(
219+
f"AI Core destination '{name}' not found in Destination Service. "
220+
"Check that the destination exists and the binding has access."
221+
)
222+
223+
base_url = dest.url or ""
224+
if base_url and not base_url.endswith("/v2"):
225+
base_url = base_url.rstrip("/") + "/v2"
226+
if base_url:
227+
os.environ["AICORE_BASE_URL"] = base_url
228+
229+
resource_group = dest.properties.get("resource_group", "default")
230+
os.environ["AICORE_RESOURCE_GROUP"] = resource_group
231+
232+
client_id = dest.properties.get("clientId", "")
233+
client_secret = dest.properties.get("clientSecret", "")
234+
token_service_url = dest.properties.get("tokenServiceURL", "")
235+
236+
if not client_id or not client_secret:
237+
raise RuntimeError(
238+
f"Destination '{name}' did not return clientId/clientSecret. "
239+
"Ensure the destination uses OAuth2ClientCredentials authentication "
240+
"and the calling app has the Destination Service technical-user scope."
241+
)
242+
243+
os.environ["AICORE_CLIENT_ID"] = client_id
244+
os.environ["AICORE_CLIENT_SECRET"] = client_secret
245+
246+
if token_service_url:
247+
if not token_service_url.endswith("/oauth/token"):
248+
token_service_url = token_service_url.rstrip("/") + "/oauth/token"
249+
os.environ["AICORE_AUTH_URL"] = token_service_url
250+
251+
logger.info("AI Core destination mode active — credentials loaded from '%s'", name)
252+
253+
254+
def _configure_direct_mode(instance_name: str) -> None:
255+
"""Load AI Core credentials directly from mounted secrets or env vars."""
256+
transparent_tls = _is_transparent_tls()
257+
140258
client_id = _get_secret("AICORE_CLIENT_ID", "clientid", instance_name=instance_name)
141-
client_secret = _get_secret(
142-
"AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name
143-
)
144259
auth_url = _get_secret("AICORE_AUTH_URL", "url", instance_name=instance_name)
145260
base_url = _get_aicore_base_url(instance_name)
146261
resource_group = _get_secret(
147262
"AICORE_RESOURCE_GROUP", default="default", instance_name=instance_name
148263
)
149264

150-
# Ensure AICORE_AUTH_URL has /oauth/token suffix
151265
if auth_url and not auth_url.endswith("/oauth/token"):
152266
auth_url = auth_url.rstrip("/") + "/oauth/token"
153267

154268
if base_url and not base_url.endswith("/v2"):
155269
base_url = base_url.rstrip("/") + "/v2"
156270

157-
# Set environment variables for LiteLLM
158271
if client_id:
159272
os.environ["AICORE_CLIENT_ID"] = client_id
160-
if client_secret:
161-
os.environ["AICORE_CLIENT_SECRET"] = client_secret
162273
if auth_url:
163274
os.environ["AICORE_AUTH_URL"] = auth_url
164275
if base_url:
165276
os.environ["AICORE_BASE_URL"] = base_url
166277
if resource_group:
167278
os.environ["AICORE_RESOURCE_GROUP"] = resource_group
168279

169-
# Log configuration completion (excluding sensitive information)
170-
logger.info("AI Core configuration has been set successfully")
280+
if transparent_tls:
281+
os.environ.pop("AICORE_CLIENT_SECRET", None)
282+
logger.info("AI Core transparent TLS mode active — client_secret not required")
283+
else:
284+
client_secret = _get_secret(
285+
"AICORE_CLIENT_SECRET", "clientsecret", instance_name=instance_name
286+
)
287+
if client_secret:
288+
os.environ["AICORE_CLIENT_SECRET"] = client_secret
171289

172-
# Activate content filtering for all sap/* LiteLLM model calls.
173-
# AICORE_FILTER_ENABLED=false disables; AICORE_FILTER_* tune thresholds.
174-
# Errors propagate — filtering misconfiguration should surface at startup
175-
# rather than be swallowed silently.
176-
set_filtering()
290+
logger.info("AI Core configuration has been set successfully")
177291

178292

179293
def _get_secret_dir_mtime(instance_name: str = "aicore-instance") -> float:

src/sap_cloud_sdk/aicore/completion.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from __future__ import annotations
5151

5252
import logging
53+
import threading
5354
from typing import Any
5455

5556
import litellm
@@ -58,6 +59,26 @@
5859

5960
logger = logging.getLogger(__name__)
6061

62+
# Proxy mode state — set by _configure_proxy_mode() in __init__.py.
63+
# When active, completion() rewrites sap/<model> → litellm_proxy/<model>.
64+
_proxy_lock = threading.Lock()
65+
_proxy_active: bool = False
66+
67+
68+
def _set_proxy_active(value: bool) -> None:
69+
"""Activate or deactivate proxy model aliasing (called by set_aicore_config)."""
70+
global _proxy_active
71+
with _proxy_lock:
72+
_proxy_active = value
73+
74+
75+
def _rewrite_model_for_proxy(kwargs: dict) -> dict:
76+
"""Rewrite sap/<model> to litellm_proxy/<model> when proxy mode is active."""
77+
model = kwargs.get("model", "")
78+
if isinstance(model, str) and model.startswith("sap/"):
79+
return {**kwargs, "model": "litellm_proxy/" + model[4:]}
80+
return kwargs
81+
6182

6283
def _maybe_translate_filter_error(exc: BaseException) -> BaseException:
6384
"""Return a :class:`ContentFilteredError` if ``exc`` is a wrapped
@@ -76,10 +97,16 @@ def completion(*args: Any, **kwargs: Any) -> Any:
7697
"""Wrapper around :func:`litellm.completion` that normalises filter errors
7798
and handles credential rotation transparently.
7899
79-
On ``AuthenticationError`` (e.g. rotated client_secret), reloads
80-
credentials from the mounted secret volume and retries once.
81-
All other exceptions surface verbatim after the filter-error translation.
100+
On ``AuthenticationError`` (e.g. rotated client_secret or mTLS cert),
101+
reloads credentials from the mounted secret volume and retries once.
102+
103+
When proxy mode is active (``AICORE_PROXY_URL`` set), rewrites
104+
``sap/<model>`` to ``litellm_proxy/<model>`` transparently.
82105
"""
106+
with _proxy_lock:
107+
proxy = _proxy_active
108+
if proxy:
109+
kwargs = _rewrite_model_for_proxy(kwargs)
83110
try:
84111
return litellm.completion(*args, **kwargs)
85112
except litellm.AuthenticationError:
@@ -88,6 +115,10 @@ def completion(*args: Any, **kwargs: Any) -> Any:
88115

89116
logger.info("AI Core credentials reloading after authentication failure")
90117
set_aicore_config()
118+
with _proxy_lock:
119+
proxy = _proxy_active
120+
if proxy:
121+
kwargs = _rewrite_model_for_proxy(kwargs)
91122
return litellm.completion(*args, **kwargs)
92123
except Exception as exc:
93124
translated = _maybe_translate_filter_error(exc)
@@ -99,15 +130,23 @@ def completion(*args: Any, **kwargs: Any) -> Any:
99130
async def acompletion(*args: Any, **kwargs: Any) -> Any:
100131
"""Async wrapper around :func:`litellm.acompletion`.
101132
102-
Same translation and credential-rotation semantics as :func:`completion`.
133+
Same credential-rotation and proxy aliasing semantics as :func:`completion`.
103134
"""
135+
with _proxy_lock:
136+
proxy = _proxy_active
137+
if proxy:
138+
kwargs = _rewrite_model_for_proxy(kwargs)
104139
try:
105140
return await litellm.acompletion(*args, **kwargs)
106141
except litellm.AuthenticationError:
107142
from sap_cloud_sdk.aicore import set_aicore_config
108143

109144
logger.info("AI Core credentials reloading after authentication failure")
110145
set_aicore_config()
146+
with _proxy_lock:
147+
proxy = _proxy_active
148+
if proxy:
149+
kwargs = _rewrite_model_for_proxy(kwargs)
111150
return await litellm.acompletion(*args, **kwargs)
112151
except Exception as exc:
113152
translated = _maybe_translate_filter_error(exc)

0 commit comments

Comments
 (0)