From ba5ed26ff7067eca1c8104c9f16220f4656ee7a9 Mon Sep 17 00:00:00 2001 From: I561719 Date: Fri, 19 Jun 2026 15:58:34 +0200 Subject: [PATCH 1/4] feat(orchestration): add content filtering and prompt shield module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activates Azure Content Safety filtering and prompt attack detection automatically for all SAP AI Core model calls. Filtering is enabled by default when set_aicore_config() is called — no code change required by the developer. - New module sap_cloud_sdk.orchestration with: - FilteringModuleConfig: configures input/output filtering thresholds and prompt shield via ORCH_FILTER_* env vars (defaults: threshold 4, prompt_shield=True on input) - set_filtering(): programmatic override for thresholds at runtime - ContentFilteredError: raised when input or output is rejected by the content filter - extract_filter_blocked(): unwraps filter rejections embedded in LiteLLM APIConnectionError exceptions - set_aicore_config() now calls _activate_filtering() at the end, applying FilteringModuleConfig.from_env() to LiteLLM's SAP provider - Observability preserved: LiteLLM still makes the HTTP call; Traceloop/OTel instrumentation is unaffected - 41 unit tests covering serialisation, env parsing, LiteLLM patch, response detection, and set_filtering() behaviour - User guides updated in aicore/ and orchestration/; README breaking change notice added - Version bump 0.27.1 → 0.28.0 --- README.md | 7 + pyproject.toml | 2 +- src/sap_cloud_sdk/aicore/__init__.py | 11 + src/sap_cloud_sdk/aicore/user-guide.md | 51 ++++ src/sap_cloud_sdk/core/telemetry/module.py | 1 + src/sap_cloud_sdk/core/telemetry/operation.py | 3 + src/sap_cloud_sdk/orchestration/__init__.py | 107 ++++++++ .../orchestration/_litellm_patch.py | 185 ++++++++++++++ src/sap_cloud_sdk/orchestration/_models.py | 135 ++++++++++ src/sap_cloud_sdk/orchestration/exceptions.py | 39 +++ src/sap_cloud_sdk/orchestration/py.typed | 0 src/sap_cloud_sdk/orchestration/user-guide.md | 147 +++++++++++ tests/orchestration/__init__.py | 0 tests/orchestration/unit/__init__.py | 0 tests/orchestration/unit/test_models.py | 149 +++++++++++ tests/orchestration/unit/test_patch.py | 232 ++++++++++++++++++ .../orchestration/unit/test_set_filtering.py | 74 ++++++ 17 files changed, 1142 insertions(+), 1 deletion(-) create mode 100644 src/sap_cloud_sdk/orchestration/__init__.py create mode 100644 src/sap_cloud_sdk/orchestration/_litellm_patch.py create mode 100644 src/sap_cloud_sdk/orchestration/_models.py create mode 100644 src/sap_cloud_sdk/orchestration/exceptions.py create mode 100644 src/sap_cloud_sdk/orchestration/py.typed create mode 100644 src/sap_cloud_sdk/orchestration/user-guide.md create mode 100644 tests/orchestration/__init__.py create mode 100644 tests/orchestration/unit/__init__.py create mode 100644 tests/orchestration/unit/test_models.py create mode 100644 tests/orchestration/unit/test_patch.py create mode 100644 tests/orchestration/unit/test_set_filtering.py diff --git a/README.md b/README.md index da768b4b..b94159f6 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi - **AI Core Integration** - **Audit Log Service** - **Audit Log NG** +- **Content Filtering & Prompt Shield** *(enabled by default from 0.28.0)* - **Destination Service** - **Document Management Service** - **Extensibility** @@ -26,6 +27,12 @@ The Python SDK offers a clean, type-safe API following Python best practices whi - **Data Anonymization Service** - **Print Service** +> **Breaking change in 0.28.0:** `set_aicore_config()` now automatically enables +> Azure Content Safety filtering and prompt shield for all SAP AI Core model calls. +> No code change is required. To disable: set `ORCH_FILTER_ENABLED=false` or call +> `set_filtering(enabled=False)` after `set_aicore_config()`. +> See the [Orchestration user guide](src/sap_cloud_sdk/orchestration/user-guide.md). + ## Requirements and Setup - **Python**: 3.11 or higher diff --git a/pyproject.toml b/pyproject.toml index 641483d7..60d58226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.27.1" +version = "0.28.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 85b7d50b..3a0e49e1 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -145,5 +145,16 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: # Log configuration completion (excluding sensitive information) logger.info("AI Core configuration has been set successfully") + # Activate content filtering for all sap/* LiteLLM model calls. + # Lazy import avoids circular dependency between aicore and orchestration. + # Filtering is ON by default (threshold 4, prompt_shield=True). + # Set ORCH_FILTER_ENABLED=false to disable, or call set_filtering() to override. + try: + from sap_cloud_sdk.orchestration._litellm_patch import _install + from sap_cloud_sdk.orchestration._models import FilteringModuleConfig + _install(FilteringModuleConfig.from_env()) + except Exception as e: + logger.warning("Could not activate orchestration filtering: %s", e) + __all__ = ["set_aicore_config"] diff --git a/src/sap_cloud_sdk/aicore/user-guide.md b/src/sap_cloud_sdk/aicore/user-guide.md index 647f402c..98e701a5 100644 --- a/src/sap_cloud_sdk/aicore/user-guide.md +++ b/src/sap_cloud_sdk/aicore/user-guide.md @@ -56,6 +56,57 @@ The `set_aicore_config()` function: 2. **Configures environment variables** for LiteLLM to use AI Core 3. **Normalizes URLs** by adding required suffixes (`/oauth/token` for auth, `/v2` for base URL) 4. **Sets resource group** (defaults to "default" if not specified) +5. **Activates content filtering** — Azure Content Safety + prompt shield enabled by default *(new in 0.28.0)* + +--- + +## Content Filtering (enabled by default from 0.28.0) + +`set_aicore_config()` automatically activates content filtering for all model calls. +No additional code is required. + +**Default thresholds:** + +| Category | Default | Meaning | +|---|---|---| +| Hate, Violence, Sexual, Self-harm | `4` | Block medium+ severity | +| Prompt shield | `true` | Block jailbreak + indirect injection attempts | + +To override thresholds via environment variables (set before calling `set_aicore_config()`): + +```bash +ORCH_FILTER_SELF_HARM=0 # strict — block any detected self-harm content +ORCH_FILTER_VIOLENCE=2 +ORCH_FILTER_ENABLED=false # disable filtering entirely +``` + +To override programmatically (call after `set_aicore_config()`): + +```python +from sap_cloud_sdk.orchestration import set_filtering +set_filtering(self_harm=0, violence=0) +``` + +To catch blocked requests: + +```python +from sap_cloud_sdk.orchestration import ContentFilteredError +from sap_cloud_sdk.orchestration._litellm_patch import extract_filter_blocked + +try: + response = completion(model="sap/anthropic--claude-4.5-sonnet", messages=[...]) +except ContentFilteredError as e: + print("Blocked by content safety policy.") +except Exception as e: + blocked = extract_filter_blocked(e) + if blocked: + print("Blocked by content safety policy.") + raise +``` + +See the [Orchestration user guide](../orchestration/user-guide.md) for full documentation. + +--- ### Credentials Loaded diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index d1f605f6..9434a00c 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -17,6 +17,7 @@ class Module(str, Enum): DMS = "dms" EXTENSIBILITY = "extensibility" OBJECTSTORE = "objectstore" + ORCHESTRATION = "orchestration" PRINT = "print" TELEMETRY = "telemetry" diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index e44ab641..8cb906f6 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -201,5 +201,8 @@ class Operation(str, Enum): AGENT_MEMORY_GET_RETENTION_CONFIG = "get_retention_config" AGENT_MEMORY_UPDATE_RETENTION_CONFIG = "update_retention_config" + # Orchestration Operations + ORCHESTRATION_SET_FILTERING = "set_filtering" + def __str__(self) -> str: return self.value diff --git a/src/sap_cloud_sdk/orchestration/__init__.py b/src/sap_cloud_sdk/orchestration/__init__.py new file mode 100644 index 00000000..e5b5dd0d --- /dev/null +++ b/src/sap_cloud_sdk/orchestration/__init__.py @@ -0,0 +1,107 @@ +"""SAP AI Core Orchestration — content filtering and prompt shield. + +Filtering is **enabled by default** when ``set_aicore_config()`` is called. +No additional code is required. To override thresholds, use ``set_filtering()`` +or set ``ORCH_FILTER_*`` environment variables. + +See user-guide.md for full documentation. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +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 ._litellm_patch import _install, extract_filter_blocked +from ._models import ContentFilterConfig, FilteringModuleConfig, PromptShieldConfig +from .exceptions import ContentFilteredError, OrchestrationError + +logger = logging.getLogger(__name__) + +__all__ = [ + "set_filtering", + "ContentFilterConfig", + "PromptShieldConfig", + "FilteringModuleConfig", + "ContentFilteredError", + "OrchestrationError", + "extract_filter_blocked", +] + + +@record_metrics(Module.ORCHESTRATION, Operation.ORCHESTRATION_SET_FILTERING) +def set_filtering( + *, + hate: Literal[0, 2, 4, 6] | None = None, + violence: Literal[0, 2, 4, 6] | None = None, + sexual: Literal[0, 2, 4, 6] | None = None, + self_harm: Literal[0, 2, 4, 6] | None = None, + prompt_shield: bool | None = None, + directions: set[Literal["input", "output"]] | None = None, + enabled: bool | None = None, +) -> None: + """Override content filtering thresholds programmatically. + + Filtering is already activated by ``set_aicore_config()`` — this function + is only needed to override specific thresholds at runtime. Any argument + not provided retains its current value (from env vars or defaults). + + Args: + hate: Azure Content Safety hate severity. 0=strict, 2=low+, 4=medium+ (default), 6=off. + violence: Azure Content Safety violence severity. + sexual: Azure Content Safety sexual severity. + self_harm: Azure Content Safety self-harm severity. + prompt_shield: Enable/disable jailbreak + indirect injection detection (input-only). + directions: Set of directions to filter. Default is ``{"input", "output"}``. + enabled: Set ``False`` to disable filtering entirely. + + Examples: + Tighten two thresholds:: + + set_filtering(self_harm=0, violence=0) + + Disable filtering entirely:: + + set_filtering(enabled=False) + """ + if enabled is False: + _install(None) + return + + # No args at all — just re-apply env-based config (respects ORCH_FILTER_ENABLED) + if all(v is None for v in [hate, violence, sexual, self_harm, prompt_shield, directions, enabled]): + _install(FilteringModuleConfig.from_env()) + return + + # Some args provided — start from env-based config then override + base = FilteringModuleConfig.from_env() or FilteringModuleConfig() + + # Build effective threshold — override only the provided args. + def _effective_filter(existing: ContentFilterConfig | None) -> ContentFilterConfig | None: + if existing is None and directions is not None and "input" not in directions: + return None + src = existing or ContentFilterConfig() + return ContentFilterConfig( + hate=hate if hate is not None else src.hate, + violence=violence if violence is not None else src.violence, + sexual=sexual if sexual is not None else src.sexual, + self_harm=self_harm if self_harm is not None else src.self_harm, + ) + + new_input = _effective_filter(base.input_filter) if (directions is None or "input" in (directions or {"input", "output"})) else None + new_output = _effective_filter(base.output_filter) if (directions is None or "output" in (directions or {"input", "output"})) else None + + new_shield = base.prompt_shield + if prompt_shield is not None: + new_shield = PromptShieldConfig(enabled=prompt_shield) + + cfg = FilteringModuleConfig( + input_filter=new_input, + output_filter=new_output, + prompt_shield=new_shield, + ) + _install(cfg) diff --git a/src/sap_cloud_sdk/orchestration/_litellm_patch.py b/src/sap_cloud_sdk/orchestration/_litellm_patch.py new file mode 100644 index 00000000..5dafcfbf --- /dev/null +++ b/src/sap_cloud_sdk/orchestration/_litellm_patch.py @@ -0,0 +1,185 @@ +"""LiteLLM provider patch that injects content filtering into SAP Orchestration v2 calls. + +Patches ``litellm.GenAIHubOrchestrationConfig`` with a subclass that: +- Injects ``modules.filtering`` into every v2 completion request body +- Detects filter rejections in responses and raises ``ContentFilteredError`` + +The patch is applied by ``_install(cfg)`` and undone by ``_install(None)``. +It is idempotent — calling it multiple times with the same config is safe. + +Two filter rejection shapes (from the v2 API) are handled: +- Input rejection: HTTP 4xx, ``error.location`` startswith + ``"Filtering Module - Input Filter"`` (content-filtering.md L130-162) +- Output rejection: HTTP 200, ``finish_reason == "content_filter"``, + empty ``message.content`` (content-filtering.md L234-303) + +LiteLLM's ``raise_for_status()`` turns 4xx responses into +``httpx.HTTPStatusError`` before ``transform_response`` is reached, +so input-filter 400s arrive wrapped in a ``litellm.APIConnectionError`` +with the JSON embedded in the exception message. +``extract_filter_blocked()`` handles that case. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import litellm +from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig +from litellm.types.utils import ModelResponse + +from .exceptions import ContentFilteredError + +logger = logging.getLogger(__name__) + +# Keep the original so _install(None) can restore it. +_ORIGINAL_CONFIG = litellm.GenAIHubOrchestrationConfig + +_active_cfg: Any = None # FilteringModuleConfig | None, stored at module level + + +class FilteringOrchestrationConfig(GenAIHubOrchestrationConfig): + """GenAIHubOrchestrationConfig subclass that injects content filtering.""" + + def transform_request( + self, + model: str, + messages: list, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + body = super().transform_request( + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + + if _active_cfg is None: + return body + + filtering_dict = _active_cfg.to_dict() + if not filtering_dict: + return body + + modules = body["config"]["modules"] + if isinstance(modules, list): + # Fallback mode (list of configs) — inject into primary config only. + modules[0]["filtering"] = filtering_dict + else: + modules["filtering"] = filtering_dict + + return body + + def transform_response( + self, + model: str, + raw_response: Any, + model_response: ModelResponse, + logging_obj: Any, + request_data: dict, + messages: list, + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: str | None = None, + json_mode: bool | None = None, + ) -> ModelResponse: + status = raw_response.status_code + + # Input-filter rejection (HTTP 4xx). + # content-filtering.md L130-162: error.location identifies the filter module. + if 400 <= status < 500: + try: + err = raw_response.json().get("error", {}) + if (err.get("location") or "").startswith("Filtering Module - Input Filter"): + data = err.get("intermediate_results", {}).get("input_filtering", {}).get("data", {}) + raise ContentFilteredError( + direction="input", + details=data, + request_id=err.get("request_id"), + ) + except ContentFilteredError: + raise + except Exception: + pass + + # Output-filter rejection (HTTP 200 + finish_reason == "content_filter"). + # content-filtering.md L234-303: message.content is "" (empty, not absent). + if status == 200: + try: + payload = raw_response.json() + choices = (payload.get("final_result") or {}).get("choices") or [] + if choices and choices[0].get("finish_reason") == "content_filter": + data = payload.get("intermediate_results", {}).get("output_filtering", {}).get("data", {}) + raise ContentFilteredError( + direction="output", + details=data, + request_id=payload.get("request_id"), + ) + except ContentFilteredError: + raise + except Exception: + pass + + return super().transform_response( + model=model, + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + api_key=api_key, + json_mode=json_mode, + ) + + +def _install(cfg: Any) -> None: # cfg: FilteringModuleConfig | None + """Patch litellm.GenAIHubOrchestrationConfig. Idempotent. + + cfg=None restores the original config and disables filtering. + """ + global _active_cfg + _active_cfg = cfg + if cfg is None: + litellm.GenAIHubOrchestrationConfig = _ORIGINAL_CONFIG # type: ignore[attr-defined] + logger.debug("orchestration filtering disabled") + else: + litellm.GenAIHubOrchestrationConfig = FilteringOrchestrationConfig # type: ignore[attr-defined] + logger.info("orchestration filtering active (FilteringOrchestrationConfig)") + + +def extract_filter_blocked(exc: Exception) -> ContentFilteredError | None: + """Parse a LiteLLM APIConnectionError for an input-filter rejection. + + When Azure Content Safety blocks the input, LiteLLM's ``raise_for_status()`` + converts the 400 into an ``httpx.HTTPStatusError``, which is then wrapped + into a ``litellm.APIConnectionError`` with the original JSON embedded in + the exception message string. This function extracts it. + + Returns None if the exception is not a content-filter rejection. + """ + msg = str(exc) + brace = msg.find("{") + if brace == -1: + return None + try: + payload = json.loads(msg[brace:]) + err = payload.get("error", {}) + if not (err.get("location") or "").startswith("Filtering Module - Input Filter"): + return None + data = err.get("intermediate_results", {}).get("input_filtering", {}).get("data", {}) + return ContentFilteredError( + direction="input", + details=data, + request_id=err.get("request_id"), + ) + except Exception: + return None diff --git a/src/sap_cloud_sdk/orchestration/_models.py b/src/sap_cloud_sdk/orchestration/_models.py new file mode 100644 index 00000000..cfda2800 --- /dev/null +++ b/src/sap_cloud_sdk/orchestration/_models.py @@ -0,0 +1,135 @@ +"""Filtering configuration dataclasses for SAP AI Core Orchestration v2.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Literal + +_TRUTHY = frozenset({"true", "1", "yes"}) +_VALID_SEVERITIES = frozenset({0, 2, 4, 6}) + + +def _env(key: str, default: str = "") -> str: + return os.environ.get(key, default).strip() + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = os.environ.get(key) + return (raw.strip().lower() in _TRUTHY) if raw is not None else default + + +def _env_severity(key: str, default: int = 4) -> int: + raw = _env(key, str(default)) + try: + val = int(raw) + except ValueError as e: + raise ValueError(f"{key} must be one of 0/2/4/6, got {raw!r}") from e + if val not in _VALID_SEVERITIES: + raise ValueError(f"{key} must be one of 0/2/4/6, got {val}") + return val + + +@dataclass +class ContentFilterConfig: + """Azure Content Safety severity thresholds. + + Severity scale: 0 = strict (block any detected content), + 2 = low+, 4 = medium+ (default), 6 = off. + + Wire fields: filters[].config.hate/violence/sexual/self_harm + """ + + hate: Literal[0, 2, 4, 6] = 4 + violence: Literal[0, 2, 4, 6] = 4 + sexual: Literal[0, 2, 4, 6] = 4 + self_harm: Literal[0, 2, 4, 6] = 4 + + +@dataclass +class PromptShieldConfig: + """Prompt-attack (jailbreak + indirect injection) detection. + + Input-only. Wire field: filters[].config.prompt_shield + """ + + enabled: bool = True + + +@dataclass +class FilteringModuleConfig: + """Content filtering for input and/or output of SAP AI Core model calls. + + Default: both directions active, threshold 4/4/4/4, prompt_shield=True. + Construct directly or use ``from_env()`` to read ORCH_FILTER_* env vars. + Call ``to_dict()`` to get the wire-format dict for the v2 request body. + """ + + input_filter: ContentFilterConfig | None = field(default_factory=ContentFilterConfig) + output_filter: ContentFilterConfig | None = field(default_factory=ContentFilterConfig) + prompt_shield: PromptShieldConfig | None = field(default_factory=PromptShieldConfig) + + @classmethod + def from_env(cls) -> FilteringModuleConfig | None: + """Build from ORCH_FILTER_* environment variables. + + Returns None when ORCH_FILTER_ENABLED=false, disabling filtering entirely. + All variables are optional — safe defaults (threshold 4, prompt_shield=True) + are used when not set. + """ + if not _env_bool("ORCH_FILTER_ENABLED", default=True): + return None + + directions_raw = _env("ORCH_FILTER_DIRECTIONS", "input,output") + directions = {d.strip() for d in directions_raw.split(",") if d.strip()} + + thresholds = ContentFilterConfig( + hate=_env_severity("ORCH_FILTER_HATE"), + violence=_env_severity("ORCH_FILTER_VIOLENCE"), + sexual=_env_severity("ORCH_FILTER_SEXUAL"), + self_harm=_env_severity("ORCH_FILTER_SELF_HARM"), + ) + prompt_shield = PromptShieldConfig( + enabled=_env_bool("ORCH_FILTER_PROMPT_SHIELD", default=True) + ) + + return cls( + input_filter=thresholds if "input" in directions else None, + output_filter=thresholds if "output" in directions else None, + prompt_shield=prompt_shield if "input" in directions else None, + ) + + def to_dict(self) -> dict: + """Serialise to the v2 modules.filtering wire format. + + Wire shape (content-filtering.md L80-114): + { + "input": {"filters": [{"type": "azure_content_safety", "config": {...}}]}, + "output": {"filters": [{"type": "azure_content_safety", "config": {...}}]} + } + prompt_shield is input-only (content-filtering.md L89). + A direction key is omitted when its filter is None. + """ + result: dict = {} + + if self.input_filter is not None: + config: dict = { + "hate": self.input_filter.hate, + "violence": self.input_filter.violence, + "sexual": self.input_filter.sexual, + "self_harm": self.input_filter.self_harm, + } + if self.prompt_shield is not None and self.prompt_shield.enabled: + config["prompt_shield"] = True + result["input"] = {"filters": [{"type": "azure_content_safety", "config": config}]} + + if self.output_filter is not None: + config = { + "hate": self.output_filter.hate, + "violence": self.output_filter.violence, + "sexual": self.output_filter.sexual, + "self_harm": self.output_filter.self_harm, + } + result["output"] = {"filters": [{"type": "azure_content_safety", "config": config}]} + + return result diff --git a/src/sap_cloud_sdk/orchestration/exceptions.py b/src/sap_cloud_sdk/orchestration/exceptions.py new file mode 100644 index 00000000..87d8df8a --- /dev/null +++ b/src/sap_cloud_sdk/orchestration/exceptions.py @@ -0,0 +1,39 @@ +"""Exceptions for the orchestration module.""" + +from __future__ import annotations + +from typing import Any, Literal + + +class OrchestrationError(Exception): + """Base exception for orchestration module errors.""" + + +class ContentFilteredError(OrchestrationError): + """Raised when the orchestration filtering module rejects input or output. + + Attributes: + direction: ``"input"`` if the user prompt was rejected (HTTP 4xx, + ``error.location`` startswith ``"Filtering Module - Input Filter"``), + or ``"output"`` if the model response was rejected (HTTP 200, + ``finish_reason == "content_filter"``). + details: Severity scalars from ``intermediate_results.{input,output}_filtering.data``. + Safe to log; does NOT include raw prompt or completion content. + request_id: ``error.request_id`` (input) or top-level ``request_id`` (output). + """ + + direction: Literal["input", "output"] + details: dict[str, Any] + request_id: str | None + + def __init__( + self, + *, + direction: Literal["input", "output"], + details: dict[str, Any], + request_id: str | None, + ) -> None: + self.direction = direction + self.details = details + self.request_id = request_id + super().__init__(f"Content filter blocked the {direction} (request_id={request_id})") diff --git a/src/sap_cloud_sdk/orchestration/py.typed b/src/sap_cloud_sdk/orchestration/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/sap_cloud_sdk/orchestration/user-guide.md b/src/sap_cloud_sdk/orchestration/user-guide.md new file mode 100644 index 00000000..2738da2e --- /dev/null +++ b/src/sap_cloud_sdk/orchestration/user-guide.md @@ -0,0 +1,147 @@ +# Orchestration — Content Filtering & Prompt Shield + +This module activates Azure Content Safety filtering and prompt attack detection +for all SAP AI Core model calls. It is part of `sap-cloud-sdk` and requires no +separate installation. + +## Getting started + +**No code change is required.** Filtering is automatically activated when +`set_aicore_config()` is called. Every subsequent `sap/*` model call through +LiteLLM is filtered. + +```python +from sap_cloud_sdk.aicore import set_aicore_config + +set_aicore_config() +# ← filtering active at threshold 4/4/4/4 + prompt_shield=True +``` + +## Default policy + +| Category | Default threshold | Meaning | +|---|---|---| +| Hate | 4 | Block medium+ severity | +| Violence | 4 | Block medium+ severity | +| Sexual | 4 | Block medium+ severity | +| Self-harm | 4 | Block medium+ severity | +| Prompt shield | enabled | Block jailbreak + indirect injection attempts (input-only) | + +Severity scale: `0` = strict (block any detected content), `2` = low+, `4` = medium+ (default), `6` = off. + +## Configuration via environment variables + +Set these before calling `set_aicore_config()`: + +| Variable | Default | Description | +|---|---|---| +| `ORCH_FILTER_ENABLED` | `true` | Set `false` to disable filtering entirely | +| `ORCH_FILTER_DIRECTIONS` | `input,output` | Comma-list: `input`, `output`, or both | +| `ORCH_FILTER_HATE` | `4` | Azure severity threshold (0/2/4/6) | +| `ORCH_FILTER_VIOLENCE` | `4` | Azure severity threshold | +| `ORCH_FILTER_SEXUAL` | `4` | Azure severity threshold | +| `ORCH_FILTER_SELF_HARM` | `4` | Azure severity threshold | +| `ORCH_FILTER_PROMPT_SHIELD` | `true` | Enable/disable prompt shield | + +Example — tighten self-harm and violence: +```bash +ORCH_FILTER_SELF_HARM=0 +ORCH_FILTER_VIOLENCE=0 +``` + +## Programmatic override with `set_filtering()` + +Only needed to override thresholds at runtime. Call after `set_aicore_config()`. + +```python +from sap_cloud_sdk.orchestration import set_filtering + +# Tighten specific thresholds +set_filtering(self_harm=0, violence=0) + +# Disable filtering entirely +set_filtering(enabled=False) +``` + +`set_filtering()` arguments: + +| Argument | Type | Description | +|---|---|---| +| `hate` | `0\|2\|4\|6` | Override hate threshold | +| `violence` | `0\|2\|4\|6` | Override violence threshold | +| `sexual` | `0\|2\|4\|6` | Override sexual threshold | +| `self_harm` | `0\|2\|4\|6` | Override self-harm threshold | +| `prompt_shield` | `bool` | Enable/disable prompt shield | +| `directions` | `set[str]` | Override active directions | +| `enabled` | `bool` | `False` disables filtering entirely | + +Unspecified arguments retain their current values (from env or defaults). + +## Handling blocked requests + +When the filter rejects a request, LiteLLM raises either a `ContentFilteredError` +(if the rejection reaches `transform_response`) or an `APIConnectionError` wrapping +the rejection JSON (for input-filter 400s caught by `raise_for_status()`). Use +`extract_filter_blocked()` for the second case. + +```python +from sap_cloud_sdk.orchestration import ContentFilteredError +from sap_cloud_sdk.orchestration._litellm_patch import extract_filter_blocked + +try: + result = await llm.ainvoke(messages) +except ContentFilteredError as e: + # e.direction: "input" or "output" + # e.details: severity scores (safe to log — does not contain the prompt) + # e.request_id: for debugging + return "Your request was blocked by content safety policy." +except Exception as e: + blocked = extract_filter_blocked(e) # unwraps LiteLLM-wrapped 400 + if blocked: + return "Your request was blocked by content safety policy." + raise +``` + +## Disabling filtering + +Via environment variable (before `set_aicore_config()`): +```bash +ORCH_FILTER_ENABLED=false +``` + +Programmatically (after `set_aicore_config()`): +```python +set_filtering(enabled=False) +``` + +## Migration from manual `litellm_extension.py` + +If your agent implements content filtering manually (e.g. by subclassing +`GenAIHubOrchestrationConfig`), you can replace the entire implementation +with the SDK: + +```python +# Before (manual): +from orchestration.litellm_extension import install +install() + +# After (SDK): +# Nothing — set_aicore_config() activates filtering automatically. +# Remove the manual install() call and the local litellm_extension.py module. +``` + +To use the SDK's types in your agent for catching filter exceptions: +```python +# Replace: +from orchestration.exceptions import ContentFilterBlocked +# With: +from sap_cloud_sdk.orchestration import ContentFilteredError +``` + +And `extract_filter_blocked`: +```python +# Replace: +from orchestration.litellm_extension import extract_filter_blocked +# With: +from sap_cloud_sdk.orchestration._litellm_patch import extract_filter_blocked +``` diff --git a/tests/orchestration/__init__.py b/tests/orchestration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/orchestration/unit/__init__.py b/tests/orchestration/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/orchestration/unit/test_models.py b/tests/orchestration/unit/test_models.py new file mode 100644 index 00000000..114069f0 --- /dev/null +++ b/tests/orchestration/unit/test_models.py @@ -0,0 +1,149 @@ +"""Unit tests for orchestration._models.""" + +import os +import pytest +from unittest.mock import patch + +from sap_cloud_sdk.orchestration._models import ( + ContentFilterConfig, + FilteringModuleConfig, + PromptShieldConfig, +) + + +class TestContentFilterConfig: + def test_defaults(self): + cfg = ContentFilterConfig() + assert cfg.hate == 4 + assert cfg.violence == 4 + assert cfg.sexual == 4 + assert cfg.self_harm == 4 + + def test_custom_values(self): + cfg = ContentFilterConfig(hate=0, violence=2, sexual=6, self_harm=0) + assert cfg.hate == 0 + assert cfg.violence == 2 + assert cfg.sexual == 6 + assert cfg.self_harm == 0 + + +class TestFilteringModuleConfigToDict: + def test_default_config_produces_both_directions(self): + result = FilteringModuleConfig().to_dict() + assert "input" in result + assert "output" in result + + def test_input_filter_has_azure_type(self): + result = FilteringModuleConfig().to_dict() + f = result["input"]["filters"][0] + assert f["type"] == "azure_content_safety" + + def test_default_thresholds_are_4(self): + result = FilteringModuleConfig().to_dict() + cfg = result["input"]["filters"][0]["config"] + assert cfg["hate"] == 4 + assert cfg["violence"] == 4 + assert cfg["sexual"] == 4 + assert cfg["self_harm"] == 4 + + def test_prompt_shield_on_input_only(self): + result = FilteringModuleConfig().to_dict() + in_cfg = result["input"]["filters"][0]["config"] + out_cfg = result["output"]["filters"][0]["config"] + assert in_cfg.get("prompt_shield") is True + assert "prompt_shield" not in out_cfg + + def test_severity_zero_serialized_not_omitted(self): + cfg = FilteringModuleConfig( + input_filter=ContentFilterConfig(hate=0, violence=0, sexual=0, self_harm=0), + output_filter=None, + ) + result = cfg.to_dict() + in_cfg = result["input"]["filters"][0]["config"] + assert in_cfg["hate"] == 0 + assert in_cfg["violence"] == 0 + + def test_none_input_filter_omits_input_key(self): + cfg = FilteringModuleConfig(input_filter=None, output_filter=ContentFilterConfig()) + result = cfg.to_dict() + assert "input" not in result + assert "output" in result + + def test_none_output_filter_omits_output_key(self): + cfg = FilteringModuleConfig(input_filter=ContentFilterConfig(), output_filter=None) + result = cfg.to_dict() + assert "input" in result + assert "output" not in result + + def test_no_prompt_shield_when_disabled(self): + cfg = FilteringModuleConfig( + prompt_shield=PromptShieldConfig(enabled=False) + ) + result = cfg.to_dict() + in_cfg = result["input"]["filters"][0]["config"] + assert "prompt_shield" not in in_cfg + + def test_no_prompt_shield_when_none(self): + cfg = FilteringModuleConfig(prompt_shield=None) + result = cfg.to_dict() + in_cfg = result["input"]["filters"][0]["config"] + assert "prompt_shield" not in in_cfg + + def test_empty_dict_when_both_filters_none(self): + cfg = FilteringModuleConfig(input_filter=None, output_filter=None) + assert cfg.to_dict() == {} + + +class TestFilteringModuleConfigFromEnv: + def _clear_env(self, monkeypatch): + for k in list(os.environ): + if k.startswith("ORCH_FILTER"): + monkeypatch.delenv(k, raising=False) + + def test_defaults_with_no_env(self, monkeypatch): + self._clear_env(monkeypatch) + cfg = FilteringModuleConfig.from_env() + assert cfg is not None + assert cfg.input_filter is not None + assert cfg.output_filter is not None + assert cfg.input_filter.hate == 4 + + def test_disabled_returns_none(self, monkeypatch): + self._clear_env(monkeypatch) + monkeypatch.setenv("ORCH_FILTER_ENABLED", "false") + assert FilteringModuleConfig.from_env() is None + + def test_custom_severity_from_env(self, monkeypatch): + self._clear_env(monkeypatch) + monkeypatch.setenv("ORCH_FILTER_SELF_HARM", "0") + monkeypatch.setenv("ORCH_FILTER_HATE", "2") + cfg = FilteringModuleConfig.from_env() + assert cfg.input_filter.self_harm == 0 + assert cfg.input_filter.hate == 2 + + def test_input_only_direction(self, monkeypatch): + self._clear_env(monkeypatch) + monkeypatch.setenv("ORCH_FILTER_DIRECTIONS", "input") + cfg = FilteringModuleConfig.from_env() + assert cfg.input_filter is not None + assert cfg.output_filter is None + + def test_output_only_direction(self, monkeypatch): + self._clear_env(monkeypatch) + monkeypatch.setenv("ORCH_FILTER_DIRECTIONS", "output") + cfg = FilteringModuleConfig.from_env() + assert cfg.input_filter is None + assert cfg.output_filter is not None + assert cfg.prompt_shield is None # prompt_shield is input-only + + def test_prompt_shield_false_from_env(self, monkeypatch): + self._clear_env(monkeypatch) + monkeypatch.setenv("ORCH_FILTER_PROMPT_SHIELD", "false") + cfg = FilteringModuleConfig.from_env() + assert cfg.prompt_shield.enabled is False + + def test_invalid_severity_raises(self, monkeypatch): + self._clear_env(monkeypatch) + monkeypatch.setenv("ORCH_FILTER_HATE", "3") + with pytest.raises(ValueError, match="ORCH_FILTER_HATE"): + FilteringModuleConfig.from_env() diff --git a/tests/orchestration/unit/test_patch.py b/tests/orchestration/unit/test_patch.py new file mode 100644 index 00000000..199d7bb2 --- /dev/null +++ b/tests/orchestration/unit/test_patch.py @@ -0,0 +1,232 @@ +"""Unit tests for orchestration._litellm_patch.""" + +import json +import pytest +from unittest.mock import MagicMock, patch + +import httpx + +from sap_cloud_sdk.orchestration._models import ContentFilterConfig, FilteringModuleConfig, PromptShieldConfig +from sap_cloud_sdk.orchestration._litellm_patch import ( + FilteringOrchestrationConfig, + _install, + _ORIGINAL_CONFIG, + extract_filter_blocked, +) +from sap_cloud_sdk.orchestration.exceptions import ContentFilteredError + + +@pytest.fixture(autouse=True) +def restore_litellm_config(): + yield + _install(None) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _stub_response(status: int, body: dict) -> httpx.Response: + return httpx.Response(status, json=body) + + +INPUT_FILTER_BODY = { + "error": { + "request_id": "req-abc", + "code": 400, + "message": "Content filtered.", + "location": "Filtering Module - Input Filter", + "intermediate_results": { + "templating": [{"content": "bad prompt", "role": "user"}], + "input_filtering": { + "data": {"azure_content_safety": {"Hate": 0, "Violence": 4, "SelfHarm": 0, "Sexual": 0}} + } + } + } +} + +OUTPUT_FILTER_BODY = { + "request_id": "req-xyz", + "intermediate_results": { + "output_filtering": {"data": {"choices": [{"index": 0, "azure_content_safety": {"Sexual": 2}}]}} + }, + "final_result": { + "id": "x", "model": "m", + "choices": [{"index": 0, "message": {"role": "assistant", "content": ""}, "finish_reason": "content_filter"}], + "usage": {"completion_tokens": 0, "prompt_tokens": 10, "total_tokens": 10} + } +} + +SUCCESS_BODY = { + "request_id": "req-ok", + "intermediate_results": {}, + "final_result": { + "id": "x", "object": "chat.completion", "model": "claude-sonnet-4-5", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}], + "usage": {"completion_tokens": 5, "prompt_tokens": 10, "total_tokens": 15} + } +} + + +# --------------------------------------------------------------------------- +# transform_request tests +# --------------------------------------------------------------------------- + +class TestTransformRequest: + @staticmethod + def _fresh_base_body() -> dict: + return { + "config": { + "modules": { + "prompt_templating": { + "prompt": {"template": [{"role": "user", "content": "hello"}]}, + "model": {"name": "anthropic--claude-4.5-sonnet", "params": {}, "version": "latest"}, + } + } + } + } + + def _call(self, filtering): + _install(filtering) + with patch( + "sap_cloud_sdk.orchestration._litellm_patch.GenAIHubOrchestrationConfig.transform_request", + return_value=self._fresh_base_body(), + ): + return FilteringOrchestrationConfig().transform_request( + model="sap/anthropic--claude-4.5-sonnet", + messages=[{"role": "user", "content": "hello"}], + optional_params={}, + litellm_params={}, + headers={}, + ) + + def test_filtering_injected_when_active(self, monkeypatch): + for k in list(__import__("os").environ): + if k.startswith("ORCH_FILTER"): + monkeypatch.delenv(k, raising=False) + body = self._call(FilteringModuleConfig.from_env()) + assert "filtering" in body["config"]["modules"] + + def test_both_directions_present_by_default(self, monkeypatch): + for k in list(__import__("os").environ): + if k.startswith("ORCH_FILTER"): + monkeypatch.delenv(k, raising=False) + body = self._call(FilteringModuleConfig.from_env()) + filtering = body["config"]["modules"]["filtering"] + assert "input" in filtering + assert "output" in filtering + + def test_no_filtering_when_cfg_none(self): + body = self._call(None) + assert "filtering" not in body["config"]["modules"] + + def test_prompt_shield_on_input(self, monkeypatch): + for k in list(__import__("os").environ): + if k.startswith("ORCH_FILTER"): + monkeypatch.delenv(k, raising=False) + body = self._call(FilteringModuleConfig.from_env()) + in_cfg = body["config"]["modules"]["filtering"]["input"]["filters"][0]["config"] + assert in_cfg.get("prompt_shield") is True + + +# --------------------------------------------------------------------------- +# transform_response tests +# --------------------------------------------------------------------------- + +class TestTransformResponse: + def _call_transform_response(self, response: httpx.Response): + from litellm.types.utils import ModelResponse + with patch( + "sap_cloud_sdk.orchestration._litellm_patch.GenAIHubOrchestrationConfig.transform_response", + return_value=ModelResponse(), + ): + return FilteringOrchestrationConfig().transform_response( + model="sap/anthropic--claude-4.5-sonnet", + raw_response=response, + model_response=ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + def test_input_filter_4xx_raises(self): + with pytest.raises(ContentFilteredError) as ei: + self._call_transform_response(_stub_response(400, INPUT_FILTER_BODY)) + assert ei.value.direction == "input" + assert ei.value.request_id == "req-abc" + # prompt text must NOT be in details + assert "bad prompt" not in str(ei.value.details) + assert "templating" not in ei.value.details + + def test_output_filter_200_raises(self): + with pytest.raises(ContentFilteredError) as ei: + self._call_transform_response(_stub_response(200, OUTPUT_FILTER_BODY)) + assert ei.value.direction == "output" + assert ei.value.request_id == "req-xyz" + + def test_success_delegates_to_super(self): + result = self._call_transform_response(_stub_response(200, SUCCESS_BODY)) + assert result is not None + + def test_non_filter_4xx_delegates_to_super(self): + body = {"error": {"code": 422, "message": "bad model", "location": "Model Module"}} + result = self._call_transform_response(_stub_response(422, body)) + assert result is not None # no ContentFilteredError raised + + +# --------------------------------------------------------------------------- +# extract_filter_blocked tests +# --------------------------------------------------------------------------- + +class TestExtractFilterBlocked: + def _make_exc(self, payload: dict) -> Exception: + return Exception(f"SapException - {json.dumps(payload)}") + + def test_extracts_input_filter(self): + exc = self._make_exc(INPUT_FILTER_BODY) + blocked = extract_filter_blocked(exc) + assert blocked is not None + assert blocked.direction == "input" + assert blocked.request_id == "req-abc" + assert blocked.details.get("azure_content_safety", {}).get("Violence") == 4 + + def test_returns_none_for_non_filter_exception(self): + assert extract_filter_blocked(Exception("network error")) is None + + def test_returns_none_for_other_location(self): + body = {"error": {"location": "Model Module", "message": "model not found"}} + exc = self._make_exc(body) + assert extract_filter_blocked(exc) is None + + def test_returns_none_for_malformed_json(self): + assert extract_filter_blocked(Exception("{ not valid json }")) is None + + +# --------------------------------------------------------------------------- +# _install tests +# --------------------------------------------------------------------------- + +class TestInstall: + def test_install_patches_litellm(self): + import litellm + cfg = FilteringModuleConfig() + _install(cfg) + assert litellm.GenAIHubOrchestrationConfig is FilteringOrchestrationConfig + _install(None) # restore + + def test_install_none_restores_original(self): + import litellm + _install(FilteringModuleConfig()) + _install(None) + assert litellm.GenAIHubOrchestrationConfig is _ORIGINAL_CONFIG + + def test_install_idempotent(self): + import litellm + cfg = FilteringModuleConfig() + _install(cfg) + _install(cfg) # second call — no error + assert litellm.GenAIHubOrchestrationConfig is FilteringOrchestrationConfig + _install(None) diff --git a/tests/orchestration/unit/test_set_filtering.py b/tests/orchestration/unit/test_set_filtering.py new file mode 100644 index 00000000..69df02b0 --- /dev/null +++ b/tests/orchestration/unit/test_set_filtering.py @@ -0,0 +1,74 @@ +"""Unit tests for orchestration.set_filtering().""" + +import os +import pytest +import litellm + +from sap_cloud_sdk.orchestration import set_filtering +from sap_cloud_sdk.orchestration._litellm_patch import ( + FilteringOrchestrationConfig, + _ORIGINAL_CONFIG, + _install, +) +from sap_cloud_sdk.orchestration._models import FilteringModuleConfig + + +@pytest.fixture(autouse=True) +def restore_litellm(): + """Restore litellm config after each test.""" + yield + _install(None) + + +def _clear_orch_env(monkeypatch): + for k in list(os.environ): + if k.startswith("ORCH_FILTER"): + monkeypatch.delenv(k, raising=False) + + +class TestSetFiltering: + def test_patches_litellm(self, monkeypatch): + _clear_orch_env(monkeypatch) + set_filtering() + assert litellm.GenAIHubOrchestrationConfig is FilteringOrchestrationConfig + + def test_disable_restores_original(self, monkeypatch): + _clear_orch_env(monkeypatch) + set_filtering() + set_filtering(enabled=False) + assert litellm.GenAIHubOrchestrationConfig is _ORIGINAL_CONFIG + + def test_override_self_harm_threshold(self, monkeypatch): + _clear_orch_env(monkeypatch) + set_filtering(self_harm=0) + # Access the active config via the module-level variable + from sap_cloud_sdk.orchestration import _litellm_patch + assert _litellm_patch._active_cfg.input_filter.self_harm == 0 + + def test_other_thresholds_unchanged_on_partial_override(self, monkeypatch): + _clear_orch_env(monkeypatch) + set_filtering(self_harm=0) + from sap_cloud_sdk.orchestration import _litellm_patch + assert _litellm_patch._active_cfg.input_filter.hate == 4 # default preserved + + def test_idempotent(self, monkeypatch): + _clear_orch_env(monkeypatch) + set_filtering() + set_filtering() + assert litellm.GenAIHubOrchestrationConfig is FilteringOrchestrationConfig + + def test_env_disabled_before_set_filtering(self, monkeypatch): + _clear_orch_env(monkeypatch) + monkeypatch.setenv("ORCH_FILTER_ENABLED", "false") + set_filtering() # should still be disabled since env says no + # env is read inside from_env(); set_filtering() with no args reads env + from sap_cloud_sdk.orchestration import _litellm_patch + assert _litellm_patch._active_cfg is None + + def test_explicit_threshold_ignores_enabled_false_env(self, monkeypatch): + _clear_orch_env(monkeypatch) + # Even with ORCH_FILTER_ENABLED=false, explicit thresholds passed to + # set_filtering() should activate filtering (programmatic override wins). + monkeypatch.setenv("ORCH_FILTER_ENABLED", "false") + set_filtering(self_harm=2) # explicit arg → activates filtering + assert litellm.GenAIHubOrchestrationConfig is FilteringOrchestrationConfig From 7fa25a6fa1f4d6c998eb96fb45a3c0ff3d8ad9d1 Mon Sep 17 00:00:00 2001 From: I561719 Date: Fri, 19 Jun 2026 17:01:30 +0200 Subject: [PATCH 2/4] fix(orchestration): ruff formatting and ty type annotations --- src/sap_cloud_sdk/aicore/__init__.py | 1 + src/sap_cloud_sdk/orchestration/__init__.py | 21 ++++++++++++--- .../orchestration/_litellm_patch.py | 26 +++++++++++++++---- src/sap_cloud_sdk/orchestration/_models.py | 20 +++++++++----- src/sap_cloud_sdk/orchestration/exceptions.py | 4 ++- tests/orchestration/unit/test_models.py | 6 +++++ 6 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 3a0e49e1..f17d500d 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -152,6 +152,7 @@ def set_aicore_config(instance_name: str = "aicore-instance") -> None: try: from sap_cloud_sdk.orchestration._litellm_patch import _install from sap_cloud_sdk.orchestration._models import FilteringModuleConfig + _install(FilteringModuleConfig.from_env()) except Exception as e: logger.warning("Could not activate orchestration filtering: %s", e) diff --git a/src/sap_cloud_sdk/orchestration/__init__.py b/src/sap_cloud_sdk/orchestration/__init__.py index e5b5dd0d..458a2706 100644 --- a/src/sap_cloud_sdk/orchestration/__init__.py +++ b/src/sap_cloud_sdk/orchestration/__init__.py @@ -73,7 +73,10 @@ def set_filtering( return # No args at all — just re-apply env-based config (respects ORCH_FILTER_ENABLED) - if all(v is None for v in [hate, violence, sexual, self_harm, prompt_shield, directions, enabled]): + if all( + v is None + for v in [hate, violence, sexual, self_harm, prompt_shield, directions, enabled] + ): _install(FilteringModuleConfig.from_env()) return @@ -81,7 +84,9 @@ def set_filtering( base = FilteringModuleConfig.from_env() or FilteringModuleConfig() # Build effective threshold — override only the provided args. - def _effective_filter(existing: ContentFilterConfig | None) -> ContentFilterConfig | None: + def _effective_filter( + existing: ContentFilterConfig | None, + ) -> ContentFilterConfig | None: if existing is None and directions is not None and "input" not in directions: return None src = existing or ContentFilterConfig() @@ -92,8 +97,16 @@ def _effective_filter(existing: ContentFilterConfig | None) -> ContentFilterConf self_harm=self_harm if self_harm is not None else src.self_harm, ) - new_input = _effective_filter(base.input_filter) if (directions is None or "input" in (directions or {"input", "output"})) else None - new_output = _effective_filter(base.output_filter) if (directions is None or "output" in (directions or {"input", "output"})) else None + new_input = ( + _effective_filter(base.input_filter) + if (directions is None or "input" in (directions or {"input", "output"})) + else None + ) + new_output = ( + _effective_filter(base.output_filter) + if (directions is None or "output" in (directions or {"input", "output"})) + else None + ) new_shield = base.prompt_shield if prompt_shield is not None: diff --git a/src/sap_cloud_sdk/orchestration/_litellm_patch.py b/src/sap_cloud_sdk/orchestration/_litellm_patch.py index 5dafcfbf..08d2429b 100644 --- a/src/sap_cloud_sdk/orchestration/_litellm_patch.py +++ b/src/sap_cloud_sdk/orchestration/_litellm_patch.py @@ -96,8 +96,14 @@ def transform_response( if 400 <= status < 500: try: err = raw_response.json().get("error", {}) - if (err.get("location") or "").startswith("Filtering Module - Input Filter"): - data = err.get("intermediate_results", {}).get("input_filtering", {}).get("data", {}) + if (err.get("location") or "").startswith( + "Filtering Module - Input Filter" + ): + data = ( + err.get("intermediate_results", {}) + .get("input_filtering", {}) + .get("data", {}) + ) raise ContentFilteredError( direction="input", details=data, @@ -115,7 +121,11 @@ def transform_response( payload = raw_response.json() choices = (payload.get("final_result") or {}).get("choices") or [] if choices and choices[0].get("finish_reason") == "content_filter": - data = payload.get("intermediate_results", {}).get("output_filtering", {}).get("data", {}) + data = ( + payload.get("intermediate_results", {}) + .get("output_filtering", {}) + .get("data", {}) + ) raise ContentFilteredError( direction="output", details=data, @@ -173,9 +183,15 @@ def extract_filter_blocked(exc: Exception) -> ContentFilteredError | None: try: payload = json.loads(msg[brace:]) err = payload.get("error", {}) - if not (err.get("location") or "").startswith("Filtering Module - Input Filter"): + if not (err.get("location") or "").startswith( + "Filtering Module - Input Filter" + ): return None - data = err.get("intermediate_results", {}).get("input_filtering", {}).get("data", {}) + data = ( + err.get("intermediate_results", {}) + .get("input_filtering", {}) + .get("data", {}) + ) return ContentFilteredError( direction="input", details=data, diff --git a/src/sap_cloud_sdk/orchestration/_models.py b/src/sap_cloud_sdk/orchestration/_models.py index cfda2800..cba9a7a4 100644 --- a/src/sap_cloud_sdk/orchestration/_models.py +++ b/src/sap_cloud_sdk/orchestration/_models.py @@ -19,7 +19,7 @@ def _env_bool(key: str, default: bool = False) -> bool: return (raw.strip().lower() in _TRUTHY) if raw is not None else default -def _env_severity(key: str, default: int = 4) -> int: +def _env_severity(key: str, default: int = 4) -> Literal[0, 2, 4, 6]: raw = _env(key, str(default)) try: val = int(raw) @@ -27,7 +27,7 @@ def _env_severity(key: str, default: int = 4) -> int: raise ValueError(f"{key} must be one of 0/2/4/6, got {raw!r}") from e if val not in _VALID_SEVERITIES: raise ValueError(f"{key} must be one of 0/2/4/6, got {val}") - return val + return val # type: ignore[return-value] @dataclass @@ -65,8 +65,12 @@ class FilteringModuleConfig: Call ``to_dict()`` to get the wire-format dict for the v2 request body. """ - input_filter: ContentFilterConfig | None = field(default_factory=ContentFilterConfig) - output_filter: ContentFilterConfig | None = field(default_factory=ContentFilterConfig) + input_filter: ContentFilterConfig | None = field( + default_factory=ContentFilterConfig + ) + output_filter: ContentFilterConfig | None = field( + default_factory=ContentFilterConfig + ) prompt_shield: PromptShieldConfig | None = field(default_factory=PromptShieldConfig) @classmethod @@ -121,7 +125,9 @@ def to_dict(self) -> dict: } if self.prompt_shield is not None and self.prompt_shield.enabled: config["prompt_shield"] = True - result["input"] = {"filters": [{"type": "azure_content_safety", "config": config}]} + result["input"] = { + "filters": [{"type": "azure_content_safety", "config": config}] + } if self.output_filter is not None: config = { @@ -130,6 +136,8 @@ def to_dict(self) -> dict: "sexual": self.output_filter.sexual, "self_harm": self.output_filter.self_harm, } - result["output"] = {"filters": [{"type": "azure_content_safety", "config": config}]} + result["output"] = { + "filters": [{"type": "azure_content_safety", "config": config}] + } return result diff --git a/src/sap_cloud_sdk/orchestration/exceptions.py b/src/sap_cloud_sdk/orchestration/exceptions.py index 87d8df8a..68f7c2ff 100644 --- a/src/sap_cloud_sdk/orchestration/exceptions.py +++ b/src/sap_cloud_sdk/orchestration/exceptions.py @@ -36,4 +36,6 @@ def __init__( self.direction = direction self.details = details self.request_id = request_id - super().__init__(f"Content filter blocked the {direction} (request_id={request_id})") + super().__init__( + f"Content filter blocked the {direction} (request_id={request_id})" + ) diff --git a/tests/orchestration/unit/test_models.py b/tests/orchestration/unit/test_models.py index 114069f0..7d94bba3 100644 --- a/tests/orchestration/unit/test_models.py +++ b/tests/orchestration/unit/test_models.py @@ -118,6 +118,8 @@ def test_custom_severity_from_env(self, monkeypatch): monkeypatch.setenv("ORCH_FILTER_SELF_HARM", "0") monkeypatch.setenv("ORCH_FILTER_HATE", "2") cfg = FilteringModuleConfig.from_env() + assert cfg is not None + assert cfg.input_filter is not None assert cfg.input_filter.self_harm == 0 assert cfg.input_filter.hate == 2 @@ -125,6 +127,7 @@ def test_input_only_direction(self, monkeypatch): self._clear_env(monkeypatch) monkeypatch.setenv("ORCH_FILTER_DIRECTIONS", "input") cfg = FilteringModuleConfig.from_env() + assert cfg is not None assert cfg.input_filter is not None assert cfg.output_filter is None @@ -132,6 +135,7 @@ def test_output_only_direction(self, monkeypatch): self._clear_env(monkeypatch) monkeypatch.setenv("ORCH_FILTER_DIRECTIONS", "output") cfg = FilteringModuleConfig.from_env() + assert cfg is not None assert cfg.input_filter is None assert cfg.output_filter is not None assert cfg.prompt_shield is None # prompt_shield is input-only @@ -140,6 +144,8 @@ def test_prompt_shield_false_from_env(self, monkeypatch): self._clear_env(monkeypatch) monkeypatch.setenv("ORCH_FILTER_PROMPT_SHIELD", "false") cfg = FilteringModuleConfig.from_env() + assert cfg is not None + assert cfg.prompt_shield is not None assert cfg.prompt_shield.enabled is False def test_invalid_severity_raises(self, monkeypatch): From 307a36f8a34bf924707e5eee7cf998e66cb7ed47 Mon Sep 17 00:00:00 2001 From: I561719 Date: Fri, 19 Jun 2026 17:05:37 +0200 Subject: [PATCH 3/4] fix(orchestration): use cast() to satisfy ty Literal return type --- src/sap_cloud_sdk/orchestration/_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/orchestration/_models.py b/src/sap_cloud_sdk/orchestration/_models.py index cba9a7a4..62555c79 100644 --- a/src/sap_cloud_sdk/orchestration/_models.py +++ b/src/sap_cloud_sdk/orchestration/_models.py @@ -4,7 +4,7 @@ import os from dataclasses import dataclass, field -from typing import Literal +from typing import Literal, cast _TRUTHY = frozenset({"true", "1", "yes"}) _VALID_SEVERITIES = frozenset({0, 2, 4, 6}) @@ -27,7 +27,7 @@ def _env_severity(key: str, default: int = 4) -> Literal[0, 2, 4, 6]: raise ValueError(f"{key} must be one of 0/2/4/6, got {raw!r}") from e if val not in _VALID_SEVERITIES: raise ValueError(f"{key} must be one of 0/2/4/6, got {val}") - return val # type: ignore[return-value] + return cast(Literal[0, 2, 4, 6], val) @dataclass From 90410e5f4cc467242dbb660340e224ba478b96dc Mon Sep 17 00:00:00 2001 From: I561719 Date: Fri, 19 Jun 2026 17:12:24 +0200 Subject: [PATCH 4/4] test(telemetry): update module and operation count assertions for orchestration --- tests/core/unit/telemetry/test_module.py | 3 ++- tests/core/unit/telemetry/test_operation.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index ccc5d1dc..ede4ef5b 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -55,7 +55,7 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 13 + assert len(all_modules) == 14 assert Module.ADMS in all_modules assert Module.AGENT_MEMORY in all_modules assert Module.AGENTGATEWAY in all_modules @@ -67,6 +67,7 @@ def test_all_modules_present(self): assert Module.DMS in all_modules assert Module.EXTENSIBILITY in all_modules assert Module.OBJECTSTORE in all_modules + assert Module.ORCHESTRATION in all_modules assert Module.PRINT in all_modules assert Module.TELEMETRY in all_modules diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index ee6db49f..31c09987 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -212,5 +212,5 @@ def test_operation_count(self): all_operations = list(Operation) # 3 auditlog + 11 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 2 aicore + 23 dms + 4 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print = 149 - assert len(all_operations) == 149 + # + 5 data_anonymization + 52 adms + 6 print + 1 orchestration = 150 + assert len(all_operations) == 150