Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 141 additions & 23 deletions src/sap_cloud_sdk/aicore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,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, reload_aicore_credentials
from .filtering import (
AzureContentFilter,
ContentFilter,
Expand All @@ -30,6 +30,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_API_KEY_ENV = "AICORE_PROXY_API_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,
Expand Down Expand Up @@ -118,14 +134,27 @@ 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/<model>`` is aliased to
``litellm_proxy/<model>`` 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.
Combined with the ``_clear_client_secret()`` mechanism (PR #257),
the secret is removed from env after the first LiteLLM call.

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
Expand All @@ -135,48 +164,137 @@ 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.
Model strings (e.g. ``sap/<model>``) are passed verbatim — no rewrite.
No AI Core credentials are written to env.
"""
import litellm as _litellm

api_key = os.environ.get(_PROXY_API_KEY_ENV, "")
_litellm.api_base = proxy_url
if api_key:
_litellm.api_key = api_key
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. The AI Core ``client_secret`` is fetched here
and removed from env after the first successful LiteLLM call
(PR #257 ``_clear_client_secret()`` mechanism).

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 # cleared after first LiteLLM call

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:
os.environ["AICORE_BASE_URL"] = base_url
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")


__all__ = [
"set_aicore_config",
"reload_aicore_credentials",
"set_filtering",
"disable_filtering",
"completion",
Expand Down
109 changes: 97 additions & 12 deletions src/sap_cloud_sdk/aicore/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@
re-raising as :class:`ContentFilteredError` so callers can rely on a
single exception type for "filter blocked you."

Credential handling
-------------------
CLIENT_SECRET minimisation (AFSDK-4291):
After the first successful LiteLLM call, ``AICORE_CLIENT_SECRET`` is removed
from ``os.environ``. LiteLLM has already captured the secret inside its token
creator closure at that point and no longer needs the env var. This minimises
the window of exposure to child processes and container introspection.

Credential rotation (reactive reload):
When a credential is rotated while the pod is running, LiteLLM's cached token
becomes invalid and the next token refresh raises ``litellm.AuthenticationError``.
The wrappers intercept this, reload credentials from the mounted secret volume
via :func:`reload_aicore_credentials`, and retry once. The secret is cleared
again after the retry succeeds.

Usage::

from sap_cloud_sdk.aicore import completion, ContentFilteredError
Expand All @@ -39,12 +54,64 @@

from __future__ import annotations

import logging
import os
import threading
from typing import Any

import litellm

from .filtering.filters import _parse_input_filter_error

logger = logging.getLogger(__name__)

# Tracks whether AICORE_CLIENT_SECRET has already been cleared after the first
# successful LiteLLM call. Reset when credentials are reloaded so the secret
# is cleared again after the retry succeeds.
_secret_lock = threading.Lock()
_secret_cleared = False


def _clear_client_secret() -> None:
"""Remove AICORE_CLIENT_SECRET from env after LiteLLM has cached the token.

Safe to call multiple times — subsequent calls are no-ops once cleared.
No-op in transparent TLS mode (secret was never written).
"""
global _secret_cleared
with _secret_lock:
if not _secret_cleared:
if os.environ.pop("AICORE_CLIENT_SECRET", None) is not None:
logger.info(
"AICORE_CLIENT_SECRET cleared from environment "
"after token acquisition (AFSDK-4291)"
)
_secret_cleared = True


def _reset_secret_cleared() -> None:
"""Allow _clear_client_secret() to fire again after a credential reload."""
global _secret_cleared
with _secret_lock:
_secret_cleared = False


def reload_aicore_credentials() -> None:
"""Re-read AI Core credentials from the mounted secret volume.

Called automatically by :func:`completion` and :func:`acompletion` when
LiteLLM raises ``AuthenticationError`` — covers credential rotation
(client_secret or mTLS certificate) without requiring a pod restart.

Safe to call manually if the application needs to force a reload, e.g.
after a deliberate secret rotation triggered by the operator.
"""
# Import here to avoid a circular import: completion ← __init__ ← completion
from sap_cloud_sdk.aicore import set_aicore_config
_reset_secret_cleared()
logger.info("AI Core credentials reloading after authentication failure")
set_aicore_config()


def _maybe_translate_filter_error(exc: BaseException) -> BaseException:
"""Return a :class:`ContentFilteredError` if ``exc`` is a wrapped
Expand All @@ -60,19 +127,29 @@ def _maybe_translate_filter_error(exc: BaseException) -> BaseException:


def completion(*args: Any, **kwargs: Any) -> Any:
"""Wrapper around :func:`litellm.completion` that normalises filter errors.
"""Wrapper around :func:`litellm.completion` that normalises filter errors
and handles credential rotation transparently.

After the first successful call, ``AICORE_CLIENT_SECRET`` is removed from
``os.environ`` — LiteLLM has captured it in its token creator closure and
no longer needs the env var (AFSDK-4291).

Forwards every argument unchanged. The only difference from calling
``litellm.completion`` directly is that an input-filter rejection
(which litellm wraps in ``APIConnectionError``) is re-raised as
:class:`ContentFilteredError`. Output-filter rejections already
surface as :class:`ContentFilteredError` via the SDK's transport patch
and pass through unchanged.
On ``AuthenticationError`` (e.g. rotated client_secret or mTLS cert),
reloads credentials from the mounted secret volume and retries once.

All other exceptions surface verbatim.
Model strings (e.g. ``sap/<model>``) 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.
"""
try:
return litellm.completion(*args, **kwargs)
result = litellm.completion(*args, **kwargs)
_clear_client_secret()
return result
except litellm.AuthenticationError:
reload_aicore_credentials()
result = litellm.completion(*args, **kwargs)
_clear_client_secret()
return result
except Exception as exc:
translated = _maybe_translate_filter_error(exc)
if translated is exc:
Expand All @@ -83,15 +160,23 @@ def completion(*args: Any, **kwargs: Any) -> Any:
async def acompletion(*args: Any, **kwargs: Any) -> Any:
"""Async wrapper around :func:`litellm.acompletion`.

Same translation semantics as :func:`completion`.
Same credential-minimisation and rotation semantics as :func:`completion`.
Model strings are passed verbatim to LiteLLM in all routing modes.
"""
try:
return await litellm.acompletion(*args, **kwargs)
result = await litellm.acompletion(*args, **kwargs)
_clear_client_secret()
return result
except litellm.AuthenticationError:
reload_aicore_credentials()
result = await litellm.acompletion(*args, **kwargs)
_clear_client_secret()
return result
except Exception as exc:
translated = _maybe_translate_filter_error(exc)
if translated is exc:
raise
raise translated from exc


__all__ = ["completion", "acompletion"]
__all__ = ["completion", "acompletion", "reload_aicore_credentials"]
Loading
Loading