Skip to content
Open
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.45.4"
version = "0.46.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
127 changes: 127 additions & 0 deletions src/sap_cloud_sdk/aicore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import logging
import os
import threading
from typing import Optional

from sap_cloud_sdk.core.secret_resolver import resolve_base_mount
Expand Down Expand Up @@ -175,8 +176,134 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None:
set_filtering()


def _get_secret_dir_mtime(instance_name: str = "aicore-instance") -> float:
"""Return the mtime of the AI Core secret directory, or 0.0 if it does not exist."""
secret_dir = os.path.join(resolve_base_mount(), "aicore", instance_name)
try:
return os.stat(secret_dir).st_mtime
except OSError:
return 0.0


@record_metrics(Module.AICORE, Operation.AICORE_WATCH_CONFIG)
def watch_aicore_config(
Comment thread
tiagoek marked this conversation as resolved.
instance_name: str = "aicore-instance",
interval: float = 60.0,
stop_event: threading.Event | None = None,
) -> threading.Thread:
"""Start a daemon thread that proactively reloads AI Core credentials
when the mounted secret volume changes.

Polls the secret directory mtime every ``interval`` seconds. On change,
calls :func:`set_aicore_config` before LiteLLM's cached OAuth token
expires — avoiding 401 errors entirely rather than recovering from them.

Kubernetes projected volumes perform an atomic symlink swap on rotation,
which changes the directory mtime. Both ``secret`` and ``projected``
volume types are covered.

Returns the daemon thread. Stop it cleanly via ``stop_event.set()``.

Each call starts a new daemon thread — avoid calling more than once per process.

Typical usage::

import threading
from sap_cloud_sdk.aicore import set_aicore_config, watch_aicore_config

set_aicore_config()

_stop = threading.Event()
watch_aicore_config(stop_event=_stop)
# at shutdown: _stop.set()
"""
if stop_event is None:
stop_event = threading.Event()

last_mtime = _get_secret_dir_mtime(instance_name)

def _watch() -> None:
nonlocal last_mtime
while not stop_event.wait(timeout=interval):
try:
current_mtime = _get_secret_dir_mtime(instance_name)
if current_mtime != last_mtime:
logger.info(
"AI Core secret volume changed — proactively reloading credentials"
)
set_aicore_config(instance_name=instance_name)
last_mtime = current_mtime
except Exception:
logger.exception("Error during proactive AI Core credential reload")

thread = threading.Thread(target=_watch, daemon=True, name="aicore-secret-watcher")
thread.start()
return thread


@record_metrics(Module.AICORE, Operation.AICORE_PATCH_LITELLM)
def patch_litellm_for_credential_rotation() -> None:
Comment thread
tiagoek marked this conversation as resolved.
"""Patch ``litellm.completion`` / ``litellm.acompletion`` globally so ALL callers
get transparent credential reload on ``AuthenticationError``.

LangGraph agents typically call ``litellm.completion`` through ``ChatLiteLLM``
(LangChain), bypassing the SDK's own ``completion()`` wrapper and its built-in
401-reload handler. Call this function once at agent startup to extend the same
reactive reload behaviour to **every** litellm caller in the process.

Idempotent — calling more than once has no additional effect.

Recommended startup pattern for LangGraph / ChatLiteLLM agents::

from sap_cloud_sdk.aicore import (
set_aicore_config,
patch_litellm_for_credential_rotation,
watch_aicore_config,
)

set_aicore_config() # load credentials
patch_litellm_for_credential_rotation() # reactive reload for ChatLiteLLM
watch_aicore_config() # proactive reload on secret rotation

Agents that already use the SDK's ``completion()`` / ``acompletion()`` wrappers
do not need this — those wrappers already handle 401s transparently.
"""
import litellm as _litellm

if getattr(_litellm, "_sap_aicore_patched", False):
return

_orig_completion = _litellm.completion
_orig_acompletion = _litellm.acompletion

def _completion(*args, **kwargs):
try:
return _orig_completion(*args, **kwargs)
except _litellm.AuthenticationError:
logger.info("AI Core credentials reloading after authentication failure")
set_aicore_config()
return _orig_completion(*args, **kwargs)

async def _acompletion(*args, **kwargs):
try:
return await _orig_acompletion(*args, **kwargs)
except _litellm.AuthenticationError:
logger.info("AI Core credentials reloading after authentication failure")
set_aicore_config()
return await _orig_acompletion(*args, **kwargs)

_litellm.completion = _completion
_litellm.acompletion = _acompletion
_litellm._sap_aicore_patched = True
logger.info(
"litellm patched for AI Core credential rotation — applies to all callers"
)


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

Credential rotation handling
----------------------------
When a credential (client_secret) is rotated while the pod is running,
LiteLLM's cached token becomes invalid and the next token refresh attempt
raises ``litellm.AuthenticationError``. The wrappers intercept this error,
reload credentials from the mounted secret volume via
:func:`sap_cloud_sdk.aicore.set_aicore_config`, and retry the call once.
The caller is unaffected — rotation is transparent. If the retry also
fails, the ``AuthenticationError`` propagates normally.

Usage::

from sap_cloud_sdk.aicore import completion, ContentFilteredError
Expand All @@ -39,12 +49,15 @@

from __future__ import annotations

import logging
from typing import Any

import litellm

from .filtering.filters import _parse_input_filter_error

logger = logging.getLogger(__name__)


def _maybe_translate_filter_error(exc: BaseException) -> BaseException:
"""Return a :class:`ContentFilteredError` if ``exc`` is a wrapped
Expand All @@ -60,19 +73,22 @@ 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.

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.

All other exceptions surface verbatim.
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.
"""
try:
return litellm.completion(*args, **kwargs)
except litellm.AuthenticationError:
# Local import avoids circular dep: completion ← __init__ ← completion
from sap_cloud_sdk.aicore import set_aicore_config

logger.info("AI Core credentials reloading after authentication failure")
set_aicore_config()
return litellm.completion(*args, **kwargs)
except Exception as exc:
translated = _maybe_translate_filter_error(exc)
if translated is exc:
Expand All @@ -83,10 +99,16 @@ 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 translation and credential-rotation semantics as :func:`completion`.
"""
try:
return await litellm.acompletion(*args, **kwargs)
except litellm.AuthenticationError:
from sap_cloud_sdk.aicore import set_aicore_config

logger.info("AI Core credentials reloading after authentication failure")
set_aicore_config()
return await litellm.acompletion(*args, **kwargs)
except Exception as exc:
translated = _maybe_translate_filter_error(exc)
if translated is exc:
Expand Down
75 changes: 75 additions & 0 deletions src/sap_cloud_sdk/aicore/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,81 @@ set_aicore_config(instance_name="aicore-production")

---

## Credential Rotation

BTP rotates AI Core service binding credentials automatically. The SDK
handles this transparently — no pod restart required.

### Reactive reload (completion wrapper)

`sap_cloud_sdk.aicore.completion` / `acompletion` catch `AuthenticationError`
(HTTP 401), reload credentials via `set_aicore_config()`, and retry the call
once. The caller never sees the error.

```python
from sap_cloud_sdk.aicore import completion, set_aicore_config

set_aicore_config()

# 401s are retried transparently — no extra code needed
response = completion(model="sap/anthropic--claude-4.5-sonnet", messages=[...])
```

### Proactive reload (recommended for all agents)

`watch_aicore_config()` starts a daemon thread that polls the mounted secret
directory every 60 seconds. When the directory mtime changes (Kubernetes
performs an atomic symlink swap on rotation), it calls `set_aicore_config()`
before the cached OAuth token expires — so agents never see a 401 at all.

```python
import threading
from sap_cloud_sdk.aicore import set_aicore_config, watch_aicore_config

set_aicore_config() # load credentials at startup
watch_aicore_config() # proactive reload on secret rotation

# Optional: stop cleanly at shutdown
_stop = threading.Event()
watch_aicore_config(stop_event=_stop)
# at shutdown: _stop.set()
```

### LangGraph / ChatLiteLLM agents

`ChatLiteLLM` (used in LangGraph agent templates) calls `litellm.completion`
directly, bypassing the SDK's reactive handler. Two options:

**Option A — proactive watcher only (recommended, one line):**

```python
from sap_cloud_sdk.aicore import set_aicore_config, watch_aicore_config

set_aicore_config()
watch_aicore_config() # ADD THIS — no other changes needed
```

**Option B — also add reactive reload for ChatLiteLLM:**

```python
from sap_cloud_sdk.aicore import (
set_aicore_config,
patch_litellm_for_credential_rotation,
watch_aicore_config,
)

set_aicore_config()
patch_litellm_for_credential_rotation() # patches litellm.completion globally
watch_aicore_config()
```

`patch_litellm_for_credential_rotation()` wraps `litellm.completion` /
`litellm.acompletion` globally so every caller in the process — including
`ChatLiteLLM` — gets transparent 401 reload. Idempotent; call it once at
startup.

---

## What It Does

The `set_aicore_config()` function:
Expand Down
2 changes: 2 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ class Operation(str, Enum):

# AI Core Operations
AICORE_SET_CONFIG = "set_aicore_config"
AICORE_WATCH_CONFIG = "watch_aicore_config"
AICORE_PATCH_LITELLM = "patch_litellm_for_credential_rotation"
AICORE_AUTO_INSTRUMENT = "auto_instrument"
AICORE_SET_FILTERING = "set_filtering"
AICORE_DISABLE_FILTERING = "disable_filtering"
Expand Down
Loading
Loading