Skip to content

Commit 4a1e11d

Browse files
romanlutzCopilot
andcommitted
Merge latest main test-isolation fixes into analytics SDK
Include upstream SDK test isolation and plagiarism normalization through 0a5abbf, preserving the analytics changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents 0df2a5b + 0a5abbf commit 4a1e11d

14 files changed

Lines changed: 721 additions & 123 deletions

File tree

‎doc/code/framework.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ If you are contributing to PyRIT, that work will most likely land in one of the
185185
- `core` stays deliberately small so a default run doesn't print 200 techniques or take forever; the wider catalog lives in `extra` and is selected on demand. Users pick subsets by passing initializer tags (e.g. `core`, `extra`, `all`) or writing their own initializer, so different runs — including from the CLI — can register different technique sets without changing the catalog.
186186
- A technique tied to one scenario is fine; if it's pinned and non-reusable it can stay local to that scenario, but if another scenario could reuse it, promote it to a catalog module and tag it.
187187
- Tags describe a technique (behavioral tags like `single_turn`/`multi_turn`, owner tags like `airt`); they don't decide what a scenario runs. There is deliberately **no global `default` tag** — a default is scenario-relative, declared per scenario via `build_technique_class_from_factories` (the `factories` list is the pool, catalog tags become named aggregate presets, and `default_tags` / `default_names` set what runs when nothing is chosen).
188+
- Generated technique enums follow their factory pool's lifecycle. Registry-derived enums and their lazy exports are discarded on setup registry reset so new scenarios see the replacement catalog. Enums built exclusively from fixed scenario-owned factories, such as `DoctorTechnique` and `JailbreakTechnique`, retain their identity across registry resets, so previously imported members remain valid for new scenario instances.
188189
- Factories opt into additive request-converter composition with `supports_additional_request_converters=True`. This is a semantic capability, not just constructor-signature detection; the factory validates that opted-in attacks accept `attack_converter_config`.
189190
- **Does not own**: the conversation algorithm itself. Branching, turn management, and scoring decisions live in the executor it wraps — a technique only selects and configures existing components, and shouldn't implement new sending, scoring, or branching logic.
190191

‎doc/contributing/4_running_tests.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ prints the 25 slowest test phases. Override the report path with
3333
CI uploads JUnit reports for every OS, Python version, and extras combination,
3434
including failed test runs, with those dimensions in each artifact name.
3535

36+
LiteLLM unit tests mock the SDK and do not need tokenizer data or a local HTTP
37+
server, including when the `litellm` or `all` extra is installed. Real adapter
38+
and outgoing-header checks run separately as
39+
[loopback integration tests](./6_integration_tests.md#litellm-loopback-integration-tests).
40+
3641
## Running a subset while iterating
3742

3843
For a narrower run, invoke `pytest` directly. You can invoke pytest if it's in your path or via python; either `pytest` or `python -m pytest`. For the following examples, we will use `pytest`.

‎doc/contributing/6_integration_tests.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,42 @@ These are tested regularly by the PyRIT team but not necessarily run every pull
88

99
- Unit tests should test all scenarios and more is often better. Integration tests should be scoped and be careful to not run too long.
1010
- Integration tests can sometimes test end-to-end. But almost always, these should target one scenario.
11+
12+
## LiteLLM loopback integration tests
13+
14+
`tests/integration/targets/test_litellm_trace_integration.py` checks the real
15+
OpenAI and Anthropic LiteLLM adapters against a local HTTP server. It verifies
16+
the actual outgoing trace and routing headers and response parsing. These tests
17+
use a fake API key and isolated SQLite memory, not live provider credentials.
18+
Like other integration tests, they are gated by `RUN_ALL_TESTS=true` and need the
19+
optional `litellm` dependency.
20+
21+
Anthropic token counting requires the official
22+
[`cl100k_base.tiktoken`](https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken)
23+
data, which is not included in the tiktoken wheel. Obtain a local copy separately
24+
before offline execution and point the integration fixture to it:
25+
26+
```powershell
27+
$env:PYRIT_TEST_TIKTOKEN_ASSET = 'C:\test-inputs\cl100k_base.tiktoken'
28+
$env:RUN_ALL_TESTS = 'true'
29+
uv run -m pytest tests\integration\targets\test_litellm_trace_integration.py
30+
```
31+
32+
On a POSIX shell:
33+
34+
```bash
35+
PYRIT_TEST_TIKTOKEN_ASSET=/tmp/test-inputs/cl100k_base.tiktoken RUN_ALL_TESTS=true \
36+
uv run -m pytest tests/integration/targets/test_litellm_trace_integration.py
37+
```
38+
39+
The fixture checks SHA-256
40+
`223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7`,
41+
creates a private temporary tokenizer cache, and loads the real encoding without
42+
relying on a warm process cache. It selects LiteLLM's bundled model-cost and
43+
Anthropic-header metadata through supported local-only flags. It never downloads
44+
data. Missing or corrupt tokenizer input is an explicit preparation error once
45+
the tests are enabled, not a skipped test.
46+
47+
Keep the data outside Git. Do not disable offline guards or allow downloads
48+
inside pytest. Ordinary unit runs need neither this asset nor the integration
49+
environment variables.

‎pyrit/scenario/scenarios/_dynamic_techniques.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
"""Lazy exports for scenario technique classes built from registered catalogs."""
4+
"""Lazy exports for scenario technique classes built from registered or local factories."""
55

66
from importlib import import_module
77
from typing import TYPE_CHECKING
@@ -17,14 +17,12 @@
1717
MultilingualTechnique: type[ScenarioTechnique]
1818
RapidResponseTechnique: type[ScenarioTechnique]
1919

20-
_TECHNIQUE_BUILDERS = {
20+
_REGISTRY_TECHNIQUE_BUILDERS = {
2121
"AdversarialBenchmarkTechnique": (
2222
"pyrit.scenario.scenarios.benchmark.adversarial",
2323
"_build_benchmark_technique",
2424
),
2525
"CyberTechnique": ("pyrit.scenario.scenarios.airt.cyber", "_build_cyber_technique"),
26-
"DoctorTechnique": ("pyrit.scenario.scenarios.garak.doctor", "_build_doctor_technique"),
27-
"JailbreakTechnique": ("pyrit.scenario.scenarios.airt.jailbreak", "_build_jailbreak_technique"),
2826
"LeakageTechnique": ("pyrit.scenario.scenarios.airt.leakage", "_build_leakage_technique"),
2927
"MultilingualTechnique": (
3028
"pyrit.scenario.scenarios.airt.multilingual",
@@ -36,6 +34,13 @@
3634
),
3735
}
3836

37+
_TECHNIQUE_BUILDERS = {
38+
**_REGISTRY_TECHNIQUE_BUILDERS,
39+
# Fixed scenario-owned pools must keep enum identity across unrelated registry resets.
40+
"DoctorTechnique": ("pyrit.scenario.scenarios.garak.doctor", "_build_doctor_technique"),
41+
"JailbreakTechnique": ("pyrit.scenario.scenarios.airt.jailbreak", "_build_jailbreak_technique"),
42+
}
43+
3944

4045
def __getattr__(name: str) -> object:
4146
try:
@@ -54,7 +59,7 @@ def reset_dynamic_technique_caches() -> None:
5459
"pyrit.scenario.scenarios.benchmark",
5560
"pyrit.scenario.scenarios.garak",
5661
}
57-
for technique_name, (module_name, builder_name) in _TECHNIQUE_BUILDERS.items():
62+
for technique_name, (module_name, builder_name) in _REGISTRY_TECHNIQUE_BUILDERS.items():
5863
builder = getattr(import_module(module_name), builder_name)
5964
builder.cache_clear()
6065
globals().pop(technique_name, None)

‎pyrit/score/float_scale/plagiarism_scorer.py‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
import re
4+
import unicodedata
55
from enum import Enum
66

77
import numpy as np
@@ -88,12 +88,56 @@ def _tokenize(self, text: str) -> list[str]:
8888
"""
8989
Tokenize text using whitespace-based tokenization (case-insensitive).
9090
91+
Text is normalized to NFKC first so that compatibility forms (fullwidth
92+
letters, mathematical alphanumerics, decomposed accents) fold to a single
93+
representation. Fraction separators between decimal digits are preserved
94+
as ``/`` instead of joining the numerator and denominator. Compact
95+
fractions remain separate from a preceding whole number.
96+
97+
Combining marks are kept only after a retained word character or another
98+
attached mark, so meaningful vowel signs survive without keeping orphaned
99+
marks from removed emoji, punctuation, or whitespace.
100+
91101
Returns:
92-
list[str]: List of lowercase tokens with punctuation removed.
102+
list[str]: Lowercase word tokens with numeric fraction separators preserved.
93103
"""
94-
text = text.lower()
95-
text = re.sub(r"[^\w\s]", "", text)
96-
return text.split()
104+
text = self._normalize_text(text)
105+
characters: list[str] = []
106+
has_word_base = False
107+
for index, char in enumerate(text):
108+
if char.isalnum() or char == "_":
109+
characters.append(char)
110+
has_word_base = True
111+
elif unicodedata.category(char).startswith("M"):
112+
if has_word_base:
113+
characters.append(char)
114+
else:
115+
has_word_base = False
116+
if char.isspace():
117+
characters.append(char)
118+
elif (
119+
char in {"/", "\u2044"}
120+
and 0 < index < len(text) - 1
121+
and text[index - 1].isdecimal()
122+
and text[index + 1].isdecimal()
123+
):
124+
characters.append("/")
125+
return "".join(characters).split()
126+
127+
def _normalize_text(self, text: str) -> str:
128+
"""
129+
Normalize compatibility forms without merging mixed-number components.
130+
131+
Returns:
132+
str: Lowercase NFKC text separating whole numbers from compact fraction symbols.
133+
"""
134+
text = "".join(
135+
f" {char}"
136+
if index > 0 and text[index - 1].isnumeric() and unicodedata.decomposition(char).startswith("<fraction>")
137+
else char
138+
for index, char in enumerate(text)
139+
)
140+
return unicodedata.normalize("NFKC", text).lower()
97141

98142
def _lcs_length(self, a: list[str], b: list[str]) -> int:
99143
"""
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Verify trace headers through real LiteLLM adapters against a loopback server."""
5+
6+
import hashlib
7+
import json
8+
import os
9+
import threading
10+
from collections.abc import Callable, Iterator
11+
from email.message import Message as HTTPHeaders
12+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13+
from pathlib import Path
14+
from typing import TYPE_CHECKING
15+
from unittest.mock import patch
16+
17+
import pytest
18+
19+
from pyrit.models import Message, MessagePiece, RequestTraceContext
20+
from pyrit.prompt_target import LiteLLMChatTarget, TargetTraceConfig
21+
22+
if TYPE_CHECKING:
23+
import tiktoken
24+
25+
pytestmark = [pytest.mark.run_only_if_all_tests, pytest.mark.usefixtures("patch_central_database")]
26+
27+
_TOKENIZER_URL = "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken"
28+
_TOKENIZER_SHA256 = "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7"
29+
_OPENAI_REPLY = {
30+
"id": "chatcmpl-1",
31+
"object": "chat.completion",
32+
"created": 0,
33+
"model": "gpt-4o",
34+
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hello"}, "finish_reason": "stop"}],
35+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
36+
}
37+
_ANTHROPIC_REPLY = {
38+
"id": "msg_1",
39+
"type": "message",
40+
"role": "assistant",
41+
"model": "claude-sonnet-4-6",
42+
"content": [{"type": "text", "text": "hello"}],
43+
"stop_reason": "end_turn",
44+
"stop_sequence": None,
45+
"usage": {"input_tokens": 1, "output_tokens": 1},
46+
}
47+
_WIRE_PROVIDERS = pytest.mark.parametrize(
48+
("model_name", "base_path", "request_path"),
49+
[
50+
("openai/gpt-4o", "/v1", "/v1/chat/completions"),
51+
("anthropic/claude-sonnet-4-6", "", "/v1/messages"),
52+
],
53+
)
54+
55+
56+
@pytest.fixture
57+
def official_cl100k_encoding(tmp_path: Path) -> Iterator["tiktoken.Encoding"]:
58+
cache_dir = tmp_path / "official-tokenizer"
59+
with patch.dict(
60+
os.environ,
61+
{
62+
"TIKTOKEN_CACHE_DIR": str(cache_dir),
63+
"LITELLM_LOCAL_MODEL_COST_MAP": "true",
64+
"LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS": "true",
65+
},
66+
):
67+
pytest.importorskip("litellm")
68+
import tiktoken
69+
import tiktoken.registry
70+
71+
source = os.environ.get("PYRIT_TEST_TIKTOKEN_ASSET")
72+
if not source:
73+
raise RuntimeError(
74+
"Set PYRIT_TEST_TIKTOKEN_ASSET to the official cl100k_base.tiktoken file. "
75+
"See doc/contributing/6_integration_tests.md for offline preparation."
76+
)
77+
content = Path(source).read_bytes()
78+
if hashlib.sha256(content).hexdigest() != _TOKENIZER_SHA256:
79+
raise ValueError(f"Official cl100k_base asset SHA-256 mismatch: {source}")
80+
cache_dir.mkdir()
81+
(cache_dir / hashlib.sha1(_TOKENIZER_URL.encode("utf-8")).hexdigest()).write_bytes(content)
82+
with patch.dict(tiktoken.registry.ENCODINGS, {}, clear=True):
83+
yield tiktoken.get_encoding("cl100k_base")
84+
85+
86+
@pytest.fixture
87+
def chat_endpoint(
88+
official_cl100k_encoding: "tiktoken.Encoding",
89+
) -> Iterator[tuple[str, list[tuple[str, HTTPHeaders]]]]:
90+
received: list[tuple[str, HTTPHeaders]] = []
91+
92+
class _Handler(BaseHTTPRequestHandler):
93+
def do_POST(self) -> None:
94+
self.rfile.read(int(self.headers.get("Content-Length", 0)))
95+
received.append((self.path, self.headers))
96+
reply = _ANTHROPIC_REPLY if self.path.endswith("/v1/messages") else _OPENAI_REPLY
97+
payload = json.dumps(reply).encode()
98+
self.send_response(200)
99+
self.send_header("Content-Type", "application/json")
100+
self.send_header("Content-Length", str(len(payload)))
101+
self.end_headers()
102+
self.wfile.write(payload)
103+
104+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
105+
pass
106+
107+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
108+
thread = threading.Thread(target=server.serve_forever, daemon=True)
109+
thread.start()
110+
try:
111+
yield f"http://127.0.0.1:{server.server_address[1]}", received
112+
finally:
113+
server.shutdown()
114+
server.server_close()
115+
thread.join()
116+
117+
118+
def _user_message() -> Message:
119+
return MessagePiece(role="user", conversation_id="convo", original_value="test prompt").to_message()
120+
121+
122+
@_WIRE_PROVIDERS
123+
async def test_recorded_trace_context_reaches_wire_through_litellm_async(
124+
*,
125+
chat_endpoint: tuple[str, list[tuple[str, HTTPHeaders]]],
126+
model_name: str,
127+
base_path: str,
128+
request_path: str,
129+
) -> None:
130+
url, received = chat_endpoint
131+
provider = model_name.split("/")[0]
132+
target = LiteLLMChatTarget(
133+
model_name=model_name,
134+
endpoint=url + base_path,
135+
api_key="test-key",
136+
extra_body_parameters={
137+
"provider_specific_header": {"custom_llm_provider": provider, "extra_headers": {"X-Route": "a"}}
138+
},
139+
trace_config=TargetTraceConfig(enabled=True),
140+
)
141+
request = _user_message()
142+
responses = await target.send_prompt_async(message=request)
143+
144+
link = RequestTraceContext.from_metadata(request.get_piece().prompt_metadata)
145+
assert link is not None
146+
assert len(received) == 1
147+
path, headers = received[0]
148+
assert path == request_path
149+
assert headers.get_all("traceparent") == [link.traceparent]
150+
assert headers.get_all("tracestate") is None
151+
assert headers["X-Route"] == "a"
152+
assert responses[0].get_value() == "hello"
153+
154+
155+
def _single_scope(provider: str) -> object:
156+
return {"custom_llm_provider": provider, "extra_headers": {"TraceParent": f"00-{'3' * 32}-{'4' * 16}-01"}}
157+
158+
159+
def _list_scope(provider: str) -> object:
160+
return [
161+
{"custom_llm_provider": "bedrock", "extra_headers": {"X-Other": "1"}},
162+
{"custom_llm_provider": f"bedrock, {provider}", "extra_headers": {"tracestate": "vendor=manual"}},
163+
]
164+
165+
166+
@_WIRE_PROVIDERS
167+
@pytest.mark.parametrize("scoped_headers", [_single_scope, _list_scope])
168+
async def test_provider_specific_trace_header_is_rejected_through_litellm_async(
169+
*,
170+
chat_endpoint: tuple[str, list[tuple[str, HTTPHeaders]]],
171+
model_name: str,
172+
base_path: str,
173+
request_path: str,
174+
scoped_headers: Callable[[str], object],
175+
) -> None:
176+
url, received = chat_endpoint
177+
target = LiteLLMChatTarget(
178+
model_name=model_name,
179+
endpoint=url + base_path,
180+
api_key="test-key",
181+
extra_body_parameters={"provider_specific_header": scoped_headers(model_name.split("/")[0])},
182+
trace_config=TargetTraceConfig(enabled=True),
183+
)
184+
with pytest.raises(ValueError, match="Manual trace headers"):
185+
await target.send_prompt_async(message=_user_message())
186+
assert received == []

0 commit comments

Comments
 (0)