From 73cf1533cefff6a356427f7c11d22447a6332738 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 6 Nov 2026 12:41:09 -0300 Subject: [PATCH 1/2] fix(aicore): surface silent fail-open in content filter - Add ContentFilteringNotConfiguredError for fail-closed (strict) mode - Emit logger.warning on both fail-open branches in transform_request - Add _assert_filtering_ready() pre-flight guard in completion()/acompletion() - Expose strict mode via AICORE_FILTER_STRICT env var and set_filtering(strict=) - Export new exception from aicore and filtering packages - Document strict mode and new env var in user-guide.md --- docs/GUIDELINES.md | 7 +- src/sap_cloud_sdk/agentgateway/user-guide.md | 14 +- src/sap_cloud_sdk/aicore/__init__.py | 2 + src/sap_cloud_sdk/aicore/completion.py | 37 +++ .../aicore/filtering/__init__.py | 7 +- src/sap_cloud_sdk/aicore/filtering/_patch.py | 41 ++- src/sap_cloud_sdk/aicore/filtering/config.py | 11 + .../aicore/filtering/exceptions.py | 20 ++ src/sap_cloud_sdk/aicore/filtering/filters.py | 26 +- src/sap_cloud_sdk/aicore/user-guide.md | 28 ++ src/sap_cloud_sdk/cbc/user-guide.md | 3 + .../core/auditlog_ng/user-guide.md | 6 +- .../core/telemetry/user-guide.md | 4 +- src/sap_cloud_sdk/destination/user-guide.md | 10 +- src/sap_cloud_sdk/extensibility/user-guide.md | 12 +- src/sap_cloud_sdk/ias/user-guide.md | 4 +- tests/aicore/filtering/unit/test_filtering.py | 43 +++ tests/aicore/filtering/unit/test_modules.py | 11 +- tests/aicore/filtering/unit/test_patch.py | 57 +++- tests/aicore/unit/test_completion.py | 270 +++++++++++++++--- 20 files changed, 525 insertions(+), 88 deletions(-) diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 0af46518..2a29ed40 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -88,6 +88,7 @@ def integration_env() -> dict: pytest.skip(f"Missing env vars: {missing}") return {v: os.environ[v] for v in required} + @pytest.fixture(scope="session") def my_client(integration_env): try: @@ -112,10 +113,12 @@ class ScenarioContext: self.last_error: Optional[Exception] = None self.cleanup_items: list = [] + @pytest.fixture def context(): return ScenarioContext() + @when("I perform the operation") def perform_operation(context, my_client): try: @@ -124,6 +127,7 @@ def perform_operation(context, my_client): except Exception as e: context.last_error = e + @then("the operation should succeed") def operation_succeeds(context): assert context.last_error is None, f"Unexpected error: {context.last_error}" @@ -229,7 +233,7 @@ def _refresh_if_rotated(self) -> None: has_changed = getattr(self._config_factory, "has_changed", None) if callable(has_changed) and has_changed(): self._config = self._config_factory() - self._cached_token = None # discard stale token + self._cached_token = None # discard stale token # rebuild session/client if needed ``` @@ -240,6 +244,7 @@ Credentials are baked into the client at construction time, so the client itself ```python _CREDENTIAL_ERROR_CODES = frozenset({"InvalidAccessKeyId", "SignatureDoesNotMatch"}) + def _execute_with_retry(self, fn): self._refresh_if_rotated() try: diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 3122e727..5c525767 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -90,9 +90,7 @@ integrations = agw_client.list_active_integrations() target_gtid = integrations[0]["global_tenant_id"] # Load only that backend's MCP tools -tools = await agw_client.list_mcp_tools( - filter=MCPToolFilter(gtids=[target_gtid]) -) +tools = await agw_client.list_mcp_tools(filter=MCPToolFilter(gtids=[target_gtid])) ``` ### A2A Agent Cards (LoB only) @@ -338,10 +336,10 @@ Both fields default to empty lists. `agent_names` is applied after fetching; `or from sap_cloud_sdk.agentgateway import MCPToolFilter MCPToolFilter( - names=[], # tool names to include (matched against MCPTool.name); empty = no filter + names=[], # tool names to include (matched against MCPTool.name); empty = no filter ord_ids=[], # ORD IDs to include (extracted from fragment URL for LoB, or matched - # against IntegrationDependency.ord_id for customer agents); empty = no filter - gtids=[], # GTIDs of connected systems whose tools to include (LoB only); empty = no filter + # against IntegrationDependency.ord_id for customer agents); empty = no filter + gtids=[], # GTIDs of connected systems whose tools to include (LoB only); empty = no filter ) ``` @@ -357,8 +355,8 @@ All fields default to empty lists. ```python class ConnectedSystem(TypedDict): - global_tenant_id: str | None # GTID of the connected backend system - system_type: str | None # application namespace + global_tenant_id: str | None # GTID of the connected backend system + system_type: str | None # application namespace integration_dependency: str | None # ORD ID fulfilled by this integration diff --git a/src/sap_cloud_sdk/aicore/__init__.py b/src/sap_cloud_sdk/aicore/__init__.py index 9fd0f85c..406819fb 100644 --- a/src/sap_cloud_sdk/aicore/__init__.py +++ b/src/sap_cloud_sdk/aicore/__init__.py @@ -24,6 +24,7 @@ ContentFilter, ContentFilteredError, ContentFiltering, + ContentFilteringNotConfiguredError, InputFiltering, LlamaGuard38bFilter, OrchestrationError, @@ -457,5 +458,6 @@ async def _acompletion(*args, **kwargs): "ContentFilter", "Severity", "ContentFilteredError", + "ContentFilteringNotConfiguredError", "OrchestrationError", ] diff --git a/src/sap_cloud_sdk/aicore/completion.py b/src/sap_cloud_sdk/aicore/completion.py index d97dc633..96d99f5e 100644 --- a/src/sap_cloud_sdk/aicore/completion.py +++ b/src/sap_cloud_sdk/aicore/completion.py @@ -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: @@ -107,6 +111,37 @@ def _output_filtering_active() -> bool: return cfg is not None and cfg.output_filtering is not None +def _filtering_is_active() -> bool: + """True when an active config will inject a non-empty ``modules.filtering``.""" + cfg = _filtering_patch._active_cfg + return cfg is not None and bool(cfg.to_dict()) + + +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_is_active() or _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.""" @@ -171,6 +206,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: @@ -195,6 +231,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: diff --git a/src/sap_cloud_sdk/aicore/filtering/__init__.py b/src/sap_cloud_sdk/aicore/filtering/__init__.py index d0903aa8..cef49c18 100644 --- a/src/sap_cloud_sdk/aicore/filtering/__init__.py +++ b/src/sap_cloud_sdk/aicore/filtering/__init__.py @@ -34,7 +34,11 @@ OutputFiltering, Severity, ) -from .exceptions import ContentFilteredError, OrchestrationError +from .exceptions import ( + ContentFilteredError, + ContentFilteringNotConfiguredError, + OrchestrationError, +) __all__ = [ "set_filtering", @@ -47,5 +51,6 @@ "ContentFilter", "Severity", "ContentFilteredError", + "ContentFilteringNotConfiguredError", "OrchestrationError", ] diff --git a/src/sap_cloud_sdk/aicore/filtering/_patch.py b/src/sap_cloud_sdk/aicore/filtering/_patch.py index bd08e210..330d8e62 100644 --- a/src/sap_cloud_sdk/aicore/filtering/_patch.py +++ b/src/sap_cloud_sdk/aicore/filtering/_patch.py @@ -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 @@ -57,7 +67,7 @@ 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__) @@ -65,6 +75,23 @@ _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): @@ -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"] @@ -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") diff --git a/src/sap_cloud_sdk/aicore/filtering/config.py b/src/sap_cloud_sdk/aicore/filtering/config.py index 22a8fa6d..1857e5e1 100644 --- a/src/sap_cloud_sdk/aicore/filtering/config.py +++ b/src/sap_cloud_sdk/aicore/filtering/config.py @@ -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 @@ -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. diff --git a/src/sap_cloud_sdk/aicore/filtering/exceptions.py b/src/sap_cloud_sdk/aicore/filtering/exceptions.py index 51673d28..cba27e5d 100644 --- a/src/sap_cloud_sdk/aicore/filtering/exceptions.py +++ b/src/sap_cloud_sdk/aicore/filtering/exceptions.py @@ -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. + """ diff --git a/src/sap_cloud_sdk/aicore/filtering/filters.py b/src/sap_cloud_sdk/aicore/filtering/filters.py index 82dec386..c7d6886c 100644 --- a/src/sap_cloud_sdk/aicore/filtering/filters.py +++ b/src/sap_cloud_sdk/aicore/filtering/filters.py @@ -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: @@ -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:: @@ -58,11 +65,17 @@ 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) @@ -70,9 +83,10 @@ 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: diff --git a/src/sap_cloud_sdk/aicore/user-guide.md b/src/sap_cloud_sdk/aicore/user-guide.md index efc95d38..83403a02 100644 --- a/src/sap_cloud_sdk/aicore/user-guide.md +++ b/src/sap_cloud_sdk/aicore/user-guide.md @@ -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: @@ -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) diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index acfb9476..baa1684c 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -153,14 +153,17 @@ from sap_cloud_sdk import cbc handler = cbc.CBCEventHandler() + def on_cv(event: cbc.ConsumptionVersionEvent) -> None: # event.app_tenant_id, event.consumption_version_id ... + def on_fa(event: cbc.FeatureActivationEvent) -> None: for f in event.features: print(f.feature_id, f.enabled) + handler.on_consumption_version_event(on_cv) handler.on_feature_activation_event(on_fa) ``` diff --git a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md index d044722b..bd1eba1c 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md +++ b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md @@ -63,9 +63,9 @@ You can spell out the destination parameters and pass connection options alongsi ```python client = create_client( - tenant="tenant_subdomain", # required to activate destination resolution - destination_name="AuditLogV3_Destination", # optional — this is the default - destination_instance="default", # optional — this is the default + tenant="tenant_subdomain", # required to activate destination resolution + destination_name="AuditLogV3_Destination", # optional — this is the default + destination_instance="default", # optional — this is the default # fragment_name="AuditLogV3_Fragment_tenant_subdomain", # optional tenant-specific fragment service_name="my-agent", batch=True, diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 82ab964d..d70e024a 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -412,7 +412,9 @@ my_verifier: TokenVerifier = IASVerifier( ias_url="https://my-tenant.accounts.ondemand.com", client_id="my-client-id", ) -auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app, token_verifier=my_verifier)]) +auto_instrument( + middlewares=[StarletteIASTelemetryMiddleware(app=app, token_verifier=my_verifier)] +) ``` See the [IAS user guide](../../ias/user-guide.md#verified-claims) for the full `IASVerifier` API. diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index ddf36e6d..cbdc23fd 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -977,11 +977,11 @@ client.get_destination("MyDest.v2") client.get_destination("AuditLogV3_Destination") # Invalid names — ValueError raised before any HTTP call -client.get_destination("../etc/passwd") # path traversal -client.get_destination("a/b") # path separator -client.get_destination("a%2Fb") # encoded separator -client.get_destination("a?query=1") # query delimiter -client.get_destination("") # empty +client.get_destination("../etc/passwd") # path traversal +client.get_destination("a/b") # path separator +client.get_destination("a%2Fb") # encoded separator +client.get_destination("a?query=1") # query delimiter +client.get_destination("") # empty ``` ### `get_destination` — ConsumptionLevel shorthand diff --git a/src/sap_cloud_sdk/extensibility/user-guide.md b/src/sap_cloud_sdk/extensibility/user-guide.md index 5c43c173..2fba7a92 100644 --- a/src/sap_cloud_sdk/extensibility/user-guide.md +++ b/src/sap_cloud_sdk/extensibility/user-guide.md @@ -192,11 +192,11 @@ async def call_hook_agw( ```python @dataclass class N8nWorkflowConfig: - ord_id: str # ORD ID of the n8n API resource - card_ord_id: str # ORD ID of the n8n MCP translation card (server identifier) - tool_name: str # MCP tool name to call on the card server + ord_id: str # ORD ID of the n8n API resource + card_ord_id: str # ORD ID of the n8n MCP translation card (server identifier) + tool_name: str # MCP tool name to call on the card server global_tenant_id: str # LWT global tenant ID for the workflow - method: HTTPMethod # HTTP method used by webhook execution + method: HTTPMethod # HTTP method used by webhook execution ``` #### `HookConfig` @@ -324,9 +324,7 @@ class Hook: name: str # Human-readable name type: HookType # Hook type (BEFORE, AFTER) deployment_type: DeploymentType # Deployment type (N8N, SERVERLESS) - n8n_workflow_config: ( - N8nWorkflowConfig # Workflow config (ORD IDs, tool name, global tenant ID, HTTP method) - ) + n8n_workflow_config: N8nWorkflowConfig # Workflow config (ORD IDs, tool name, global tenant ID, HTTP method) timeout: int # Timeout in seconds execution_mode: ExecutionMode # Execution mode (SYNC, ASYNC) on_failure: OnFailure # Failure behavior (CONTINUE, BLOCK) diff --git a/src/sap_cloud_sdk/ias/user-guide.md b/src/sap_cloud_sdk/ias/user-guide.md index b9a9d52c..78ea611b 100644 --- a/src/sap_cloud_sdk/ias/user-guide.md +++ b/src/sap_cloud_sdk/ias/user-guide.md @@ -114,7 +114,9 @@ except IASConfigError: ... # Or configure explicitly -verifier = IASVerifier(ias_url="https://my-tenant.accounts.ondemand.com", client_id="my-client-id") +verifier = IASVerifier( + ias_url="https://my-tenant.accounts.ondemand.com", client_id="my-client-id" +) # Verify a token — raises IASTokenError on any failure verified = verifier("Bearer ") diff --git a/tests/aicore/filtering/unit/test_filtering.py b/tests/aicore/filtering/unit/test_filtering.py index 6cb16024..7ce6d47e 100644 --- a/tests/aicore/filtering/unit/test_filtering.py +++ b/tests/aicore/filtering/unit/test_filtering.py @@ -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): @@ -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. diff --git a/tests/aicore/filtering/unit/test_modules.py b/tests/aicore/filtering/unit/test_modules.py index f4ef3bd8..aba3afe6 100644 --- a/tests/aicore/filtering/unit/test_modules.py +++ b/tests/aicore/filtering/unit/test_modules.py @@ -11,7 +11,7 @@ LlamaGuard38bFilter, OutputFiltering, ) -from sap_cloud_sdk.aicore.filtering.config import load_from_env +from sap_cloud_sdk.aicore.filtering.config import load_from_env, load_strict_from_env class TestInputFiltering: @@ -114,6 +114,15 @@ def test_disabled_returns_none(self, monkeypatch): monkeypatch.setenv("AICORE_FILTER_ENABLED", "false") assert load_from_env() is None + def test_strict_default_false(self, monkeypatch): + self._clear_env(monkeypatch) + assert load_strict_from_env() is False + + def test_strict_true_from_env(self, monkeypatch): + self._clear_env(monkeypatch) + monkeypatch.setenv("AICORE_FILTER_STRICT", "true") + assert load_strict_from_env() is True + def test_custom_severity_from_env(self, monkeypatch): self._clear_env(monkeypatch) monkeypatch.setenv("AICORE_FILTER_SELF_HARM", "0") diff --git a/tests/aicore/filtering/unit/test_patch.py b/tests/aicore/filtering/unit/test_patch.py index 34140aba..0d3bd511 100644 --- a/tests/aicore/filtering/unit/test_patch.py +++ b/tests/aicore/filtering/unit/test_patch.py @@ -20,7 +20,10 @@ _parse_output_filter_stream_error, ) from sap_cloud_sdk.aicore.filtering.config import load_from_env -from sap_cloud_sdk.aicore.filtering.exceptions import ContentFilteredError +from sap_cloud_sdk.aicore.filtering.exceptions import ( + ContentFilteredError, + ContentFilteringNotConfiguredError, +) @pytest.fixture(autouse=True) @@ -123,8 +126,8 @@ def _fresh_base_body() -> dict: } } - def _call(self, filtering): - _install(filtering) + def _call(self, filtering, *, strict=False): + _install(filtering, strict=strict) with patch( "sap_cloud_sdk.aicore.filtering._patch.GenAIHubOrchestrationConfig.transform_request", return_value=self._fresh_base_body(), @@ -166,6 +169,46 @@ def test_prompt_shield_on_input(self, monkeypatch): assert in_cfg.get("prompt_shield") is True +# --------------------------------------------------------------------------- +# Fail-open behaviour (warn / fail-closed) tests +# --------------------------------------------------------------------------- + + +class TestFailOpen(TestTransformRequest): + """Fail-open branches must warn (default) or raise (strict), never go silent.""" + + def test_empty_config_warns_default(self, caplog): + # ContentFiltering() with no directions → to_dict() == {} → branch 2. + with caplog.at_level("WARNING", logger="sap_cloud_sdk.aicore.filtering._patch"): + body = self._call(ContentFiltering()) + assert "filtering" not in body["config"]["modules"] + assert any("WITHOUT filter" in r.message for r in caplog.records) + + def test_empty_config_raises_in_strict(self): + with pytest.raises(ContentFilteringNotConfiguredError): + self._call(ContentFiltering(), strict=True) + + def test_cfg_none_warns_default(self, caplog): + # Defensive branch 1: subclass invoked while _active_cfg is None. + with caplog.at_level("WARNING", logger="sap_cloud_sdk.aicore.filtering._patch"): + body = self._call(None) + assert "filtering" not in body["config"]["modules"] + assert any("WITHOUT filter" in r.message for r in caplog.records) + + def test_cfg_none_raises_in_strict(self): + with pytest.raises(ContentFilteringNotConfiguredError): + self._call(None, strict=True) + + def test_valid_config_no_warning(self, caplog): + cfg = ContentFiltering( + input_filtering=InputFiltering(filters=[AzureContentFilter()]) + ) + with caplog.at_level("WARNING", logger="sap_cloud_sdk.aicore.filtering._patch"): + body = self._call(cfg, strict=True) + assert "filtering" in body["config"]["modules"] + assert not any("WITHOUT filter" in r.message for r in caplog.records) + + # --------------------------------------------------------------------------- # transform_response tests # --------------------------------------------------------------------------- @@ -300,9 +343,7 @@ def test_install_idempotent(self): "location": "Filtering Module - Output Filter", "intermediate_results": { "output_filtering": { - "data": { - "azure_content_safety": {"Hate": 0, "Violence": 6} - } + "data": {"azure_content_safety": {"Hate": 0, "Violence": 6}} } }, } @@ -322,7 +363,9 @@ def test_extracts_output_filter(self): assert blocked.details["azure_content_safety"]["Violence"] == 6 def test_returns_none_for_non_output_location(self): - body = {"error": {"location": "Filtering Module - Input Filter", "message": "x"}} + body = { + "error": {"location": "Filtering Module - Input Filter", "message": "x"} + } assert _parse_output_filter_stream_error(self._make_exc(body)) is None def test_returns_none_for_non_filter_error(self): diff --git a/tests/aicore/unit/test_completion.py b/tests/aicore/unit/test_completion.py index fc9cadf1..aaa49b23 100644 --- a/tests/aicore/unit/test_completion.py +++ b/tests/aicore/unit/test_completion.py @@ -27,13 +27,47 @@ import asyncio import json +import os from unittest.mock import MagicMock, patch import litellm import pytest from sap_cloud_sdk.aicore import acompletion, completion -from sap_cloud_sdk.aicore.filtering.exceptions import ContentFilteredError +from sap_cloud_sdk.aicore.filtering.exceptions import ( + ContentFilteredError, + ContentFilteringNotConfiguredError, +) + + +@pytest.fixture(autouse=True) +def _reset_filtering_state(monkeypatch): + """Isolate each test from ambient AICORE_FILTER_* env and module state. + + The completion wrappers run a filtering-readiness pre-flight check that + reads ``AICORE_FILTER_STRICT`` and module-level patch state; reset both so + tests are hermetic regardless of order or developer environment. + """ + import sys + + from sap_cloud_sdk.aicore.filtering import _patch as _p + + # The module is already loaded by the top-level `from sap_cloud_sdk.aicore + # import completion` import above. We must NOT use a bare + # `import sap_cloud_sdk.aicore.completion` here — that shadows the function + # name at module level and causes ty to resolve `completion(...)` calls as + # the submodule (not callable). Access via sys.modules instead. + _comp_mod = sys.modules["sap_cloud_sdk.aicore.completion"] + + for k in list(os.environ): + if k.startswith("AICORE_FILTER"): + monkeypatch.delenv(k, raising=False) + _p._active_cfg = None + _p._strict_mode = False + _p._configured = False + # Use setattr so ty doesn't complain about an unknown attribute on ModuleType. + setattr(_comp_mod, "_unconfigured_warned", False) + yield # --------------------------------------------------------------------------- @@ -227,7 +261,10 @@ def fake_completion(*args, **kwargs): return result with ( - patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=fake_completion), + 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=[]) @@ -242,7 +279,10 @@ def test_auth_error_retry_also_fails_propagates(self): ) with ( - patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=auth_err), + 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): @@ -270,7 +310,9 @@ def test_non_auth_error_does_not_trigger_reload(self): raised = ValueError("some other error") with ( - patch("sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=raised), + 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): @@ -293,48 +335,71 @@ class TestStreamingGuard: @pytest.fixture(autouse=True) def restore_filtering(self): from sap_cloud_sdk.aicore.filtering._patch import _install + _install(None) yield _install(None) def _activate_input_only(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, ContentFiltering, InputFiltering, set_filtering, + AzureContentFilter, + ContentFiltering, + InputFiltering, + set_filtering, + ) + + set_filtering( + ContentFiltering( + input_filtering=InputFiltering(filters=[AzureContentFilter()]) + ) ) - set_filtering(ContentFiltering( - input_filtering=InputFiltering(filters=[AzureContentFilter()]) - )) def _activate_output(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, ContentFiltering, OutputFiltering, set_filtering, + AzureContentFilter, + ContentFiltering, + OutputFiltering, + set_filtering, + ) + + set_filtering( + ContentFiltering( + output_filtering=OutputFiltering(filters=[AzureContentFilter()]) + ) ) - set_filtering(ContentFiltering( - output_filtering=OutputFiltering(filters=[AzureContentFilter()]) - )) def _activate_input_and_output(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, ContentFiltering, InputFiltering, OutputFiltering, + AzureContentFilter, + ContentFiltering, + InputFiltering, + OutputFiltering, set_filtering, ) - set_filtering(ContentFiltering( - input_filtering=InputFiltering(filters=[AzureContentFilter()]), - output_filtering=OutputFiltering(filters=[AzureContentFilter()]), - )) + + set_filtering( + ContentFiltering( + input_filtering=InputFiltering(filters=[AzureContentFilter()]), + output_filtering=OutputFiltering(filters=[AzureContentFilter()]), + ) + ) # --- no filtering or input-only: stream=True passes through -------------------- def test_no_filtering_stream_allowed(self): sentinel = object() - with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel + ): result = completion(model="sap/x", messages=[], stream=True) assert result is sentinel def test_input_only_filtering_stream_allowed(self): self._activate_input_only() sentinel = object() - with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel + ): result = completion(model="sap/x", messages=[], stream=True) assert result is sentinel @@ -344,7 +409,10 @@ def test_acompletion_input_only_stream_allowed(self): async def _fake_acompletion(*_, **__): return sentinel - with patch("sap_cloud_sdk.aicore.completion.litellm.acompletion", side_effect=_fake_acompletion): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.acompletion", + side_effect=_fake_acompletion, + ): result = asyncio.run(acompletion(model="sap/x", messages=[], stream=True)) assert result is sentinel @@ -355,7 +423,10 @@ def test_output_filter_stream_returns_wrapper(self): self._activate_output() fake_stream = iter([]) - with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=fake_stream): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + return_value=fake_stream, + ): result = completion(model="sap/x", messages=[], stream=True) assert isinstance(result, _FilteringStreamWrapper) @@ -364,7 +435,10 @@ def test_input_and_output_filter_stream_returns_wrapper(self): self._activate_input_and_output() fake_stream = iter([]) - with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=fake_stream): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + return_value=fake_stream, + ): result = completion(model="sap/x", messages=[], stream=True) assert isinstance(result, _FilteringStreamWrapper) @@ -373,7 +447,9 @@ def test_output_filter_no_stream_not_wrapped(self): self._activate_output() sentinel = object() - with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel + ): result = completion(model="sap/x", messages=[]) assert result is sentinel assert not isinstance(result, _FilteringStreamWrapper) @@ -390,7 +466,10 @@ async def _fake_stream_aiter(): async def _fake_acompletion(*_, **__): return _fake_stream_aiter() - with patch("sap_cloud_sdk.aicore.completion.litellm.acompletion", side_effect=_fake_acompletion): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.acompletion", + side_effect=_fake_acompletion, + ): result = asyncio.run(acompletion(model="sap/x", messages=[], stream=True)) assert isinstance(result, _AsyncFilteringStreamWrapper) @@ -402,21 +481,21 @@ async def _fake_acompletion(*_, **__): def _output_filter_stream_error_json() -> str: """In-stream error event JSON for an output-filter rejection (v2 shape).""" - return json.dumps({ - "error": { - "request_id": "req-stream-out", - "code": 500, - "message": "Content filtered by output filter.", - "location": "Filtering Module - Output Filter", - "intermediate_results": { - "output_filtering": { - "data": { - "azure_content_safety": {"Violence": 6, "Hate": 0} + return json.dumps( + { + "error": { + "request_id": "req-stream-out", + "code": 500, + "message": "Content filtered by output filter.", + "location": "Filtering Module - Output Filter", + "intermediate_results": { + "output_filtering": { + "data": {"azure_content_safety": {"Violence": 6, "Hate": 0}} } - } - }, + }, + } } - }) + ) class TestStreamOutputFilterIterator: @@ -425,17 +504,24 @@ class TestStreamOutputFilterIterator: @pytest.fixture(autouse=True) def restore_filtering(self): from sap_cloud_sdk.aicore.filtering._patch import _install + _install(None) yield _install(None) def _activate_output(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, ContentFiltering, OutputFiltering, set_filtering, + AzureContentFilter, + ContentFiltering, + OutputFiltering, + set_filtering, + ) + + set_filtering( + ContentFiltering( + output_filtering=OutputFiltering(filters=[AzureContentFilter()]) + ) ) - set_filtering(ContentFiltering( - output_filtering=OutputFiltering(filters=[AzureContentFilter()]) - )) # --- sync wrapper --- @@ -479,7 +565,10 @@ def _bad_stream(): raise ValueError(_output_filter_stream_error_json()) yield - with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=_bad_stream()): + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + return_value=_bad_stream(), + ): stream = completion(model="sap/x", messages=[], stream=True) with pytest.raises(ContentFilteredError) as ei: @@ -558,7 +647,10 @@ async def fake_acompletion(*args, **kwargs): return result with ( - patch("sap_cloud_sdk.aicore.completion.litellm.acompletion", side_effect=fake_acompletion), + 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=[])) @@ -575,8 +667,98 @@ 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.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=[])) + + +# --------------------------------------------------------------------------- +# Pre-flight filtering-readiness guard (_assert_filtering_ready) +# --------------------------------------------------------------------------- + + +class TestFilteringReadinessGuard: + """completion()/acompletion() must not silently run with filtering never set up.""" + + def test_never_configured_warns_once_and_proceeds(self, caplog): + sentinel = object() + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + return_value=sentinel, + ) as mock_litellm: + with caplog.at_level("WARNING", logger="sap_cloud_sdk.aicore.completion"): + r1 = completion(model="sap/x", messages=[]) + r2 = completion(model="sap/x", messages=[]) + assert r1 is sentinel and r2 is sentinel + assert mock_litellm.call_count == 2 + warnings = [r for r in caplog.records if "never configured" in r.message] + assert len(warnings) == 1 # one-time only + + def test_never_configured_strict_env_raises(self, monkeypatch): + monkeypatch.setenv("AICORE_FILTER_STRICT", "true") + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion" + ) as mock_litellm: + with pytest.raises(ContentFilteringNotConfiguredError): + completion(model="sap/x", messages=[]) + mock_litellm.assert_not_called() # fail closed before the network call + + def test_active_filtering_no_warning(self, monkeypatch, caplog): + from sap_cloud_sdk.aicore.filtering import set_filtering, disable_filtering + from sap_cloud_sdk.aicore.filtering import ( + AzureContentFilter, + ContentFiltering, + InputFiltering, + ) + + set_filtering( + ContentFiltering( + input_filtering=InputFiltering(filters=[AzureContentFilter()]) + ) + ) + try: + sentinel = object() + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + return_value=sentinel, + ): + with caplog.at_level( + "WARNING", logger="sap_cloud_sdk.aicore.completion" + ): + result = completion(model="sap/x", messages=[]) + assert result is sentinel + assert not any("never configured" in r.message for r in caplog.records) + finally: + disable_filtering() + + def test_intentional_disable_no_warning(self, monkeypatch, caplog): + from sap_cloud_sdk.aicore.filtering import disable_filtering + + disable_filtering() # explicit opt-out → _configured True, active False + sentinel = object() + with patch( + "sap_cloud_sdk.aicore.completion.litellm.completion", + return_value=sentinel, + ): + with caplog.at_level("WARNING", logger="sap_cloud_sdk.aicore.completion"): + result = completion(model="sap/x", messages=[]) + assert result is sentinel + assert not any("never configured" in r.message for r in caplog.records) + + def test_acompletion_never_configured_strict_raises(self, monkeypatch): + monkeypatch.setenv("AICORE_FILTER_STRICT", "true") + + async def fake_acompletion(*args, **kwargs): + return object() + + with patch( + "sap_cloud_sdk.aicore.completion.litellm.acompletion", + side_effect=fake_acompletion, + ): + with pytest.raises(ContentFilteringNotConfiguredError): + asyncio.run(acompletion(model="sap/x", messages=[])) From 561ff50ff9cad220113db66a58e2f05839f06b42 Mon Sep 17 00:00:00 2001 From: Soares Date: Wed, 11 Nov 2026 14:11:37 -0300 Subject: [PATCH 2/2] test(aicore): revert stray auto-formatting in tests --- docs/GUIDELINES.md | 7 +- src/sap_cloud_sdk/agentgateway/user-guide.md | 14 +- src/sap_cloud_sdk/aicore/completion.py | 8 +- src/sap_cloud_sdk/cbc/user-guide.md | 3 - .../core/auditlog_ng/user-guide.md | 6 +- .../core/telemetry/user-guide.md | 4 +- src/sap_cloud_sdk/destination/user-guide.md | 10 +- src/sap_cloud_sdk/extensibility/user-guide.md | 12 +- src/sap_cloud_sdk/ias/user-guide.md | 4 +- tests/aicore/filtering/unit/test_patch.py | 8 +- tests/aicore/unit/test_completion.py | 147 +++++------------- 11 files changed, 74 insertions(+), 149 deletions(-) diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 2a29ed40..0af46518 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -88,7 +88,6 @@ def integration_env() -> dict: pytest.skip(f"Missing env vars: {missing}") return {v: os.environ[v] for v in required} - @pytest.fixture(scope="session") def my_client(integration_env): try: @@ -113,12 +112,10 @@ class ScenarioContext: self.last_error: Optional[Exception] = None self.cleanup_items: list = [] - @pytest.fixture def context(): return ScenarioContext() - @when("I perform the operation") def perform_operation(context, my_client): try: @@ -127,7 +124,6 @@ def perform_operation(context, my_client): except Exception as e: context.last_error = e - @then("the operation should succeed") def operation_succeeds(context): assert context.last_error is None, f"Unexpected error: {context.last_error}" @@ -233,7 +229,7 @@ def _refresh_if_rotated(self) -> None: has_changed = getattr(self._config_factory, "has_changed", None) if callable(has_changed) and has_changed(): self._config = self._config_factory() - self._cached_token = None # discard stale token + self._cached_token = None # discard stale token # rebuild session/client if needed ``` @@ -244,7 +240,6 @@ Credentials are baked into the client at construction time, so the client itself ```python _CREDENTIAL_ERROR_CODES = frozenset({"InvalidAccessKeyId", "SignatureDoesNotMatch"}) - def _execute_with_retry(self, fn): self._refresh_if_rotated() try: diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 5c525767..3122e727 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -90,7 +90,9 @@ integrations = agw_client.list_active_integrations() target_gtid = integrations[0]["global_tenant_id"] # Load only that backend's MCP tools -tools = await agw_client.list_mcp_tools(filter=MCPToolFilter(gtids=[target_gtid])) +tools = await agw_client.list_mcp_tools( + filter=MCPToolFilter(gtids=[target_gtid]) +) ``` ### A2A Agent Cards (LoB only) @@ -336,10 +338,10 @@ Both fields default to empty lists. `agent_names` is applied after fetching; `or from sap_cloud_sdk.agentgateway import MCPToolFilter MCPToolFilter( - names=[], # tool names to include (matched against MCPTool.name); empty = no filter + names=[], # tool names to include (matched against MCPTool.name); empty = no filter ord_ids=[], # ORD IDs to include (extracted from fragment URL for LoB, or matched - # against IntegrationDependency.ord_id for customer agents); empty = no filter - gtids=[], # GTIDs of connected systems whose tools to include (LoB only); empty = no filter + # against IntegrationDependency.ord_id for customer agents); empty = no filter + gtids=[], # GTIDs of connected systems whose tools to include (LoB only); empty = no filter ) ``` @@ -355,8 +357,8 @@ All fields default to empty lists. ```python class ConnectedSystem(TypedDict): - global_tenant_id: str | None # GTID of the connected backend system - system_type: str | None # application namespace + global_tenant_id: str | None # GTID of the connected backend system + system_type: str | None # application namespace integration_dependency: str | None # ORD ID fulfilled by this integration diff --git a/src/sap_cloud_sdk/aicore/completion.py b/src/sap_cloud_sdk/aicore/completion.py index 96d99f5e..456405bc 100644 --- a/src/sap_cloud_sdk/aicore/completion.py +++ b/src/sap_cloud_sdk/aicore/completion.py @@ -111,12 +111,6 @@ def _output_filtering_active() -> bool: return cfg is not None and cfg.output_filtering is not None -def _filtering_is_active() -> bool: - """True when an active config will inject a non-empty ``modules.filtering``.""" - cfg = _filtering_patch._active_cfg - return cfg is not None and bool(cfg.to_dict()) - - def _assert_filtering_ready() -> None: """Pre-flight guard against calling completion with filtering never configured. @@ -128,7 +122,7 @@ def _assert_filtering_ready() -> None: default mode it emits a one-time warning. """ global _unconfigured_warned - if _filtering_is_active() or _filtering_patch._configured: + if _filtering_patch._configured: return if load_strict_from_env(): raise ContentFilteringNotConfiguredError( diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index baa1684c..acfb9476 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -153,17 +153,14 @@ from sap_cloud_sdk import cbc handler = cbc.CBCEventHandler() - def on_cv(event: cbc.ConsumptionVersionEvent) -> None: # event.app_tenant_id, event.consumption_version_id ... - def on_fa(event: cbc.FeatureActivationEvent) -> None: for f in event.features: print(f.feature_id, f.enabled) - handler.on_consumption_version_event(on_cv) handler.on_feature_activation_event(on_fa) ``` diff --git a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md index bd1eba1c..d044722b 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md +++ b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md @@ -63,9 +63,9 @@ You can spell out the destination parameters and pass connection options alongsi ```python client = create_client( - tenant="tenant_subdomain", # required to activate destination resolution - destination_name="AuditLogV3_Destination", # optional — this is the default - destination_instance="default", # optional — this is the default + tenant="tenant_subdomain", # required to activate destination resolution + destination_name="AuditLogV3_Destination", # optional — this is the default + destination_instance="default", # optional — this is the default # fragment_name="AuditLogV3_Fragment_tenant_subdomain", # optional tenant-specific fragment service_name="my-agent", batch=True, diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index d70e024a..82ab964d 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -412,9 +412,7 @@ my_verifier: TokenVerifier = IASVerifier( ias_url="https://my-tenant.accounts.ondemand.com", client_id="my-client-id", ) -auto_instrument( - middlewares=[StarletteIASTelemetryMiddleware(app=app, token_verifier=my_verifier)] -) +auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app, token_verifier=my_verifier)]) ``` See the [IAS user guide](../../ias/user-guide.md#verified-claims) for the full `IASVerifier` API. diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index cbdc23fd..ddf36e6d 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -977,11 +977,11 @@ client.get_destination("MyDest.v2") client.get_destination("AuditLogV3_Destination") # Invalid names — ValueError raised before any HTTP call -client.get_destination("../etc/passwd") # path traversal -client.get_destination("a/b") # path separator -client.get_destination("a%2Fb") # encoded separator -client.get_destination("a?query=1") # query delimiter -client.get_destination("") # empty +client.get_destination("../etc/passwd") # path traversal +client.get_destination("a/b") # path separator +client.get_destination("a%2Fb") # encoded separator +client.get_destination("a?query=1") # query delimiter +client.get_destination("") # empty ``` ### `get_destination` — ConsumptionLevel shorthand diff --git a/src/sap_cloud_sdk/extensibility/user-guide.md b/src/sap_cloud_sdk/extensibility/user-guide.md index 2fba7a92..5c43c173 100644 --- a/src/sap_cloud_sdk/extensibility/user-guide.md +++ b/src/sap_cloud_sdk/extensibility/user-guide.md @@ -192,11 +192,11 @@ async def call_hook_agw( ```python @dataclass class N8nWorkflowConfig: - ord_id: str # ORD ID of the n8n API resource - card_ord_id: str # ORD ID of the n8n MCP translation card (server identifier) - tool_name: str # MCP tool name to call on the card server + ord_id: str # ORD ID of the n8n API resource + card_ord_id: str # ORD ID of the n8n MCP translation card (server identifier) + tool_name: str # MCP tool name to call on the card server global_tenant_id: str # LWT global tenant ID for the workflow - method: HTTPMethod # HTTP method used by webhook execution + method: HTTPMethod # HTTP method used by webhook execution ``` #### `HookConfig` @@ -324,7 +324,9 @@ class Hook: name: str # Human-readable name type: HookType # Hook type (BEFORE, AFTER) deployment_type: DeploymentType # Deployment type (N8N, SERVERLESS) - n8n_workflow_config: N8nWorkflowConfig # Workflow config (ORD IDs, tool name, global tenant ID, HTTP method) + n8n_workflow_config: ( + N8nWorkflowConfig # Workflow config (ORD IDs, tool name, global tenant ID, HTTP method) + ) timeout: int # Timeout in seconds execution_mode: ExecutionMode # Execution mode (SYNC, ASYNC) on_failure: OnFailure # Failure behavior (CONTINUE, BLOCK) diff --git a/src/sap_cloud_sdk/ias/user-guide.md b/src/sap_cloud_sdk/ias/user-guide.md index 78ea611b..b9a9d52c 100644 --- a/src/sap_cloud_sdk/ias/user-guide.md +++ b/src/sap_cloud_sdk/ias/user-guide.md @@ -114,9 +114,7 @@ except IASConfigError: ... # Or configure explicitly -verifier = IASVerifier( - ias_url="https://my-tenant.accounts.ondemand.com", client_id="my-client-id" -) +verifier = IASVerifier(ias_url="https://my-tenant.accounts.ondemand.com", client_id="my-client-id") # Verify a token — raises IASTokenError on any failure verified = verifier("Bearer ") diff --git a/tests/aicore/filtering/unit/test_patch.py b/tests/aicore/filtering/unit/test_patch.py index 0d3bd511..48bfeab6 100644 --- a/tests/aicore/filtering/unit/test_patch.py +++ b/tests/aicore/filtering/unit/test_patch.py @@ -343,7 +343,9 @@ def test_install_idempotent(self): "location": "Filtering Module - Output Filter", "intermediate_results": { "output_filtering": { - "data": {"azure_content_safety": {"Hate": 0, "Violence": 6}} + "data": { + "azure_content_safety": {"Hate": 0, "Violence": 6} + } } }, } @@ -363,9 +365,7 @@ def test_extracts_output_filter(self): assert blocked.details["azure_content_safety"]["Violence"] == 6 def test_returns_none_for_non_output_location(self): - body = { - "error": {"location": "Filtering Module - Input Filter", "message": "x"} - } + body = {"error": {"location": "Filtering Module - Input Filter", "message": "x"}} assert _parse_output_filter_stream_error(self._make_exc(body)) is None def test_returns_none_for_non_filter_error(self): diff --git a/tests/aicore/unit/test_completion.py b/tests/aicore/unit/test_completion.py index aaa49b23..2f6453bc 100644 --- a/tests/aicore/unit/test_completion.py +++ b/tests/aicore/unit/test_completion.py @@ -261,10 +261,7 @@ def fake_completion(*args, **kwargs): return result with ( - patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - side_effect=fake_completion, - ), + 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=[]) @@ -279,10 +276,7 @@ def test_auth_error_retry_also_fails_propagates(self): ) with ( - patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - side_effect=auth_err, - ), + 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): @@ -310,9 +304,7 @@ def test_non_auth_error_does_not_trigger_reload(self): raised = ValueError("some other error") with ( - patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", side_effect=raised - ), + 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): @@ -335,71 +327,48 @@ class TestStreamingGuard: @pytest.fixture(autouse=True) def restore_filtering(self): from sap_cloud_sdk.aicore.filtering._patch import _install - _install(None) yield _install(None) def _activate_input_only(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, - ContentFiltering, - InputFiltering, - set_filtering, - ) - - set_filtering( - ContentFiltering( - input_filtering=InputFiltering(filters=[AzureContentFilter()]) - ) + AzureContentFilter, ContentFiltering, InputFiltering, set_filtering, ) + set_filtering(ContentFiltering( + input_filtering=InputFiltering(filters=[AzureContentFilter()]) + )) def _activate_output(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, - ContentFiltering, - OutputFiltering, - set_filtering, - ) - - set_filtering( - ContentFiltering( - output_filtering=OutputFiltering(filters=[AzureContentFilter()]) - ) + AzureContentFilter, ContentFiltering, OutputFiltering, set_filtering, ) + set_filtering(ContentFiltering( + output_filtering=OutputFiltering(filters=[AzureContentFilter()]) + )) def _activate_input_and_output(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, - ContentFiltering, - InputFiltering, - OutputFiltering, + AzureContentFilter, ContentFiltering, InputFiltering, OutputFiltering, set_filtering, ) - - set_filtering( - ContentFiltering( - input_filtering=InputFiltering(filters=[AzureContentFilter()]), - output_filtering=OutputFiltering(filters=[AzureContentFilter()]), - ) - ) + set_filtering(ContentFiltering( + input_filtering=InputFiltering(filters=[AzureContentFilter()]), + output_filtering=OutputFiltering(filters=[AzureContentFilter()]), + )) # --- no filtering or input-only: stream=True passes through -------------------- def test_no_filtering_stream_allowed(self): sentinel = object() - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel): result = completion(model="sap/x", messages=[], stream=True) assert result is sentinel def test_input_only_filtering_stream_allowed(self): self._activate_input_only() sentinel = object() - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel): result = completion(model="sap/x", messages=[], stream=True) assert result is sentinel @@ -409,10 +378,7 @@ def test_acompletion_input_only_stream_allowed(self): async def _fake_acompletion(*_, **__): return sentinel - with patch( - "sap_cloud_sdk.aicore.completion.litellm.acompletion", - side_effect=_fake_acompletion, - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.acompletion", side_effect=_fake_acompletion): result = asyncio.run(acompletion(model="sap/x", messages=[], stream=True)) assert result is sentinel @@ -423,10 +389,7 @@ def test_output_filter_stream_returns_wrapper(self): self._activate_output() fake_stream = iter([]) - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - return_value=fake_stream, - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=fake_stream): result = completion(model="sap/x", messages=[], stream=True) assert isinstance(result, _FilteringStreamWrapper) @@ -435,10 +398,7 @@ def test_input_and_output_filter_stream_returns_wrapper(self): self._activate_input_and_output() fake_stream = iter([]) - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - return_value=fake_stream, - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=fake_stream): result = completion(model="sap/x", messages=[], stream=True) assert isinstance(result, _FilteringStreamWrapper) @@ -447,9 +407,7 @@ def test_output_filter_no_stream_not_wrapped(self): self._activate_output() sentinel = object() - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=sentinel): result = completion(model="sap/x", messages=[]) assert result is sentinel assert not isinstance(result, _FilteringStreamWrapper) @@ -466,10 +424,7 @@ async def _fake_stream_aiter(): async def _fake_acompletion(*_, **__): return _fake_stream_aiter() - with patch( - "sap_cloud_sdk.aicore.completion.litellm.acompletion", - side_effect=_fake_acompletion, - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.acompletion", side_effect=_fake_acompletion): result = asyncio.run(acompletion(model="sap/x", messages=[], stream=True)) assert isinstance(result, _AsyncFilteringStreamWrapper) @@ -481,21 +436,21 @@ async def _fake_acompletion(*_, **__): def _output_filter_stream_error_json() -> str: """In-stream error event JSON for an output-filter rejection (v2 shape).""" - return json.dumps( - { - "error": { - "request_id": "req-stream-out", - "code": 500, - "message": "Content filtered by output filter.", - "location": "Filtering Module - Output Filter", - "intermediate_results": { - "output_filtering": { - "data": {"azure_content_safety": {"Violence": 6, "Hate": 0}} + return json.dumps({ + "error": { + "request_id": "req-stream-out", + "code": 500, + "message": "Content filtered by output filter.", + "location": "Filtering Module - Output Filter", + "intermediate_results": { + "output_filtering": { + "data": { + "azure_content_safety": {"Violence": 6, "Hate": 0} } - }, - } + } + }, } - ) + }) class TestStreamOutputFilterIterator: @@ -504,24 +459,17 @@ class TestStreamOutputFilterIterator: @pytest.fixture(autouse=True) def restore_filtering(self): from sap_cloud_sdk.aicore.filtering._patch import _install - _install(None) yield _install(None) def _activate_output(self): from sap_cloud_sdk.aicore.filtering import ( - AzureContentFilter, - ContentFiltering, - OutputFiltering, - set_filtering, - ) - - set_filtering( - ContentFiltering( - output_filtering=OutputFiltering(filters=[AzureContentFilter()]) - ) + AzureContentFilter, ContentFiltering, OutputFiltering, set_filtering, ) + set_filtering(ContentFiltering( + output_filtering=OutputFiltering(filters=[AzureContentFilter()]) + )) # --- sync wrapper --- @@ -565,10 +513,7 @@ def _bad_stream(): raise ValueError(_output_filter_stream_error_json()) yield - with patch( - "sap_cloud_sdk.aicore.completion.litellm.completion", - return_value=_bad_stream(), - ): + with patch("sap_cloud_sdk.aicore.completion.litellm.completion", return_value=_bad_stream()): stream = completion(model="sap/x", messages=[], stream=True) with pytest.raises(ContentFilteredError) as ei: @@ -647,10 +592,7 @@ async def fake_acompletion(*args, **kwargs): return result with ( - patch( - "sap_cloud_sdk.aicore.completion.litellm.acompletion", - side_effect=fake_acompletion, - ), + 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=[])) @@ -667,10 +609,7 @@ 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.completion.litellm.acompletion", side_effect=fake_acompletion), patch("sap_cloud_sdk.aicore.set_aicore_config"), ): with pytest.raises(litellm.AuthenticationError):