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.
- pytest_wardenbot/__init__.py +27 -0
- pytest_wardenbot/_corpus_override.py +108 -0
- pytest_wardenbot/_errors.py +33 -0
- pytest_wardenbot/_formatting.py +97 -0
- pytest_wardenbot/_redaction.py +77 -0
- pytest_wardenbot/_util.py +29 -0
- pytest_wardenbot/adapters/__init__.py +36 -0
- pytest_wardenbot/adapters/_sync_bridge.py +51 -0
- pytest_wardenbot/adapters/anthropic_msgs.py +221 -0
- pytest_wardenbot/adapters/base.py +100 -0
- pytest_wardenbot/adapters/http.py +259 -0
- pytest_wardenbot/adapters/openai_chat.py +221 -0
- pytest_wardenbot/business_truth.py +174 -0
- pytest_wardenbot/canary.py +99 -0
- pytest_wardenbot/corpus/__init__.py +37 -0
- pytest_wardenbot/corpus/encoded_payloads.py +65 -0
- pytest_wardenbot/corpus/indirect_injection.py +68 -0
- pytest_wardenbot/corpus/jailbreak.py +44 -0
- pytest_wardenbot/corpus/multi_turn.py +69 -0
- pytest_wardenbot/corpus/off_topic.py +32 -0
- pytest_wardenbot/corpus/refusal_bypass.py +36 -0
- pytest_wardenbot/corpus/system_prompt_leak.py +30 -0
- pytest_wardenbot/grading/__init__.py +60 -0
- pytest_wardenbot/grading/deterministic.py +362 -0
- pytest_wardenbot/grading/judge.py +428 -0
- pytest_wardenbot/plugin.py +323 -0
- pytest_wardenbot/quickstart.py +320 -0
- pytest_wardenbot/remediation.py +60 -0
- pytest_wardenbot/runners/__init__.py +10 -0
- pytest_wardenbot/runners/base.py +43 -0
- pytest_wardenbot/tests/__init__.py +11 -0
- pytest_wardenbot/tests/test_business_truth.py +25 -0
- pytest_wardenbot/tests/test_canary_leak.py +52 -0
- pytest_wardenbot/tests/test_encoded_payloads.py +85 -0
- pytest_wardenbot/tests/test_indirect_injection.py +57 -0
- pytest_wardenbot/tests/test_multi_turn.py +67 -0
- pytest_wardenbot/tests/test_off_topic.py +54 -0
- pytest_wardenbot/tests/test_prompt_injection.py +54 -0
- pytest_wardenbot/tests/test_refusal_bypass.py +46 -0
- pytest_wardenbot/tests/test_semantic.py +43 -0
- pytest_wardenbot/tests/test_system_prompt_leak.py +47 -0
- pytest_wardenbot-0.1.0.dist-info/METADATA +171 -0
- pytest_wardenbot-0.1.0.dist-info/RECORD +46 -0
- pytest_wardenbot-0.1.0.dist-info/WHEEL +4 -0
- pytest_wardenbot-0.1.0.dist-info/entry_points.txt +2 -0
- 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})"
|