pytest-wardenbot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. pytest_wardenbot/__init__.py +27 -0
  2. pytest_wardenbot/_corpus_override.py +108 -0
  3. pytest_wardenbot/_errors.py +33 -0
  4. pytest_wardenbot/_formatting.py +97 -0
  5. pytest_wardenbot/_redaction.py +77 -0
  6. pytest_wardenbot/_util.py +29 -0
  7. pytest_wardenbot/adapters/__init__.py +36 -0
  8. pytest_wardenbot/adapters/_sync_bridge.py +51 -0
  9. pytest_wardenbot/adapters/anthropic_msgs.py +221 -0
  10. pytest_wardenbot/adapters/base.py +100 -0
  11. pytest_wardenbot/adapters/http.py +259 -0
  12. pytest_wardenbot/adapters/openai_chat.py +221 -0
  13. pytest_wardenbot/business_truth.py +174 -0
  14. pytest_wardenbot/canary.py +99 -0
  15. pytest_wardenbot/corpus/__init__.py +37 -0
  16. pytest_wardenbot/corpus/encoded_payloads.py +65 -0
  17. pytest_wardenbot/corpus/indirect_injection.py +68 -0
  18. pytest_wardenbot/corpus/jailbreak.py +44 -0
  19. pytest_wardenbot/corpus/multi_turn.py +69 -0
  20. pytest_wardenbot/corpus/off_topic.py +32 -0
  21. pytest_wardenbot/corpus/refusal_bypass.py +36 -0
  22. pytest_wardenbot/corpus/system_prompt_leak.py +30 -0
  23. pytest_wardenbot/grading/__init__.py +60 -0
  24. pytest_wardenbot/grading/deterministic.py +362 -0
  25. pytest_wardenbot/grading/judge.py +428 -0
  26. pytest_wardenbot/plugin.py +323 -0
  27. pytest_wardenbot/quickstart.py +320 -0
  28. pytest_wardenbot/remediation.py +60 -0
  29. pytest_wardenbot/runners/__init__.py +10 -0
  30. pytest_wardenbot/runners/base.py +43 -0
  31. pytest_wardenbot/tests/__init__.py +11 -0
  32. pytest_wardenbot/tests/test_business_truth.py +25 -0
  33. pytest_wardenbot/tests/test_canary_leak.py +52 -0
  34. pytest_wardenbot/tests/test_encoded_payloads.py +85 -0
  35. pytest_wardenbot/tests/test_indirect_injection.py +57 -0
  36. pytest_wardenbot/tests/test_multi_turn.py +67 -0
  37. pytest_wardenbot/tests/test_off_topic.py +54 -0
  38. pytest_wardenbot/tests/test_prompt_injection.py +54 -0
  39. pytest_wardenbot/tests/test_refusal_bypass.py +46 -0
  40. pytest_wardenbot/tests/test_semantic.py +43 -0
  41. pytest_wardenbot/tests/test_system_prompt_leak.py +47 -0
  42. pytest_wardenbot-0.1.0.dist-info/METADATA +171 -0
  43. pytest_wardenbot-0.1.0.dist-info/RECORD +46 -0
  44. pytest_wardenbot-0.1.0.dist-info/WHEEL +4 -0
  45. pytest_wardenbot-0.1.0.dist-info/entry_points.txt +2 -0
  46. pytest_wardenbot-0.1.0.dist-info/licenses/LICENSE.md +201 -0
@@ -0,0 +1,27 @@
1
+ """pytest-wardenbot — pytest plugin for testing chatbots and LLM apps.
2
+
3
+ See https://github.com/pardamike/pytest-wardenbot for documentation.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from pytest_wardenbot._errors import WardenBotError, WardenBotInfraError
9
+ from pytest_wardenbot.adapters.base import (
10
+ AsyncChatbotAdapter,
11
+ ChatbotAdapter,
12
+ ChatbotResponse,
13
+ )
14
+ from pytest_wardenbot.business_truth import BusinessTruthFact, MatchType
15
+ from pytest_wardenbot.grading.judge import JudgeCase
16
+
17
+ __all__ = [
18
+ "AsyncChatbotAdapter",
19
+ "BusinessTruthFact",
20
+ "ChatbotAdapter",
21
+ "ChatbotResponse",
22
+ "JudgeCase",
23
+ "MatchType",
24
+ "WardenBotError",
25
+ "WardenBotInfraError",
26
+ "__version__",
27
+ ]
@@ -0,0 +1,108 @@
1
+ """Resolve user-supplied corpus overrides at collection time.
2
+
3
+ Each shipped test that parametrizes over an attack corpus uses
4
+ `pytest_generate_tests` to look up an override fixture (e.g.
5
+ `wardenbot_jailbreak_prompts`). If the user has registered such a fixture in
6
+ their conftest.py, the override is used; otherwise the bundled default applies.
7
+
8
+ The override fixture must be a plain `() -> tuple[(str, str), ...]` function —
9
+ no `request`, no other fixture dependencies. That's because pytest's
10
+ fixture machinery isn't fully available at collection time; we call the
11
+ fixture function directly. If the override needs more setup, the user should
12
+ build their corpus at import time and have the fixture return the prepared
13
+ tuple.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ from collections.abc import Sequence
20
+ from typing import Any
21
+
22
+ # Different corpora have different entry shapes (jailbreak: (prompt, attack_id),
23
+ # encoded: (prompt, triggers, attack_id), multi-turn: (priming, payload, attack_id)).
24
+ # We keep the type loose here — each shipped test knows its own entry shape.
25
+ CorpusEntry = tuple[Any, ...]
26
+
27
+
28
+ def _lookup_fixture_defs(metafunc: Any, fixture_name: str) -> Sequence[Any]:
29
+ """Return all fixture defs for `fixture_name` visible to the test node.
30
+
31
+ Two sources are consulted:
32
+
33
+ 1. `metafunc._arg2fixturedefs` — fixtures already in the test's argument
34
+ closure. Hit when the test function (or some other fixture it depends
35
+ on) declares the corpus-override fixture as a param.
36
+
37
+ 2. `config._fixturemanager.getfixturedefs(fixture_name, metafunc.definition)`
38
+ — full conftest chain lookup. Hit in the common case where the user
39
+ defines `wardenbot_jailbreak_prompts` in their conftest but the test
40
+ function doesn't list it as a param.
41
+
42
+ Both are internal pytest APIs but they've been stable across 7.x and 8.x
43
+ and are used by other plugins (pytest-asyncio, pytest-bdd, etc).
44
+ """
45
+ closure_defs = getattr(metafunc, "_arg2fixturedefs", {}).get(fixture_name)
46
+ if closure_defs:
47
+ return closure_defs
48
+
49
+ # Common case: the test function doesn't list the override fixture as a
50
+ # parameter, so it's not in the test's closure. Look it up via the session's
51
+ # FixtureManager — that's where every registered fixture (plugin defaults +
52
+ # user conftest overrides) lives.
53
+ #
54
+ # In pytest 8.x the FixtureManager was reachable as `config._fixturemanager`;
55
+ # in 9.x it lives on the Session. We try both for compatibility.
56
+ definition = getattr(metafunc, "definition", None)
57
+ if definition is None:
58
+ return ()
59
+ fm = None
60
+ session = getattr(definition, "session", None)
61
+ if session is not None:
62
+ fm = getattr(session, "_fixturemanager", None)
63
+ if fm is None:
64
+ config = getattr(metafunc, "config", None)
65
+ if config is not None:
66
+ fm = getattr(config, "_fixturemanager", None)
67
+ if fm is None:
68
+ return ()
69
+
70
+ defs = fm.getfixturedefs(fixture_name, definition)
71
+ return defs or ()
72
+
73
+
74
+ def resolve_corpus(
75
+ metafunc: Any,
76
+ fixture_name: str,
77
+ default_corpus: Sequence[CorpusEntry],
78
+ ) -> Sequence[CorpusEntry]:
79
+ """Look up `fixture_name` in the active fixture chain; return its value or default.
80
+
81
+ Walks the fixture defs from most-specific to most-generic and returns the
82
+ first fixture function value that's callable with zero args. Falls back to
83
+ `default_corpus` if no usable override is registered.
84
+ """
85
+ fixturedefs = _lookup_fixture_defs(metafunc, fixture_name)
86
+ if not fixturedefs:
87
+ return default_corpus
88
+
89
+ for fixturedef in reversed(fixturedefs):
90
+ func = getattr(fixturedef, "func", None)
91
+ if func is None:
92
+ continue
93
+ try:
94
+ sig = inspect.signature(func)
95
+ except (TypeError, ValueError):
96
+ continue
97
+ if sig.parameters:
98
+ # Fixture has dependencies we can't satisfy at collection time.
99
+ continue
100
+ try:
101
+ value = func()
102
+ except Exception:
103
+ continue
104
+ if value is None:
105
+ continue
106
+ return tuple(value)
107
+
108
+ return default_corpus
@@ -0,0 +1,33 @@
1
+ """Error taxonomy for pytest-wardenbot.
2
+
3
+ Distinguishes infrastructure failures (the chatbot under test couldn't be
4
+ reached, returned malformed data, hit a network error) from security findings
5
+ (the chatbot responded, but the response failed a check).
6
+
7
+ Why this matters: when a CI run shows red, the on-call engineer needs to know
8
+ "is my bot down?" vs. "is my bot compromised?" — the operational response is
9
+ completely different. `WardenBotInfraError` propagating naturally lands the
10
+ result in pytest's ERROR bucket (vs. FAILURE for assertions), so the
11
+ distinction is visible without any custom reporting layer.
12
+
13
+ Callers that need the original cause can access it via `__cause__` since all
14
+ wrappers use `raise ... from exc`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+
20
+ class WardenBotError(Exception):
21
+ """Base class for all pytest-wardenbot exceptions."""
22
+
23
+
24
+ class WardenBotInfraError(WardenBotError):
25
+ """The chatbot under test could not be reached or returned malformed data.
26
+
27
+ Raised by adapters when the underlying transport fails (network error,
28
+ HTTP 5xx, timeout), or the response is structurally wrong (non-JSON,
29
+ missing expected field, wrong type). Distinct from AssertionError, which
30
+ signals the chatbot DID respond but failed a security check.
31
+
32
+ The original exception is preserved as `__cause__`.
33
+ """
@@ -0,0 +1,97 @@
1
+ """Shared failure-message formatting.
2
+
3
+ All shipped tests produce assertion messages with the same skeleton:
4
+
5
+ WardenBot test failed: <kind>
6
+
7
+ Prompt sent:
8
+ <prompt>
9
+
10
+ <one or more labeled sections>
11
+
12
+ Response (first N chars):
13
+ <truncated response>
14
+
15
+ Agent-ready remediation (paste into Cursor / Claude Code):
16
+ <remediation>
17
+
18
+ Keeping the format in one place means: (a) the agent-ready remediation block
19
+ renders identically across deterministic / business-truth / judge / future
20
+ runner failures, so downstream tooling can parse it consistently, and (b)
21
+ adjusting the truncation cap or layout is one edit, not three.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Sequence
27
+
28
+ DEFAULT_MAX_RESPONSE_CHARS = 500
29
+ """Cap response text in failure messages. Prevents PII-bearing bot replies
30
+ from dumping megabytes into CI logs."""
31
+
32
+
33
+ def format_failure_message(
34
+ *,
35
+ kind: str,
36
+ prompt: str,
37
+ response_text: str,
38
+ sections: Sequence[tuple[str, str]] = (),
39
+ remediation: str,
40
+ max_response_chars: int = DEFAULT_MAX_RESPONSE_CHARS,
41
+ ) -> str:
42
+ """Render a structured WardenBot failure message.
43
+
44
+ Args:
45
+ kind: Short category, e.g. "jailbreak compliance" or "business-truth mismatch".
46
+ prompt: The prompt that was sent to the chatbot.
47
+ response_text: The chatbot's response. Truncated to `max_response_chars`.
48
+ sections: Optional extra labeled blocks rendered between prompt and response.
49
+ Each entry is `(label, body)`. Body may contain newlines; it's indented
50
+ uniformly under the label.
51
+ remediation: Agent-ready prose pasted into the trailing remediation block.
52
+ max_response_chars: Cap on response text. Default 500.
53
+ """
54
+ truncated = (
55
+ response_text
56
+ if len(response_text) <= max_response_chars
57
+ else response_text[:max_response_chars] + "…"
58
+ )
59
+
60
+ parts: list[str] = [
61
+ f"WardenBot test failed: {kind}",
62
+ "",
63
+ " Prompt sent:",
64
+ f" {prompt!r}",
65
+ "",
66
+ ]
67
+
68
+ for label, body in sections:
69
+ parts.append(f" {label}:")
70
+ for line in body.splitlines() or [""]:
71
+ parts.append(f" {line}")
72
+ parts.append("")
73
+
74
+ parts.extend(
75
+ [
76
+ f" Response (first {max_response_chars} chars):",
77
+ f" {truncated!r}",
78
+ "",
79
+ " Agent-ready remediation (paste into Cursor / Claude Code):",
80
+ f" {remediation}",
81
+ "",
82
+ ]
83
+ )
84
+
85
+ return "\n".join(parts)
86
+
87
+
88
+ def format_indicator_list(indicators: Sequence[str]) -> str:
89
+ """Render an indicator list as a multi-line section body.
90
+
91
+ Used by deterministic checks where the section is "which patterns matched".
92
+ """
93
+ if not indicators:
94
+ return "(none)"
95
+ lines = [f"({len(indicators)} matched)"]
96
+ lines.extend(f" - {ind}" for ind in indicators)
97
+ return "\n".join(lines)
@@ -0,0 +1,77 @@
1
+ """Redact sensitive values from chatbot response payloads.
2
+
3
+ The `raw` field on `ChatbotResponse` stores the vendor's complete API response.
4
+ If a chatbot echoes back the request's Authorization header (some debug endpoints
5
+ do this), or if the vendor's response itself contains a long-lived token, that
6
+ value would otherwise show up in pytest tracebacks and CI logs.
7
+
8
+ We do best-effort redaction: any dict key that case-insensitively contains one
9
+ of the known sensitive substrings has its value replaced with `[REDACTED]`.
10
+ Lists and nested dicts are walked recursively. Non-dict / non-list values pass
11
+ through unchanged.
12
+
13
+ This is opt-out: adapters that need the unredacted payload (debugging a vendor
14
+ response shape, for example) pass `keep_sensitive_response_fields=True`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ DEFAULT_SENSITIVE_FIELD_PARTS: tuple[str, ...] = (
22
+ "authorization",
23
+ "auth-token",
24
+ "auth_token",
25
+ "api-key",
26
+ "api_key",
27
+ "apikey",
28
+ "cookie",
29
+ "set-cookie",
30
+ "password",
31
+ "secret",
32
+ "bearer",
33
+ "x-token",
34
+ "x_token",
35
+ "session-token",
36
+ "session_token",
37
+ )
38
+ """Case-insensitive substrings matched against dict keys."""
39
+
40
+ REDACTED_PLACEHOLDER = "[REDACTED]"
41
+
42
+
43
+ def _is_sensitive_key(key: object, sensitive_parts: tuple[str, ...]) -> bool:
44
+ if not isinstance(key, str):
45
+ return False
46
+ lower = key.lower()
47
+ return any(part in lower for part in sensitive_parts)
48
+
49
+
50
+ def redact_response_payload(
51
+ payload: Any,
52
+ *,
53
+ sensitive_parts: tuple[str, ...] = DEFAULT_SENSITIVE_FIELD_PARTS,
54
+ ) -> Any:
55
+ """Return a deep copy of `payload` with sensitive values redacted.
56
+
57
+ Dicts: any value whose key contains a sensitive substring is replaced
58
+ with `REDACTED_PLACEHOLDER`. All other values are walked recursively.
59
+ Lists, tuples: each element is walked recursively (tuples become lists
60
+ since they aren't JSON-native).
61
+ Other types: returned unchanged.
62
+
63
+ Returns the input unchanged if not a dict/list/tuple — adapters can pass
64
+ arbitrary payloads without checking the type first.
65
+ """
66
+ if isinstance(payload, dict):
67
+ return {
68
+ k: (
69
+ REDACTED_PLACEHOLDER
70
+ if _is_sensitive_key(k, sensitive_parts)
71
+ else redact_response_payload(v, sensitive_parts=sensitive_parts)
72
+ )
73
+ for k, v in payload.items()
74
+ }
75
+ if isinstance(payload, list | tuple):
76
+ return [redact_response_payload(item, sensitive_parts=sensitive_parts) for item in payload]
77
+ return payload
@@ -0,0 +1,29 @@
1
+ """Small internal utilities shared across the plugin.
2
+
3
+ Private (leading underscore in the module name): nothing here is part of the
4
+ public API. Callers inside the package import from `pytest_wardenbot._util`;
5
+ external users should not.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
13
+
14
+
15
+ def slugify(text: str, *, max_len: int = 50, fallback: str = "item") -> str:
16
+ """Make a short, pytest-id-friendly slug from arbitrary text.
17
+
18
+ Lowercases, replaces non-alphanumeric runs with `-`, strips leading and
19
+ trailing `-`, and caps the length. Returns `fallback` if the result would
20
+ be empty (e.g., input was pure punctuation).
21
+
22
+ Used by `BusinessTruthFact.parametrize_id` and `JudgeCase.parametrize_id`
23
+ so test IDs render consistently regardless of which type produced them.
24
+ """
25
+ lower = text.lower()
26
+ slug = _SLUG_RE.sub("-", lower).strip("-")
27
+ if len(slug) > max_len:
28
+ slug = slug[:max_len].rstrip("-")
29
+ return slug or fallback
@@ -0,0 +1,36 @@
1
+ """Chatbot adapters.
2
+
3
+ Users write their own `chatbot` pytest fixture that returns one of these adapters
4
+ (or any object that satisfies `ChatbotAdapter` / `AsyncChatbotAdapter`). The
5
+ shipped tests use the fixture to send probes against the customer's chatbot.
6
+
7
+ Bundled adapters (no extras needed):
8
+ HTTPChatbotAdapter, AsyncHTTPChatbotAdapter
9
+
10
+ Vendor-specific adapters (require the corresponding extra):
11
+ pytest_wardenbot.adapters.openai_chat [openai]
12
+ OpenAIChatAdapter, AsyncOpenAIChatAdapter
13
+ pytest_wardenbot.adapters.anthropic_msgs [anthropic]
14
+ AnthropicMessagesAdapter, AsyncAnthropicMessagesAdapter
15
+
16
+ Bridge helper:
17
+ to_sync(async_adapter) -> wraps an AsyncChatbotAdapter as a
18
+ ChatbotAdapter for use with the v0.1 sync shipped tests.
19
+ """
20
+
21
+ from pytest_wardenbot.adapters._sync_bridge import to_sync
22
+ from pytest_wardenbot.adapters.base import (
23
+ AsyncChatbotAdapter,
24
+ ChatbotAdapter,
25
+ ChatbotResponse,
26
+ )
27
+ from pytest_wardenbot.adapters.http import AsyncHTTPChatbotAdapter, HTTPChatbotAdapter
28
+
29
+ __all__ = [
30
+ "AsyncChatbotAdapter",
31
+ "AsyncHTTPChatbotAdapter",
32
+ "ChatbotAdapter",
33
+ "ChatbotResponse",
34
+ "HTTPChatbotAdapter",
35
+ "to_sync",
36
+ ]
@@ -0,0 +1,51 @@
1
+ """Wrap an `AsyncChatbotAdapter` so it satisfies the sync `ChatbotAdapter`.
2
+
3
+ The shipped tests in v0.1 are synchronous. Users whose chatbots are behind
4
+ async transports can still drive the shipped tests by passing their async
5
+ adapter through `to_sync(...)` in their `chatbot` fixture:
6
+
7
+ from pytest_wardenbot.adapters import to_sync
8
+ from pytest_wardenbot.adapters.openai_chat import AsyncOpenAIChatAdapter
9
+
10
+ @pytest.fixture
11
+ def chatbot():
12
+ return to_sync(AsyncOpenAIChatAdapter())
13
+
14
+ The bridge uses `asyncio.run` per call. That precludes use from inside a
15
+ running event loop (pytest-asyncio tests, for example) — `asyncio.run` will
16
+ raise. In that case, drive the async adapter directly from an async test.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+
23
+ from pytest_wardenbot.adapters.base import (
24
+ AsyncChatbotAdapter,
25
+ ChatbotAdapter,
26
+ ChatbotResponse,
27
+ )
28
+
29
+
30
+ class _SyncFromAsyncAdapter:
31
+ """Internal adapter wrapping an async adapter behind the sync Protocol."""
32
+
33
+ def __init__(self, inner: AsyncChatbotAdapter) -> None:
34
+ self._inner = inner
35
+ self.name = f"sync-from-async({inner.name})"
36
+
37
+ def send_message(self, prompt: str, *, session_id: str | None = None) -> ChatbotResponse:
38
+ return asyncio.run(self._inner.send_message(prompt, session_id=session_id))
39
+
40
+ def reset_session(self, session_id: str) -> None:
41
+ asyncio.run(self._inner.reset_session(session_id))
42
+
43
+
44
+ def to_sync(adapter: AsyncChatbotAdapter) -> ChatbotAdapter:
45
+ """Return a `ChatbotAdapter` that delegates each call to the async adapter.
46
+
47
+ Uses `asyncio.run` per call. Raises `RuntimeError` if called from inside
48
+ a running event loop — in that case, use the async adapter directly from
49
+ an async test instead of wrapping it.
50
+ """
51
+ return _SyncFromAsyncAdapter(adapter)
@@ -0,0 +1,221 @@
1
+ """Anthropic Messages adapter (sync + async).
2
+
3
+ Requires the `[anthropic]` extra:
4
+
5
+ pip install "pytest-wardenbot[anthropic]"
6
+
7
+ Both adapters support optional session-keyed conversation memory: pass
8
+ `session_id` to `send_message` and the adapter accumulates user/assistant
9
+ turns. Calling `reset_session(session_id)` drops the stored turns.
10
+
11
+ The Anthropic Messages API distinguishes between the system prompt (a
12
+ top-level `system` parameter) and the conversation `messages` array (which
13
+ contains only user and assistant turns). The adapter handles this naturally:
14
+ `system_prompt` is sent as the `system` argument on every call; messages
15
+ contains only the alternating user/assistant turns.
16
+
17
+ A user-supplied client can be injected via the `client` parameter for tests
18
+ and custom configuration.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ from typing import Any
25
+
26
+ from pytest_wardenbot._errors import WardenBotInfraError
27
+ from pytest_wardenbot._redaction import redact_response_payload
28
+ from pytest_wardenbot.adapters.base import ChatbotResponse
29
+
30
+ _INSTALL_HINT = (
31
+ "AnthropicMessagesAdapter requires the [anthropic] extra. "
32
+ "Install with: pip install 'pytest-wardenbot[anthropic]'"
33
+ )
34
+
35
+
36
+ def _import_anthropic() -> Any:
37
+ try:
38
+ import anthropic # type: ignore[import-not-found]
39
+
40
+ return anthropic
41
+ except ImportError as exc:
42
+ raise ImportError(_INSTALL_HINT) from exc
43
+
44
+
45
+ def _extract_text(message: Any) -> str:
46
+ """Pull the assistant text from an Anthropic Message response.
47
+
48
+ Anthropic responses contain a list of content blocks; for normal text
49
+ generation the first block has a `text` attribute. Concatenates all text
50
+ blocks (some responses can split text across blocks).
51
+ """
52
+ try:
53
+ blocks = message.content
54
+ except AttributeError as exc:
55
+ raise WardenBotInfraError(f"Anthropic response missing .content. Got: {message!r}") from exc
56
+
57
+ text_parts: list[str] = []
58
+ for block in blocks:
59
+ block_text = getattr(block, "text", None)
60
+ if isinstance(block_text, str):
61
+ text_parts.append(block_text)
62
+ return "".join(text_parts)
63
+
64
+
65
+ def _message_to_raw(message: Any) -> dict[str, Any]:
66
+ """Best-effort dict view of an Anthropic message for ChatbotResponse.raw."""
67
+ if hasattr(message, "model_dump"):
68
+ return message.model_dump()
69
+ if isinstance(message, dict):
70
+ return message
71
+ return {"repr": repr(message)}
72
+
73
+
74
+ class AnthropicMessagesAdapter:
75
+ """Synchronous Anthropic Messages adapter.
76
+
77
+ Example:
78
+
79
+ ```python
80
+ @pytest.fixture
81
+ def chatbot():
82
+ return AnthropicMessagesAdapter(
83
+ model="claude-haiku-4-5",
84
+ system_prompt="You are a customer-support assistant for Example Corp.",
85
+ )
86
+ ```
87
+ """
88
+
89
+ name = "anthropic-messages"
90
+
91
+ def __init__(
92
+ self,
93
+ *,
94
+ model: str = "claude-haiku-4-5",
95
+ system_prompt: str | None = None,
96
+ max_tokens: int = 1024,
97
+ temperature: float = 0.0,
98
+ client: Any | None = None,
99
+ extra_request_fields: dict[str, Any] | None = None,
100
+ keep_sensitive_response_fields: bool = False,
101
+ ) -> None:
102
+ if client is None:
103
+ anthropic = _import_anthropic()
104
+ client = anthropic.Anthropic()
105
+ self._client: Any = client
106
+ self._model = model
107
+ self._system_prompt = system_prompt
108
+ self._max_tokens = max_tokens
109
+ self._temperature = temperature
110
+ self._extra_request_fields = dict(extra_request_fields or {})
111
+ self._keep_sensitive_response_fields = keep_sensitive_response_fields
112
+ self._sessions: dict[str, list[dict[str, str]]] = {}
113
+
114
+ def send_message(self, prompt: str, *, session_id: str | None = None) -> ChatbotResponse:
115
+ history = self._sessions.setdefault(session_id, []) if session_id else []
116
+ messages = [*history, {"role": "user", "content": prompt}]
117
+
118
+ kwargs: dict[str, Any] = {
119
+ "model": self._model,
120
+ "max_tokens": self._max_tokens,
121
+ "temperature": self._temperature,
122
+ "messages": messages,
123
+ **self._extra_request_fields,
124
+ }
125
+ if self._system_prompt:
126
+ kwargs["system"] = self._system_prompt
127
+
128
+ start = time.perf_counter()
129
+ try:
130
+ message = self._client.messages.create(**kwargs)
131
+ except Exception as exc:
132
+ raise WardenBotInfraError(
133
+ f"Anthropic API call failed: {type(exc).__name__}: {exc}"
134
+ ) from exc
135
+ elapsed_ms = (time.perf_counter() - start) * 1000
136
+
137
+ text = _extract_text(message)
138
+ if session_id is not None:
139
+ history.append({"role": "user", "content": prompt})
140
+ history.append({"role": "assistant", "content": text})
141
+
142
+ raw = _message_to_raw(message)
143
+ stored_raw = raw if self._keep_sensitive_response_fields else redact_response_payload(raw)
144
+ return ChatbotResponse(text=text, raw=stored_raw, latency_ms=elapsed_ms)
145
+
146
+ def reset_session(self, session_id: str) -> None:
147
+ self._sessions.pop(session_id, None)
148
+
149
+ def __repr__(self) -> str:
150
+ return f"AnthropicMessagesAdapter(model={self._model!r})"
151
+
152
+
153
+ class AsyncAnthropicMessagesAdapter:
154
+ """Async counterpart to `AnthropicMessagesAdapter`.
155
+
156
+ Same shape, same error wrapping, same redaction default. Uses
157
+ `anthropic.AsyncAnthropic`.
158
+ """
159
+
160
+ name = "async-anthropic-messages"
161
+
162
+ def __init__(
163
+ self,
164
+ *,
165
+ model: str = "claude-haiku-4-5",
166
+ system_prompt: str | None = None,
167
+ max_tokens: int = 1024,
168
+ temperature: float = 0.0,
169
+ client: Any | None = None,
170
+ extra_request_fields: dict[str, Any] | None = None,
171
+ keep_sensitive_response_fields: bool = False,
172
+ ) -> None:
173
+ if client is None:
174
+ anthropic = _import_anthropic()
175
+ client = anthropic.AsyncAnthropic()
176
+ self._client: Any = client
177
+ self._model = model
178
+ self._system_prompt = system_prompt
179
+ self._max_tokens = max_tokens
180
+ self._temperature = temperature
181
+ self._extra_request_fields = dict(extra_request_fields or {})
182
+ self._keep_sensitive_response_fields = keep_sensitive_response_fields
183
+ self._sessions: dict[str, list[dict[str, str]]] = {}
184
+
185
+ async def send_message(self, prompt: str, *, session_id: str | None = None) -> ChatbotResponse:
186
+ history = self._sessions.setdefault(session_id, []) if session_id else []
187
+ messages = [*history, {"role": "user", "content": prompt}]
188
+
189
+ kwargs: dict[str, Any] = {
190
+ "model": self._model,
191
+ "max_tokens": self._max_tokens,
192
+ "temperature": self._temperature,
193
+ "messages": messages,
194
+ **self._extra_request_fields,
195
+ }
196
+ if self._system_prompt:
197
+ kwargs["system"] = self._system_prompt
198
+
199
+ start = time.perf_counter()
200
+ try:
201
+ message = await self._client.messages.create(**kwargs)
202
+ except Exception as exc:
203
+ raise WardenBotInfraError(
204
+ f"Anthropic API call failed: {type(exc).__name__}: {exc}"
205
+ ) from exc
206
+ elapsed_ms = (time.perf_counter() - start) * 1000
207
+
208
+ text = _extract_text(message)
209
+ if session_id is not None:
210
+ history.append({"role": "user", "content": prompt})
211
+ history.append({"role": "assistant", "content": text})
212
+
213
+ raw = _message_to_raw(message)
214
+ stored_raw = raw if self._keep_sensitive_response_fields else redact_response_payload(raw)
215
+ return ChatbotResponse(text=text, raw=stored_raw, latency_ms=elapsed_ms)
216
+
217
+ async def reset_session(self, session_id: str) -> None:
218
+ self._sessions.pop(session_id, None)
219
+
220
+ def __repr__(self) -> str:
221
+ return f"AsyncAnthropicMessagesAdapter(model={self._model!r})"