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
10 changes: 5 additions & 5 deletions src/sap_cloud_sdk/aicore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,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"):
Expand All @@ -156,14 +156,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")
Expand Down
40 changes: 30 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,21 @@ def _maybe_translate_filter_error(exc: BaseException) -> BaseException:


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

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.
"""Wrapper around :func:`litellm.completion` that normalises filter errors
and handles credential rotation transparently.

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 +98,15 @@ 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
2 changes: 2 additions & 0 deletions tests/aicore/unit/test_aicore.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,3 +710,5 @@ 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


126 changes: 123 additions & 3 deletions tests/aicore/unit/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
- :class:`ContentFilteredError` already raised by the transport patch
passes through unchanged (we don't double-wrap).
- ``acompletion`` exhibits the same behaviour on the async path.
- On ``AuthenticationError``, credentials are reloaded and the call is
retried once (credential rotation without pod restart).
"""

from __future__ import annotations

import asyncio
import json
from unittest.mock import patch
from unittest.mock import MagicMock, call, patch

import litellm
import pytest

from sap_cloud_sdk.aicore import acompletion, completion
Expand Down Expand Up @@ -187,13 +190,130 @@ async def fake_acompletion(**kwargs):
def test_non_filter_exception_surfaces_verbatim(self):
raised = _FakeAPIConnectionError("SapException - other transport error")

async def fake_acompletion(**kwargs):
async def fake_acompletion_non_filter(**kwargs):
raise raised

with patch(
"sap_cloud_sdk.aicore.completion.litellm.acompletion",
side_effect=fake_acompletion,
side_effect=fake_acompletion_non_filter,
):
with pytest.raises(_FakeAPIConnectionError) as ei:
asyncio.run(acompletion(model="sap/x", messages=[]))
assert ei.value is raised


# ---------------------------------------------------------------------------
# Reactive reload on AuthenticationError — sync
# ---------------------------------------------------------------------------


class TestCompletionReactiveReload:
def test_auth_error_triggers_reload_and_retry_succeeds(self):
"""On AuthenticationError, credentials reload and second call succeeds."""
sentinel = object()
auth_err = litellm.AuthenticationError(
message="401 Unauthorized", llm_provider="sap", model="sap/x"
)
call_returns = [auth_err, sentinel]

def fake_completion(*args, **kwargs):
result = call_returns.pop(0)
if isinstance(result, Exception):
raise result
return result

with (
patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=fake_completion),
patch("sap_cloud_sdk.aicore.set_aicore_config") as mock_reload,
):
result = completion(model="sap/x", messages=[])

assert result is sentinel
mock_reload.assert_called_once_with()

def test_auth_error_retry_also_fails_propagates(self):
"""If the retry also raises AuthenticationError, it propagates to the caller."""
auth_err = litellm.AuthenticationError(
message="401 Unauthorized", llm_provider="sap", model="sap/x"
)

with (
patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=auth_err),
patch("sap_cloud_sdk.aicore.set_aicore_config"),
):
with pytest.raises(litellm.AuthenticationError):
completion(model="sap/x", messages=[])

def test_auth_error_reload_called_exactly_once(self):
"""Reload is called exactly once — no infinite retry loop."""
auth_err = litellm.AuthenticationError(
message="401", llm_provider="sap", model="sap/x"
)
mock_litellm = MagicMock(side_effect=auth_err)

with (
patch("sap_cloud_sdk.aicore.completion.litellm.completion", mock_litellm),
patch("sap_cloud_sdk.aicore.set_aicore_config") as mock_reload,
):
with pytest.raises(litellm.AuthenticationError):
completion(model="sap/x", messages=[])

mock_reload.assert_called_once()
assert mock_litellm.call_count == 2

def test_non_auth_error_does_not_trigger_reload(self):
"""Non-authentication errors do not trigger a credential reload."""
raised = ValueError("some other error")

with (
patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=raised),
patch("sap_cloud_sdk.aicore.set_aicore_config") as mock_reload,
):
with pytest.raises(ValueError):
completion(model="sap/x", messages=[])

mock_reload.assert_not_called()


# ---------------------------------------------------------------------------
# Reactive reload on AuthenticationError — async
# ---------------------------------------------------------------------------


class TestACompletionReactiveReload:
def test_auth_error_triggers_reload_and_retry_succeeds(self):
sentinel = object()
auth_err = litellm.AuthenticationError(
message="401 Unauthorized", llm_provider="sap", model="sap/x"
)
call_returns = [auth_err, sentinel]

async def fake_acompletion(*args, **kwargs):
result = call_returns.pop(0)
if isinstance(result, Exception):
raise result
return result

with (
patch("sap_cloud_sdk.aicore.completion.litellm.acompletion", side_effect=fake_acompletion),
patch("sap_cloud_sdk.aicore.set_aicore_config") as mock_reload,
):
result = asyncio.run(acompletion(model="sap/x", messages=[]))

assert result is sentinel
mock_reload.assert_called_once_with()

def test_auth_error_retry_also_fails_propagates(self):
auth_err = litellm.AuthenticationError(
message="401", llm_provider="sap", model="sap/x"
)

async def fake_acompletion(*args, **kwargs):
raise auth_err

with (
patch("sap_cloud_sdk.aicore.completion.litellm.acompletion", side_effect=fake_acompletion),
patch("sap_cloud_sdk.aicore.set_aicore_config"),
):
with pytest.raises(litellm.AuthenticationError):
asyncio.run(acompletion(model="sap/x", messages=[]))
Loading