Skip to content
Merged
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: 2 additions & 0 deletions src/sap_cloud_sdk/aicore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ContentFilter,
ContentFilteredError,
ContentFiltering,
ContentFilteringNotConfiguredError,
InputFiltering,
LlamaGuard38bFilter,
OrchestrationError,
Expand Down Expand Up @@ -457,5 +458,6 @@ async def _acompletion(*args, **kwargs):
"ContentFilter",
"Severity",
"ContentFilteredError",
"ContentFilteringNotConfiguredError",
"OrchestrationError",
]
31 changes: 31 additions & 0 deletions src/sap_cloud_sdk/aicore/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,13 @@
from .filtering.filters import _parse_input_filter_error
from .filtering import _patch as _filtering_patch
from .filtering._patch import _parse_output_filter_stream_error
from .filtering.config import load_strict_from_env
from .filtering.exceptions import ContentFilteringNotConfiguredError

logger = logging.getLogger(__name__)

_unconfigured_warned = False # emit the "never configured" warning at most once


@record_metrics(Module.AICORE, Operation.AICORE_REACTIVE_RELOAD)
def _reload_reactive() -> None:
Expand Down Expand Up @@ -107,6 +111,31 @@ def _output_filtering_active() -> bool:
return cfg is not None and cfg.output_filtering is not None


def _assert_filtering_ready() -> None:
"""Pre-flight guard against calling completion with filtering never configured.

Fires only when neither :func:`set_aicore_config` nor :func:`set_filtering`
has run in this process (``_filtering_patch._configured`` is ``False``). An
intentional opt-out — ``disable_filtering()`` or ``AICORE_FILTER_ENABLED=false``
— sets ``_configured`` and stays silent; the empty-config case is handled at
request time inside ``transform_request``. In strict mode this raises; in the
default mode it emits a one-time warning.
"""
global _unconfigured_warned
if _filtering_patch._configured:
return
if load_strict_from_env():
raise ContentFilteringNotConfiguredError(
"completion() called but content filtering was never configured"
)
if not _unconfigured_warned:
logger.warning(
"completion() called but content filtering was never configured — "
"request is UNFILTERED (call set_aicore_config() or set_filtering())"
)
_unconfigured_warned = True


class _FilteringStreamWrapper:
"""Sync iterator wrapper that converts in-stream output-filter errors."""

Expand Down Expand Up @@ -171,6 +200,7 @@ def completion(*args: Any, **kwargs: Any) -> Any:
in :func:`set_aicore_config`, not by rewriting the model name.
"""
is_streaming = kwargs.get("stream", False)
_assert_filtering_ready()
try:
result = litellm.completion(*args, **kwargs)
except litellm.AuthenticationError:
Expand All @@ -195,6 +225,7 @@ async def acompletion(*args: Any, **kwargs: Any) -> Any:
Model strings are passed verbatim to LiteLLM in all routing modes.
"""
is_streaming = kwargs.get("stream", False)
_assert_filtering_ready()
try:
result = await litellm.acompletion(*args, **kwargs)
except litellm.AuthenticationError:
Expand Down
7 changes: 6 additions & 1 deletion src/sap_cloud_sdk/aicore/filtering/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@
OutputFiltering,
Severity,
)
from .exceptions import ContentFilteredError, OrchestrationError
from .exceptions import (
ContentFilteredError,
ContentFilteringNotConfiguredError,
OrchestrationError,
)

__all__ = [
"set_filtering",
Expand All @@ -47,5 +51,6 @@
"ContentFilter",
"Severity",
"ContentFilteredError",
"ContentFilteringNotConfiguredError",
"OrchestrationError",
]
41 changes: 38 additions & 3 deletions src/sap_cloud_sdk/aicore/filtering/_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
``disable_filtering`` in :mod:`.filters`) and is idempotent — calling it
multiple times with the same config is safe.

Fail-open behaviour
-------------------
``transform_request`` must never silently drop the ``modules.filtering`` block.
If the active config is missing or serialises empty, :func:`_fail_open` is
invoked: in the default mode it emits a ``logging.WARNING`` and the request
proceeds unfiltered; in strict (fail-closed) mode — opt-in via
``AICORE_FILTER_STRICT`` or ``set_filtering(strict=True)`` — it raises
:class:`ContentFilteringNotConfiguredError` instead of sending an unfiltered
body.

Two filter-rejection shapes (from the v2 API) are handled in buffered mode:

- Input rejection: HTTP 4xx with ``error.location`` startswith
Expand Down Expand Up @@ -57,14 +67,31 @@
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
from litellm.types.utils import ModelResponse

from .exceptions import ContentFilteredError
from .exceptions import ContentFilteredError, ContentFilteringNotConfiguredError

logger = logging.getLogger(__name__)

# Keep the original so _install(None) can restore it.
_ORIGINAL_CONFIG = litellm.GenAIHubOrchestrationConfig

_active_cfg: Any = None # ContentFiltering | None, stored at module level
_strict_mode: bool = False # fail-closed: raise instead of sending unfiltered
_configured: bool = False # True once _install() has run (set or disable)


def _fail_open(reason: str) -> None:
"""Handle a would-be-unfiltered request.

In strict mode, raise :class:`ContentFilteringNotConfiguredError`. Otherwise
emit a ``logging.WARNING`` and let the caller send the request unfiltered.
"""
if _strict_mode:
raise ContentFilteringNotConfiguredError(
f"refusing to send unfiltered request: {reason}"
)
logger.warning(
"content filtering INACTIVE — request sent WITHOUT filter (%s)", reason
)


class FilteringOrchestrationConfig(GenAIHubOrchestrationConfig):
Expand All @@ -87,10 +114,15 @@ def transform_request(
)

if _active_cfg is None:
# Defensive: the subclass is installed iff _active_cfg is not None,
# so this fires only if litellm cached the patched class across a
# disable_filtering(). Treat as a fail-open condition.
_fail_open("set_filtering() not called or filter patch stale")
return body

filtering_dict = _active_cfg.to_dict()
if not filtering_dict:
_fail_open("content filtering config is empty")
return body

modules = body["config"]["modules"]
Expand Down Expand Up @@ -179,13 +211,16 @@ def transform_response(
)


def _install(cfg: Any) -> None: # cfg: ContentFiltering | None
def _install(cfg: Any, *, strict: bool = False) -> None: # cfg: ContentFiltering | None
"""Patch litellm.GenAIHubOrchestrationConfig. Idempotent.

cfg=None restores the original config and disables filtering.
strict=True enables fail-closed mode (raise instead of sending unfiltered).
"""
global _active_cfg
global _active_cfg, _strict_mode, _configured
_active_cfg = cfg
_strict_mode = strict
_configured = True
if cfg is None:
litellm.GenAIHubOrchestrationConfig = _ORIGINAL_CONFIG
logger.debug("content filtering disabled")
Expand Down
11 changes: 11 additions & 0 deletions src/sap_cloud_sdk/aicore/filtering/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- ``AICORE_FILTER_SEXUAL`` (int 0/2/4/6, default ``4``)
- ``AICORE_FILTER_SELF_HARM`` (int 0/2/4/6, default ``4``)
- ``AICORE_FILTER_PROMPT_SHIELD`` (bool, default ``true``) — input-only
- ``AICORE_FILTER_STRICT`` (bool, default ``false``) — fail-closed mode
"""

from __future__ import annotations
Expand Down Expand Up @@ -71,6 +72,16 @@ def _read_env_choice(key: str, choices: set[int], default: int) -> int:
return value


def load_strict_from_env() -> bool:
"""Return the fail-closed toggle from ``AICORE_FILTER_STRICT`` (default ``False``).

When ``True``, the SDK raises
:class:`sap_cloud_sdk.aicore.filtering.exceptions.ContentFilteringNotConfiguredError`
instead of sending an unfiltered request.
"""
return _read_env_bool("AICORE_FILTER_STRICT", default=False)


def load_from_env() -> ContentFiltering | None:
"""Build a :class:`ContentFiltering` from ``AICORE_FILTER_*`` env vars.

Expand Down
20 changes: 20 additions & 0 deletions src/sap_cloud_sdk/aicore/filtering/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,23 @@ def __init__(
super().__init__(
f"Content filter blocked the {direction} (request_id={request_id})"
)


class ContentFilteringNotConfiguredError(OrchestrationError):
"""Raised in strict mode when a request would be sent WITHOUT content filtering.

Strict (fail-closed) mode is opt-in via the ``AICORE_FILTER_STRICT``
environment variable or the ``strict=`` argument to
:func:`sap_cloud_sdk.aicore.set_filtering`. When active, the SDK refuses to
send an unfiltered orchestration request instead of silently failing open.

This is raised when:

- ``completion()`` is called but content filtering was never configured
(neither :func:`set_aicore_config` nor :func:`set_filtering` ran), or
- the active :class:`ContentFiltering` serialises to an empty wire payload
(e.g. ``ContentFiltering()`` with no input or output direction).

In the default (non-strict) mode these conditions emit a
``logging.WARNING`` and the request proceeds unfiltered.
"""
26 changes: 20 additions & 6 deletions src/sap_cloud_sdk/aicore/filtering/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@

from .models import ContentFiltering
from ._patch import _install
from .config import load_from_env
from .config import load_from_env, load_strict_from_env
from .exceptions import ContentFilteredError


@record_metrics(Module.AICORE, Operation.AICORE_SET_FILTERING)
def set_filtering(config: ContentFiltering | None = None) -> None:
def set_filtering(
config: ContentFiltering | None = None, *, strict: bool | None = None
) -> None:
"""Install a content-filtering configuration.

Args:
Expand All @@ -39,6 +41,11 @@ def set_filtering(config: ContentFiltering | None = None) -> None:
``AICORE_FILTER_ENABLED=false`` to keep filtering off. An
explicit non-``None`` config always activates filtering, even
when the env var would have disabled it.
strict: Enable fail-closed mode. When ``True``, a request that would be
sent without a content filter (empty config) raises
:class:`ContentFilteringNotConfiguredError` instead of proceeding
unfiltered. When ``None`` (the default), the value is read from the
``AICORE_FILTER_STRICT`` environment variable (default ``False``).

Examples:
Activate strict input filtering with Prompt Shield::
Expand All @@ -58,21 +65,28 @@ def set_filtering(config: ContentFiltering | None = None) -> None:
Re-apply env-based config after changing variables::

set_filtering()

Fail closed if filtering is ever misconfigured::

set_filtering(my_config, strict=True)
"""
if strict is None:
strict = load_strict_from_env()
if config is None:
_install(load_from_env())
_install(load_from_env(), strict=strict)
return
_install(config)
_install(config, strict=strict)


@record_metrics(Module.AICORE, Operation.AICORE_DISABLE_FILTERING)
def disable_filtering() -> None:
"""Disable content filtering for SAP AI Core model calls.

Restores the original ``litellm.GenAIHubOrchestrationConfig``.
Idempotent — safe to call when filtering is already disabled.
Idempotent — safe to call when filtering is already disabled. Disabling is
an explicit opt-out, so strict/fail-closed mode is cleared.
"""
_install(None)
_install(None, strict=False)


def _parse_input_filter_error(exc: Exception) -> ContentFilteredError | None:
Expand Down
28 changes: 28 additions & 0 deletions src/sap_cloud_sdk/aicore/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Set these **before** calling `set_aicore_config()`:
| `AICORE_FILTER_SEXUAL` | `4` | Azure severity threshold |
| `AICORE_FILTER_SELF_HARM` | `4` | Azure severity threshold |
| `AICORE_FILTER_PROMPT_SHIELD` | `true` | Enable/disable prompt shield |
| `AICORE_FILTER_STRICT` | `false` | Fail-closed mode — raise instead of sending an unfiltered request |

Example — strict self-harm and violence:

Expand Down Expand Up @@ -338,6 +339,33 @@ Or disable entirely via env (before `set_aicore_config()`):
AICORE_FILTER_ENABLED=false
```

### Strict (fail-closed) mode

By default, if a request would be sent without a content filter — because
filtering was never configured, or because the active configuration is empty —
the SDK emits a `logging.WARNING` and sends the request **unfiltered**. This
fail-open behaviour keeps calls working, but a silently-disabled filter can go
unnoticed.

Enable **strict mode** to fail closed instead: the SDK raises
`ContentFilteringNotConfiguredError` rather than sending an unfiltered body.

```python
from sap_cloud_sdk.aicore import set_filtering

set_filtering(my_config, strict=True) # programmatic
```

```bash
AICORE_FILTER_STRICT=true # or via env, before set_aicore_config()
```

The `strict=` argument takes precedence over `AICORE_FILTER_STRICT`; when it is
omitted (or `None`), the env var decides (default `false`). `disable_filtering()`
is an explicit opt-out and always clears strict mode. The strict guard covers
both calling `completion()` before any filtering setup and an empty
`ContentFiltering()` config.

### Handle blocked requests

Use `sap_cloud_sdk.aicore.completion` (or `acompletion` for the async path)
Expand Down
43 changes: 43 additions & 0 deletions tests/aicore/filtering/unit/test_filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,41 @@ def test_multi_filter_input(self, monkeypatch):
assert filters[0].provider == "azure_content_safety"
assert filters[1].provider == "llama_guard_3_8b"

def test_strict_param_sets_mode(self, monkeypatch):
_clear_aicore_env(monkeypatch)
from sap_cloud_sdk.aicore.filtering import _patch as _filters_mod

set_filtering(
ContentFiltering(
input_filtering=InputFiltering(filters=[AzureContentFilter()])
),
strict=True,
)
assert _filters_mod._strict_mode is True

def test_strict_defaults_to_env(self, monkeypatch):
_clear_aicore_env(monkeypatch)
monkeypatch.setenv("AICORE_FILTER_STRICT", "true")
from sap_cloud_sdk.aicore.filtering import _patch as _filters_mod

set_filtering()
assert _filters_mod._strict_mode is True

def test_strict_off_by_default(self, monkeypatch):
_clear_aicore_env(monkeypatch)
from sap_cloud_sdk.aicore.filtering import _patch as _filters_mod

set_filtering()
assert _filters_mod._strict_mode is False

def test_explicit_strict_overrides_env(self, monkeypatch):
_clear_aicore_env(monkeypatch)
monkeypatch.setenv("AICORE_FILTER_STRICT", "true")
from sap_cloud_sdk.aicore.filtering import _patch as _filters_mod

set_filtering(strict=False)
assert _filters_mod._strict_mode is False


class TestDisableFiltering:
def test_disable_restores_original_config(self, monkeypatch):
Expand All @@ -124,6 +159,14 @@ def test_disable_is_idempotent(self, monkeypatch):
disable_filtering()
assert litellm.GenAIHubOrchestrationConfig is _ORIGINAL_CONFIG

def test_disable_clears_strict(self, monkeypatch):
_clear_aicore_env(monkeypatch)
from sap_cloud_sdk.aicore.filtering import _patch as _filters_mod

set_filtering(strict=True)
disable_filtering()
assert _filters_mod._strict_mode is False

def test_disable_when_never_enabled(self, monkeypatch):
# disable_filtering() before any set_filtering() is a clean no-op:
# litellm config stays at the original AND _active_cfg stays cleared.
Expand Down
Loading
Loading