mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
* test: add logging e2e coverage (s3_v2, gcs_bucket, team langfuse callback, datadog failure) Five new live e2e scenarios raising Logging & Guardrails registry coverage: s3_v2 success and failure objects read back from the real S3 bucket, gcs_bucket success record read back through the GCS JSON API (with nextPageToken pagination and per-request bearer minting), team-scoped Langfuse callback delivery with non-team isolation, and DataDog failure event delivery queried by indexed model_group. datadog_reader gains query-based variants of the marker search; the langfuse cell is a new registry row. Bucket readers settle past a full flush interval so a late duplicate cannot hide from the exactly-one assertions * test: cover clock-skew day prefix in gcs read-back and retry team callback propagation * test: key the s3 failure read-back on the provider error, not payload absence * chore: rerun ci * chore: rerun ci after config sync * chore: rerun ci with pr lane env * chore: rerun ci * chore: rerun ci * chore: rerun ci * chore: rerun ci * chore: rerun ci * test: add guardrail e2e coverage (presidio masking, bedrock post and during call, moderation on messages) (#38553) * test: add guardrail e2e coverage (presidio masking, bedrock post/during, moderation on messages) * test: require the phone placeholder positively in the presidio masking predicate * test: count only the 400 verdict body as a bedrock post_call block * test(e2e): exempt the guardrail config echo from the post_call leak assertion * test(e2e): pin the fail-closed contract for an unknown guardrail name (skipped, product gap) * test(e2e): tolerate the readiness 503 from a transient db blip in the callback-config probes
137 lines
6.1 KiB
Python
137 lines
6.1 KiB
Python
"""Live e2e: Bedrock ApplyGuardrail blocks on chat, pre_call and post_call.
|
|
|
|
pre_call registers a bedrock guardrail via POST /guardrails with identifier/
|
|
version from env, then sends a prompt the guardrail's configured policy denies.
|
|
HTTP 400 (or other non-2xx block) with a guardrail-shaped body is the contract;
|
|
a 200 means the guardrail never ran. post_call scans the MODEL OUTPUT only, so
|
|
its test makes the model echo the word the guardrail's word policy denies
|
|
(BEDROCK_GUARDRAIL_BLOCKED_WORD, default FORBIDDENWORD) and the block must
|
|
arrive without leaking the model's text.
|
|
|
|
No AWS keys are passed: the gateway signs ApplyGuardrail with its own
|
|
pod-identity role, since the static AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
|
env vars are deliberately absent from the gateway (they hijack RDS IAM auth).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from e2e_config import unique_marker
|
|
from e2e_http import UnknownApiError
|
|
from guardrails_client import (
|
|
BedrockGuardrailParamsBody,
|
|
GuardrailsClient,
|
|
poll_until_blocked,
|
|
)
|
|
from lifecycle import ResourceManager
|
|
from pydantic import JsonValue, TypeAdapter
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
|
|
|
|
|
def _without_assessments(value: JsonValue) -> JsonValue:
|
|
"""The assessments echo guardrail CONFIG, not content: the stage guardrail's
|
|
topic policy is itself named after the denied word, so its label lands in
|
|
every assessment listing and would trip a leak check aimed at model output."""
|
|
if isinstance(value, dict):
|
|
return {key: _without_assessments(child) for key, child in value.items() if key != "assessments"}
|
|
if isinstance(value, list):
|
|
return [_without_assessments(item) for item in value]
|
|
return value
|
|
|
|
|
|
MODEL = "gemini-2.5-flash"
|
|
# Matches the word/topic policy the guardrail this suite points at actually denies.
|
|
# Content filters are not assumed: the guardrail resource carries no contentPolicy,
|
|
# so a hate/violence prompt returns action=NONE and never blocks.
|
|
BLOCKED_PROMPT = "Give me a recipe for sourdough bread."
|
|
|
|
|
|
class TestBedrockGuardrail:
|
|
@pytest.mark.covers(
|
|
"guardrail.bedrock.pre_call.blocks",
|
|
exercised_on=["chat_completions"],
|
|
)
|
|
def test_bedrock_pre_call_blocks_harmful_prompt(
|
|
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
|
|
) -> None:
|
|
identifier = os.environ["BEDROCK_GUARDRAIL_IDENTIFIER"]
|
|
version = os.environ["BEDROCK_GUARDRAIL_VERSION"]
|
|
|
|
name = f"e2e-bedrock-guard-{unique_marker()}"
|
|
guardrail_id = client.create_bedrock_guardrail(name, identifier=identifier, version=version)
|
|
resources.defer(lambda: client.delete_guardrail(guardrail_id))
|
|
|
|
# Selected per request rather than registered default_on, so an upstream
|
|
# ApplyGuardrail failure surfaces here instead of 403ing every other suite
|
|
# running against this proxy.
|
|
result = poll_until_blocked(lambda: client.chat(scoped_key, MODEL, BLOCKED_PROMPT, guardrails=[name]))
|
|
|
|
match result:
|
|
case UnknownApiError(status_code=status, body=body):
|
|
assert status in {400, 403}, f"expected a guardrail block status, got {status}: {body[:400]}"
|
|
body_lower = body.lower()
|
|
assert any(
|
|
token in body_lower
|
|
for token in (
|
|
"guardrail",
|
|
"blocked",
|
|
"violat",
|
|
"content",
|
|
"bedrock",
|
|
"intervened",
|
|
)
|
|
), f"block body should name the guardrail reason; got: {body[:400]}"
|
|
case _:
|
|
pytest.fail(f"bedrock default-on guardrail did not block harmful prompt; got {result}")
|
|
|
|
@pytest.mark.covers(
|
|
"guardrail.bedrock.post_call.blocks",
|
|
exercised_on=["chat_completions"],
|
|
)
|
|
def test_bedrock_post_call_blocks_denied_model_output(
|
|
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
|
|
) -> None:
|
|
identifier = os.environ["BEDROCK_GUARDRAIL_IDENTIFIER"]
|
|
version = os.environ["BEDROCK_GUARDRAIL_VERSION"]
|
|
blocked_word = os.environ.get("BEDROCK_GUARDRAIL_BLOCKED_WORD", "FORBIDDENWORD")
|
|
|
|
name = f"e2e-bedrock-post-{unique_marker()}"
|
|
guardrail_id = client.register(
|
|
name,
|
|
BedrockGuardrailParamsBody(
|
|
mode="post_call",
|
|
default_on=False,
|
|
guardrailIdentifier=identifier,
|
|
guardrailVersion=version,
|
|
),
|
|
)
|
|
resources.defer(lambda: client.delete_guardrail(guardrail_id))
|
|
|
|
# post_call scans OUTPUT only, so the denied word has to come out of the
|
|
# model: ask it to echo the word verbatim. The word in the prompt itself
|
|
# is not scanned in this mode.
|
|
prompt = f"Reply with exactly this one word and nothing else: {blocked_word}"
|
|
result = poll_until_blocked(lambda: client.chat(scoped_key, MODEL, prompt, guardrails=[name], max_tokens=128))
|
|
|
|
match result:
|
|
case UnknownApiError(status_code=status, body=body):
|
|
# A policy block is a 400 naming the verdict; a failed
|
|
# ApplyGuardrail call surfaces as 403 "guardrail request
|
|
# failed", which must not count as a block.
|
|
assert status == 400, f"expected the guardrail block status 400, got {status}: {body[:400]}"
|
|
body_lower = body.lower()
|
|
assert any(token in body_lower for token in ("violated", "blocked", "intervened")), (
|
|
f"block body should name the guardrail verdict; got: {body[:400]}"
|
|
)
|
|
assert blocked_word not in json.dumps(_without_assessments(_JSON.validate_json(body))), (
|
|
f"the blocked model output must not leak into the error body; got: {body[:400]}"
|
|
)
|
|
case _:
|
|
pytest.fail(f"bedrock post_call guardrail did not block denied model output; got {result}")
|