auditkit 1.0.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 (81) hide show
  1. auditkit/README.md +99 -0
  2. auditkit/__init__.py +177 -0
  3. auditkit/__main__.py +3 -0
  4. auditkit/_bootstrap.py +77 -0
  5. auditkit/_identity_guard.py +99 -0
  6. auditkit/adapter.py +264 -0
  7. auditkit/annotator.py +339 -0
  8. auditkit/api.py +502 -0
  9. auditkit/assets/auditkit_logo.png +0 -0
  10. auditkit/cache.py +47 -0
  11. auditkit/cli.py +417 -0
  12. auditkit/comparison.py +563 -0
  13. auditkit/diff.py +265 -0
  14. auditkit/errors.py +54 -0
  15. auditkit/evaluator.py +20 -0
  16. auditkit/experiment.py +145 -0
  17. auditkit/hf_publish.py +262 -0
  18. auditkit/lmeval_engine.py +550 -0
  19. auditkit/loaders.py +121 -0
  20. auditkit/logs.py +18 -0
  21. auditkit/metric.py +199 -0
  22. auditkit/metrics/README.md +15 -0
  23. auditkit/metrics/__init__.py +0 -0
  24. auditkit/metrics/code.py +222 -0
  25. auditkit/metrics/embedding.py +131 -0
  26. auditkit/metrics/encoder_judge.py +423 -0
  27. auditkit/metrics/generation.py +331 -0
  28. auditkit/metrics/guard.py +412 -0
  29. auditkit/metrics/hallucination.py +45 -0
  30. auditkit/metrics/judge.py +547 -0
  31. auditkit/metrics/pairwise.py +153 -0
  32. auditkit/metrics/perf.py +53 -0
  33. auditkit/metrics/rag.py +149 -0
  34. auditkit/metrics/security.py +64 -0
  35. auditkit/metrics/toxicity.py +238 -0
  36. auditkit/model/README.md +16 -0
  37. auditkit/model/__init__.py +485 -0
  38. auditkit/model/anthropic.py +94 -0
  39. auditkit/model/api_gen.py +133 -0
  40. auditkit/model/groq_gen.py +121 -0
  41. auditkit/model/hf_gen.py +385 -0
  42. auditkit/model/lexsi.py +155 -0
  43. auditkit/model/litellm_gen.py +65 -0
  44. auditkit/model/openai.py +90 -0
  45. auditkit/model/openrouter_gen.py +152 -0
  46. auditkit/model/vllm_gen.py +316 -0
  47. auditkit/model_compare.py +655 -0
  48. auditkit/redteam/README.md +9 -0
  49. auditkit/redteam/__init__.py +26 -0
  50. auditkit/redteam/detector.py +37 -0
  51. auditkit/redteam/detectors/README.md +5 -0
  52. auditkit/redteam/detectors/builtin.py +126 -0
  53. auditkit/redteam/probe.py +39 -0
  54. auditkit/redteam/probes/README.md +5 -0
  55. auditkit/redteam/probes/builtin.py +85 -0
  56. auditkit/redteam/runner.py +206 -0
  57. auditkit/registry.py +65 -0
  58. auditkit/report.py +278 -0
  59. auditkit/report_format.py +52 -0
  60. auditkit/router.py +54 -0
  61. auditkit/runner.py +575 -0
  62. auditkit/runspec.py +159 -0
  63. auditkit/sample.py +40 -0
  64. auditkit/scenario.py +88 -0
  65. auditkit/scenarios/README.md +10 -0
  66. auditkit/scenarios/__init__.py +4 -0
  67. auditkit/scenarios/arc.py +33 -0
  68. auditkit/scenarios/gsm8k.py +32 -0
  69. auditkit/scenarios/hellaswag.py +33 -0
  70. auditkit/scenarios/humaneval.py +32 -0
  71. auditkit/scenarios/mmlu.py +34 -0
  72. auditkit/scenarios/truthfulqa.py +33 -0
  73. auditkit/score.py +165 -0
  74. auditkit/scorers.py +117 -0
  75. auditkit/scoring.py +79 -0
  76. auditkit/types.py +69 -0
  77. auditkit-1.0.0.dist-info/METADATA +396 -0
  78. auditkit-1.0.0.dist-info/RECORD +81 -0
  79. auditkit-1.0.0.dist-info/WHEEL +4 -0
  80. auditkit-1.0.0.dist-info/entry_points.txt +2 -0
  81. auditkit-1.0.0.dist-info/licenses/LICENSE.md +92 -0
@@ -0,0 +1,133 @@
1
+ """Generic OpenAI-compatible HTTP API backend (optional extra: auditkit[requests])."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Any
6
+
7
+ from . import (
8
+ Model, Request, Result_, Generated, resolve_params, resolve_messages,
9
+ DEFAULT_TEMPERATURE, reject_generation_kwargs, split_session_kwargs,
10
+ )
11
+ from ..errors import ExtraNotInstalled
12
+
13
+ # OpenAI-compatible endpoints (the whole point of this backend) use
14
+ # OpenAI's own kwarg names.
15
+ _KEY_MAP = {
16
+ "temperature": "temperature",
17
+ "top_p": "top_p",
18
+ "max_tokens": "max_tokens",
19
+ "stop_sequences": "stop",
20
+ "presence_penalty": "presence_penalty",
21
+ "frequency_penalty": "frequency_penalty",
22
+ "num_completions": "n",
23
+ "seed": "seed",
24
+ }
25
+
26
+
27
+ class APIModel(Model):
28
+ """Call any OpenAI-compatible HTTP API.
29
+
30
+ Parameters
31
+ ----------
32
+ model
33
+ Model name sent in the API request body.
34
+ api_base
35
+ Base URL of the API endpoint (default: ``https://api.openai.com/v1``).
36
+ api_key
37
+ API key. Falls back to ``API_KEY`` env var, then ``OPENAI_API_KEY``.
38
+ chat_template
39
+ If True (default), uses ``/chat/completions`` with messages format.
40
+ If False, uses ``/completions`` with raw prompt.
41
+ """
42
+
43
+ name = "api"
44
+ threadsafe = False
45
+
46
+ def __init__(
47
+ self,
48
+ model: str = "gpt-4o-mini",
49
+ api_base: str = "https://api.openai.com/v1",
50
+ api_key: str | None = None,
51
+ chat_template: bool = True,
52
+ name: str = "api",
53
+ **kwargs: Any,
54
+ ) -> None:
55
+ reject_generation_kwargs(kwargs, "APIModel")
56
+ self._model_name = model
57
+ self._api_base = api_base.rstrip("/")
58
+ self._api_key = api_key or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
59
+ self._chat_template = chat_template
60
+ # verify=/proxies=/cert=/timeout= are real requests.Session concerns,
61
+ # not API request-body fields -- split them out so e.g.
62
+ # APIModel(verify=False) actually disables TLS verification (useful
63
+ # for a self-signed internal endpoint) instead of silently becoming
64
+ # an extra, ignored JSON field in the chat-completions payload.
65
+ self._session_kwargs, self._extra_kwargs = split_session_kwargs(kwargs)
66
+ self._session = None
67
+ self.name = name
68
+
69
+ def _ensure_session(self) -> None:
70
+ if self._session is not None:
71
+ return
72
+ try:
73
+ import requests
74
+ except ImportError:
75
+ raise ExtraNotInstalled("requests", "pip install auditkit[requests]")
76
+ self._session = requests.Session()
77
+ self._session.headers.update({
78
+ "Authorization": f"Bearer {self._api_key}" if self._api_key else "",
79
+ "Content-Type": "application/json",
80
+ })
81
+ for attr in ("verify", "proxies", "cert"):
82
+ if attr in self._session_kwargs:
83
+ setattr(self._session, attr, self._session_kwargs[attr])
84
+
85
+ def generate(self, requests: list[Request]) -> list[Result_]:
86
+ self._ensure_session()
87
+ results = []
88
+ for r in requests:
89
+ prompt = r.prompt if isinstance(r.prompt, str) else str(r.prompt)
90
+ defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
91
+ gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
92
+ if self._chat_template:
93
+ endpoint = f"{self._api_base}/chat/completions"
94
+ body = {
95
+ "model": self._model_name,
96
+ "messages": resolve_messages(r),
97
+ **gen_kwargs,
98
+ }
99
+ else:
100
+ endpoint = f"{self._api_base}/completions"
101
+ body = {
102
+ "model": self._model_name,
103
+ "prompt": prompt,
104
+ **gen_kwargs,
105
+ }
106
+ body.update(self._extra_kwargs)
107
+ timeout = self._session_kwargs.get("timeout", 120)
108
+ import time
109
+ t0 = time.monotonic()
110
+ resp = self._session.post(endpoint, json=body, timeout=timeout)
111
+ try:
112
+ resp.raise_for_status()
113
+ except Exception as e:
114
+ # requests' default HTTPError message drops the response
115
+ # body, which is where the real API actually explains what's
116
+ # wrong. Surface it instead of a bare "400 Client Error".
117
+ raise type(e)(f"{e} -- response body: {resp.text}") from None
118
+ latency_ms = (time.monotonic() - t0) * 1000
119
+ data = resp.json()
120
+ if self._chat_template:
121
+ text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
122
+ else:
123
+ text = data.get("choices", [{}])[0].get("text", "")
124
+ raw_usage = data.get("usage") or {}
125
+ usage = {}
126
+ if raw_usage:
127
+ usage = {
128
+ "prompt_tokens": raw_usage.get("prompt_tokens", 0),
129
+ "completion_tokens": raw_usage.get("completion_tokens", 0),
130
+ "total_tokens": raw_usage.get("total_tokens", 0),
131
+ }
132
+ results.append(Result_(completions=[Generated(text=text)], usage=usage, latency_ms=latency_ms))
133
+ return results
@@ -0,0 +1,121 @@
1
+ """Groq model backend (optional extra: auditkit[requests]).
2
+
3
+ Groq's API is a documented OpenAI-compatible endpoint
4
+ (https://api.groq.com/openai/v1, standard Bearer auth, /chat/completions
5
+ request/response shape) -- this backend is a thin, pre-configured wrapper so
6
+ you don't have to pass api_base=/api_key= through the generic api: backend
7
+ by hand every time.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import time
13
+ from typing import Any
14
+
15
+ from . import (
16
+ Model, Request, Result_, Generated, resolve_params, resolve_messages,
17
+ DEFAULT_TEMPERATURE, reject_generation_kwargs, split_session_kwargs,
18
+ )
19
+ from ..errors import ExtraNotInstalled
20
+
21
+ _KEY_MAP = {
22
+ "temperature": "temperature",
23
+ "top_p": "top_p",
24
+ "max_tokens": "max_tokens",
25
+ "stop_sequences": "stop",
26
+ "presence_penalty": "presence_penalty",
27
+ "frequency_penalty": "frequency_penalty",
28
+ "num_completions": "n",
29
+ "seed": "seed",
30
+ }
31
+
32
+
33
+ class GroqModel(Model):
34
+ """Call the Groq chat completions API.
35
+
36
+ Parameters
37
+ ----------
38
+ model
39
+ Model name on Groq (e.g. ``"llama-3.3-70b-versatile"``).
40
+ api_key
41
+ Groq API key. Falls back to the ``GROQ_API_KEY`` env var.
42
+ api_base
43
+ Base URL (default: ``https://api.groq.com/openai/v1``).
44
+ """
45
+
46
+ name = "groq"
47
+ threadsafe = False
48
+
49
+ def __init__(
50
+ self,
51
+ model: str = "llama-3.3-70b-versatile",
52
+ api_key: str | None = None,
53
+ api_base: str = "https://api.groq.com/openai/v1",
54
+ name: str = "groq",
55
+ **kwargs: Any,
56
+ ) -> None:
57
+ reject_generation_kwargs(kwargs, "GroqModel")
58
+ self._model_name = model
59
+ self._api_base = api_base.rstrip("/")
60
+ self._api_key = api_key or os.environ.get("GROQ_API_KEY")
61
+ # verify=/proxies=/cert=/timeout= are real requests.Session concerns,
62
+ # not API request-body fields -- split them out so e.g.
63
+ # GroqModel(verify=False) actually disables TLS verification instead
64
+ # of silently becoming an extra, ignored JSON field in the payload.
65
+ self._session_kwargs, self._extra_kwargs = split_session_kwargs(kwargs)
66
+ self._session = None
67
+ self.name = name
68
+
69
+ def _ensure_session(self) -> None:
70
+ if self._session is not None:
71
+ return
72
+ try:
73
+ import requests
74
+ except ImportError:
75
+ raise ExtraNotInstalled("requests", "pip install auditkit[requests]")
76
+ self._session = requests.Session()
77
+ self._session.headers.update({
78
+ "Authorization": f"Bearer {self._api_key}" if self._api_key else "",
79
+ "Content-Type": "application/json",
80
+ })
81
+ for attr in ("verify", "proxies", "cert"):
82
+ if attr in self._session_kwargs:
83
+ setattr(self._session, attr, self._session_kwargs[attr])
84
+
85
+ def generate(self, requests: list[Request]) -> list[Result_]:
86
+ self._ensure_session()
87
+ results = []
88
+ for r in requests:
89
+ defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
90
+ gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
91
+ body = {
92
+ "model": self._model_name,
93
+ "messages": resolve_messages(r),
94
+ **gen_kwargs,
95
+ **self._extra_kwargs,
96
+ }
97
+ timeout = self._session_kwargs.get("timeout", 120)
98
+ t0 = time.monotonic()
99
+ resp = self._session.post(f"{self._api_base}/chat/completions", json=body, timeout=timeout)
100
+ try:
101
+ resp.raise_for_status()
102
+ except Exception as e:
103
+ # requests' default HTTPError message drops the response
104
+ # body, which is where Groq actually explains what's wrong
105
+ # (an invalid model slug, an unsupported param, rate
106
+ # limiting, ...) -- surface it instead of a bare "400
107
+ # Client Error: Bad Request".
108
+ raise type(e)(f"{e} -- response body: {resp.text}") from None
109
+ latency_ms = (time.monotonic() - t0) * 1000
110
+ data = resp.json()
111
+ text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
112
+ raw_usage = data.get("usage") or {}
113
+ usage = {}
114
+ if raw_usage:
115
+ usage = {
116
+ "prompt_tokens": raw_usage.get("prompt_tokens", 0),
117
+ "completion_tokens": raw_usage.get("completion_tokens", 0),
118
+ "total_tokens": raw_usage.get("total_tokens", 0),
119
+ }
120
+ results.append(Result_(completions=[Generated(text=text)], usage=usage, latency_ms=latency_ms))
121
+ return results
@@ -0,0 +1,385 @@
1
+ """HuggingFace generation backend (optional extra: auditkit[transformers])."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from . import (
7
+ Model, Request, Result_, Generated, LogLikelihood, resolve_params,
8
+ DEFAULT_TEMPERATURE, reject_generation_kwargs, _free_torch_memory,
9
+ )
10
+ from ..errors import ExtraNotInstalled
11
+ from ..types import Capability
12
+
13
+ # stop_sequences maps to generate()'s real stop_strings kwarg (needs the
14
+ # tokenizer passed alongside it -- handled in generate() below, not here).
15
+ # seed has no generate()-kwarg equivalent at all; transformers' own
16
+ # set_seed() (a global RNG reset) is the only per-call reproducibility knob,
17
+ # so it's applied separately in generate() rather than through this map.
18
+ _KEY_MAP = {
19
+ "temperature": "temperature",
20
+ "top_p": "top_p",
21
+ "top_k": "top_k",
22
+ "max_tokens": "max_new_tokens",
23
+ "stop_sequences": "stop_strings",
24
+ }
25
+
26
+ # Model-LOADING kwargs `transformers.pipeline()` accepts at construction
27
+ # time (forwarded internally to from_pretrained) -- these must reach
28
+ # pipeline() itself in _ensure_pipeline(), not the per-call generate()
29
+ # invocation. The pipeline's __call__ doesn't accept any of these at all;
30
+ # passing e.g. torch_dtype= here previously raised TypeError at generate()
31
+ # time since it was blindly spread into **self._extra_kwargs on the call,
32
+ # never on construction. Anything in **kwargs NOT in this set is assumed
33
+ # to be a real generation-time kwarg (repetition_penalty, num_beams,
34
+ # no_repeat_ngram_size, ...) and keeps going to the pipeline call, as
35
+ # before -- this fix only redirects the loading-specific ones.
36
+ _LOAD_TIME_KWARGS = frozenset({
37
+ "torch_dtype", "dtype", "device_map", "trust_remote_code", "revision",
38
+ "low_cpu_mem_usage", "quantization_config", "model_kwargs", "use_fast",
39
+ "attn_implementation", "load_in_8bit", "load_in_4bit",
40
+ })
41
+
42
+
43
+ class HFGenModel(Model):
44
+ """Generate text using a HuggingFace transformers model."""
45
+
46
+ name = "hf"
47
+ is_local = True
48
+
49
+ def __init__(self, model: str = "gpt2", device: str | None = "auto",
50
+ name: str = "hf", hf_token: str | None = None,
51
+ token: str | None = None, **kwargs: Any) -> None:
52
+ reject_generation_kwargs(kwargs, "HFGenModel")
53
+ self._model_name = model
54
+ self._device = device
55
+ # Gated models (Llama, Gemma, ...) need a HuggingFace access token at
56
+ # load time; accept `hf_token` or `token` and pass it to from_pretrained.
57
+ self._token = hf_token or token
58
+ self._extra_kwargs = kwargs
59
+ self._pipeline = None
60
+ self.name = name
61
+
62
+ def capabilities(self) -> set[Capability]:
63
+ return {Capability.GENERATE, Capability.LOGLIKELIHOOD}
64
+
65
+ def model_info(self) -> dict[str, Any]:
66
+ """Real parameter count/sparsity/on-disk-equivalent size, introspected
67
+ from the loaded checkpoint -- never a caller-supplied guess.
68
+
69
+ Loads the pipeline first if it isn't already (so calling this before
70
+ any ``generate()``/``loglikelihood()`` call still works, e.g. from a
71
+ comparison that wants size info without necessarily having run yet).
72
+ ``size_mb`` is each parameter's actual in-memory dtype size summed up
73
+ (``numel() * element_size()``) -- the same bytes an on-disk safetensors
74
+ checkpoint stores, not a fixed fp32/fp16 assumption.
75
+ """
76
+ self._ensure_pipeline()
77
+ model = self._pipeline.model
78
+ params = list(model.parameters())
79
+ total_params = sum(p.numel() for p in params)
80
+ # nonzero count is what actually shows pruning -- a structurally
81
+ # pruned model still allocates the same tensor shapes, just with
82
+ # zeroed-out weights, so total_params alone can't tell two
83
+ # differently-pruned checkpoints apart.
84
+ nonzero_params = sum(int((p != 0).sum().item()) for p in params)
85
+ size_bytes = sum(p.numel() * p.element_size() for p in params)
86
+ return {
87
+ "is_local": True,
88
+ "model_name": self._model_name,
89
+ "total_params": total_params,
90
+ "nonzero_params": nonzero_params,
91
+ "sparsity": 1.0 - (nonzero_params / total_params) if total_params else 0.0,
92
+ "size_mb": size_bytes / (1024 * 1024),
93
+ }
94
+
95
+ def unload(self) -> None:
96
+ """Drop the loaded transformers pipeline (model + tokenizer) and free
97
+ the GPU cache -- so ``compare_models()`` can reclaim this checkpoint's
98
+ memory before loading the next. Lazily reloads on the next call."""
99
+ self._pipeline = None
100
+ _free_torch_memory()
101
+
102
+ def _resolve_device(self) -> str:
103
+ """Best available device: CUDA GPU > Apple Silicon MPS > CPU.
104
+
105
+ transformers.pipeline(device=...) forwards straight to
106
+ torch.device(...), which has no concept of "auto" -- it only
107
+ understands concrete device-type strings ("cpu", "cuda", "mps",
108
+ ...). "auto" is a real HF concept, but it belongs to a different
109
+ parameter (device_map="auto" on from_pretrained's multi-GPU
110
+ placement), not this one. Previously "auto" was forwarded verbatim
111
+ and crashed immediately on every zero-config construction. An
112
+ explicit device (e.g. "cpu") always wins; detection only runs when
113
+ the caller left it at the default or passed None.
114
+ """
115
+ if self._device not in (None, "auto"):
116
+ return self._device
117
+ import torch
118
+ if torch.cuda.is_available():
119
+ return "cuda"
120
+ if torch.backends.mps.is_available():
121
+ return "mps"
122
+ return "cpu"
123
+
124
+ def _logits_processor(self):
125
+ """A processor that sanitizes non-finite logits before sampling,
126
+ WITHOUT touching genuinely finite ones.
127
+
128
+ Some checkpoints (confirmed: gpt2-medium, on this environment) emit
129
+ nan/inf logits straight out of the forward pass for certain prompts --
130
+ a numerical-precision issue in the model computation itself, not
131
+ something caused by our decoding settings. torch.multinomial (real
132
+ sampling) rejects a distribution containing any nan/inf/negative
133
+ value outright, crashing the whole run.
134
+
135
+ A previous version of this fix blanket-clamped every logit
136
+ (finite or not) to a fixed [-50, 50] range. That's a real,
137
+ confirmed bug for checkpoints whose legitimate logit scale exceeds
138
+ that range: bigscience/bloom-560m's real, non-NaN logits for a
139
+ typical prompt reach ~430 for the correct top token, and clamping
140
+ collapses every one of its top ~30 tokens down to the SAME ceiling
141
+ value of 50.0 -- creating an artificial tie that argmax/greedy then
142
+ breaks by lowest token ID, which favors a low-ID special token
143
+ (confirmed: the real EOS token, id 2, won the tie over the actual
144
+ best token " Paris"), producing empty generated text instead of a
145
+ real answer. Only the positions that are ACTUALLY non-finite are
146
+ replaced now; every genuinely finite logit -- regardless of
147
+ magnitude -- passes through completely untouched, so a model's
148
+ real, correctly-ranked distribution is never altered unless it
149
+ would otherwise crash generation outright.
150
+ """
151
+ import torch
152
+ from transformers import LogitsProcessor
153
+
154
+ class _SanitizeLogits(LogitsProcessor):
155
+ def __call__(self, input_ids, scores):
156
+ non_finite = ~torch.isfinite(scores)
157
+ if not non_finite.any():
158
+ return scores
159
+ scores = scores.clone()
160
+ # A fixed, safe replacement for whatever was actually
161
+ # broken -- not a clamp applied to the whole tensor.
162
+ scores[non_finite & (scores == float("inf"))] = 1e4
163
+ scores[non_finite & (scores == float("-inf"))] = -1e4
164
+ scores[non_finite & torch.isnan(scores)] = -1e4
165
+ return scores
166
+
167
+ return _SanitizeLogits()
168
+
169
+ def _ensure_pipeline(self) -> None:
170
+ if self._pipeline is not None:
171
+ return
172
+ try:
173
+ import torch # noqa: F401 -- forces a clear ExtraNotInstalled if torch is missing
174
+ from transformers import pipeline
175
+ except ImportError:
176
+ raise ExtraNotInstalled("transformers", "pip install auditkit[transformers]")
177
+ pipe_kwargs: dict[str, Any] = {}
178
+ if self._token:
179
+ pipe_kwargs["token"] = self._token
180
+ for k in _LOAD_TIME_KWARGS:
181
+ if k in self._extra_kwargs:
182
+ pipe_kwargs[k] = self._extra_kwargs[k]
183
+ self._pipeline = pipeline(
184
+ "text-generation",
185
+ model=self._model_name,
186
+ device=self._resolve_device(),
187
+ **pipe_kwargs,
188
+ )
189
+ # generate() always drives length via max_new_tokens (see _KEY_MAP) --
190
+ # never max_length. Most checkpoints still ship a generation_config.json
191
+ # with a default max_length (commonly 20), which transformers otherwise
192
+ # warns about on every single call ("Both max_new_tokens and max_length
193
+ # seem to have been set ... max_new_tokens will take precedence").
194
+ # Harmless in practice (the warning itself confirms max_new_tokens wins),
195
+ # but noisy on every generate() call across an entire run.
196
+ #
197
+ # Pipeline.__init__ deep-copies model.generation_config into its OWN
198
+ # self.generation_config attribute at construction time (see
199
+ # transformers/pipelines/base.py), and TextGenerationPipeline reads
200
+ # that copy, not the model's live one -- so clearing only
201
+ # self._pipeline.model.generation_config.max_length here has no
202
+ # effect on the warning; the pipeline's own already-copied
203
+ # generation_config has to be cleared too.
204
+ #
205
+ # Guarded with getattr: not every transformers version/model exposes a
206
+ # `generation_config` on the pipeline (older TextGenerationPipeline
207
+ # lacks the attribute) or a non-None one on the model. Clearing it is
208
+ # only warning-suppression, so a missing attribute must never crash
209
+ # pipeline construction (it previously raised AttributeError on, e.g.,
210
+ # sshleifer/tiny-gpt2, taking down generate()/loglikelihood() entirely).
211
+ for _holder in (self._pipeline.model, self._pipeline):
212
+ _gen_cfg = getattr(_holder, "generation_config", None)
213
+ if _gen_cfg is not None:
214
+ _gen_cfg.max_length = None
215
+
216
+ def _render_prompt(self, request: Request) -> str:
217
+ """Render *request* to the literal text sent to the model.
218
+
219
+ The backend owns the model-specific wire format, so *any* adapter's
220
+ output is formatted correctly for the model at hand — routing decides
221
+ the technique (MCQ/RAG/generation), never the chat-vs-base formatting:
222
+
223
+ * **Instruct/chat model** (tokenizer has a ``chat_template``): render
224
+ through the model's *own* template with its real special tokens
225
+ (``<|im_start|>``, ``[INST]``, ...). Structured turns
226
+ (``request.params["messages"]``, e.g. from ``ChatAdapter``) render
227
+ as-is; a flat prompt from any other adapter is wrapped as a single
228
+ ``user`` turn so it still gets the model's real formatting instead
229
+ of raw text it was never trained on.
230
+ * **Base model** (no ``chat_template``): the plain prompt text.
231
+ """
232
+ text = request.prompt if isinstance(request.prompt, str) else str(request.prompt)
233
+ # Explicit opt-out: send the prompt verbatim, skipping the chat template.
234
+ # Classifier-style models (e.g. some safety guards like WildGuard /
235
+ # HarmBench) are trained on their own fixed prompt string; wrapping that
236
+ # in the tokenizer's chat template would corrupt it.
237
+ if request.params.get("apply_chat_template") is False:
238
+ return text
239
+ tokenizer = getattr(self._pipeline, "tokenizer", None)
240
+ chat_template = getattr(tokenizer, "chat_template", None) if tokenizer is not None else None
241
+ if not chat_template:
242
+ return text
243
+ messages = request.params.get("messages") or [{"role": "user", "content": text}]
244
+ # Extra kwargs some chat templates need (e.g. a safety guard's per-call
245
+ # policy: ShieldGemma's `guideline=`, Granite Guardian's `guardian_config=`).
246
+ extra = request.params.get("chat_template_kwargs") or {}
247
+ return tokenizer.apply_chat_template(
248
+ messages, tokenize=False, add_generation_prompt=True, **extra)
249
+
250
+ def generate(self, requests: list[Request]) -> list[Result_]:
251
+ self._ensure_pipeline()
252
+ prompts = [self._render_prompt(r) for r in requests]
253
+ # The pipeline runs all prompts in one batched call sharing one
254
+ # sampling config, so per-request param variation isn't meaningful
255
+ # here -- the first request's params represent the whole batch
256
+ # (in practice uniform, since one evaluate() run shares one RunConfig).
257
+ defaults = {"temperature": DEFAULT_TEMPERATURE, "max_new_tokens": 128}
258
+ gen_kwargs = resolve_params(requests[0], defaults, _KEY_MAP) if requests else defaults
259
+ # Never hand the pipeline an ambiguous "sample, but with no temperature"
260
+ # combination -- some checkpoints' own generation_config defaults to
261
+ # do_sample=True, and sampling with temperature=None can produce an
262
+ # invalid (nan/inf) probability tensor for some models (confirmed:
263
+ # gpt2-medium crashes this way, plain gpt2 doesn't). Explicitly choose
264
+ # greedy vs. sampling instead of leaving it to be inferred.
265
+ temp = gen_kwargs.pop("temperature")
266
+ if temp is not None and temp > 0:
267
+ gen_kwargs["temperature"] = temp
268
+ gen_kwargs["do_sample"] = True
269
+ else:
270
+ gen_kwargs["do_sample"] = False
271
+ # generate()'s stop_strings criterion needs the tokenizer passed
272
+ # alongside it to check decoded output against -- the pipeline
273
+ # doesn't supply this on its own.
274
+ if "stop_strings" in gen_kwargs:
275
+ gen_kwargs["tokenizer"] = self._pipeline.tokenizer
276
+ # No per-call seed kwarg exists for generate()/pipeline() -- transformers'
277
+ # own set_seed() resets the global torch/numpy/python RNGs, the only
278
+ # reproducibility knob available. A real, if blunt, effect: same seed
279
+ # + same prompt + do_sample=True reliably reproduces the same output.
280
+ seed = requests[0].params.get("seed") if requests else None
281
+ if seed is not None:
282
+ from transformers import set_seed
283
+ set_seed(seed)
284
+ call_time_extra_kwargs = {
285
+ k: v for k, v in self._extra_kwargs.items() if k not in _LOAD_TIME_KWARGS
286
+ }
287
+ outputs = self._pipeline(
288
+ prompts,
289
+ # transformers' text-generation pipeline defaults to echoing the
290
+ # full prompt back as part of "generated_text" -- every other
291
+ # backend (openai.py, anthropic.py, vllm_gen.py, ...) returns only
292
+ # the new completion, never the input. That inconsistency broke
293
+ # LLMJudge: its instructions necessarily list every valid verdict
294
+ # label, so with the prompt echoed back, the parser reliably
295
+ # mistook its own instructions for the model's real answer.
296
+ return_full_text=False,
297
+ logits_processor=[self._logits_processor()],
298
+ **gen_kwargs,
299
+ **call_time_extra_kwargs,
300
+ )
301
+ results = []
302
+ for out in outputs:
303
+ if isinstance(out, list):
304
+ text = out[0].get("generated_text", "") if isinstance(out[0], dict) else str(out[0])
305
+ elif isinstance(out, dict):
306
+ text = out.get("generated_text", "")
307
+ else:
308
+ text = str(out)
309
+ results.append(Result_(completions=[Generated(text=text)]))
310
+ return results
311
+
312
+ def loglikelihood(self, requests: list[Request]) -> list[LogLikelihood]:
313
+ """Real per-candidate log-probability scoring via teacher forcing.
314
+
315
+ For each request, feeds ``prompt + target`` through the model with
316
+ the target's own tokens forced as the continuation (never sampled
317
+ or generated), and sums the log-probability the model assigns to
318
+ each of the target's actual tokens, conditioned on everything
319
+ before it. This is the standard MCQ-by-loglikelihood technique
320
+ (the same approach lm-eval-harness uses for ``hf:`` models) -- it
321
+ measures how likely the model considers a *specific* candidate
322
+ answer, without requiring it to generate that exact text itself.
323
+
324
+ Reuses the same lazily-loaded pipeline's model/tokenizer as
325
+ ``generate()`` (``transformers.pipeline`` exposes both as plain
326
+ attributes) -- no separate model load. One forward pass per
327
+ request, not yet batched across requests (a real cost -- see the
328
+ design note this followed up on -- but batching ragged
329
+ prompt/continuation lengths correctly, with proper padding and
330
+ attention masks, is real additional surface area for a subtle
331
+ numerical bug; left as a follow-up once this is verified correct).
332
+ """
333
+ self._ensure_pipeline()
334
+ import torch
335
+
336
+ model = self._pipeline.model
337
+ tokenizer = self._pipeline.tokenizer
338
+ device = self._pipeline.device
339
+
340
+ results: list[LogLikelihood] = []
341
+ with torch.no_grad():
342
+ for r in requests:
343
+ prompt = r.prompt if isinstance(r.prompt, str) else str(r.prompt)
344
+ target = r.params.get("target", "")
345
+
346
+ # Tokenize context alone and context+target together (not
347
+ # context and target separately) so continuation_ids reflects
348
+ # how the tokenizer actually splits the continuation as part
349
+ # of one contiguous string -- BPE merges can span the
350
+ # context/continuation boundary, so naive separate
351
+ # tokenization can silently produce different token ids
352
+ # than what the model will actually see.
353
+ context_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
354
+ full_ids = tokenizer(prompt + target, add_special_tokens=False)["input_ids"]
355
+ continuation_ids = full_ids[len(context_ids):]
356
+
357
+ if not continuation_ids:
358
+ # Empty target (or one that tokenizes to nothing new) --
359
+ # nothing to score.
360
+ results.append(LogLikelihood(logprob=0.0, is_greedy=True))
361
+ continue
362
+
363
+ input_ids = torch.tensor([full_ids], device=device)
364
+ logits = model(input_ids=input_ids).logits[0] # [seq_len, vocab]
365
+ # log_probs[j] is the model's distribution for the token at
366
+ # position j+1, predicted from positions [0..j].
367
+ log_probs = torch.log_softmax(logits[:-1], dim=-1)
368
+ target_ids = torch.tensor(full_ids[1:], device=device)
369
+ token_log_probs = log_probs.gather(1, target_ids.unsqueeze(-1)).squeeze(-1)
370
+
371
+ # The first continuation token is at full_ids[len(context_ids)],
372
+ # predicted by log_probs[len(context_ids) - 1]. (Empty context
373
+ # has no preceding token to condition the first continuation
374
+ # token on -- clamped to 0 as a best-effort approximation,
375
+ # not mathematically exact for that edge case.)
376
+ start = max(len(context_ids) - 1, 0)
377
+ continuation_log_probs = token_log_probs[start:]
378
+ greedy_ids = log_probs[start:].argmax(dim=-1)
379
+ is_greedy = bool(torch.equal(greedy_ids, target_ids[start:]))
380
+
381
+ results.append(LogLikelihood(
382
+ logprob=float(continuation_log_probs.sum().item()),
383
+ is_greedy=is_greedy,
384
+ ))
385
+ return results