Coverage for transformer_lens/model_bridge/sources/inspect/_provider_base.py: 85%
103 statements
« prev ^ index » next coverage.py v7.10.1, created at 2026-09-21 19:27 +0000
« prev ^ index » next coverage.py v7.10.1, created at 2026-09-21 19:27 +0000
1"""Shared base for ``tl_bridge``-style Inspect providers (HF, vLLM).
3Subclasses load their model + tokenizer, run their structural self-check, and implement
4backend-specific ``_generate_capture`` / ``_generate_eval`` — this base owns the
5``generate()`` dispatch, message-to-ids rendering (chat template + tools), logprob
6construction, and validation of the per-turn ``capture=[...]`` config.
8Subclass contract:
9- Set ``self._tokenizer`` (HF-style ``AutoTokenizer``), ``self._device`` (str),
10 ``self._kinds`` (frozenset of served boundary kinds), ``self._capability_note`` (str
11 explaining any gating), and ``self._eval_capture`` (dict ``{wire_key: hook_name}``,
12 built via :meth:`_parse_eval_capture`) before any ``generate()`` call.
13- Implement ``_generate_capture(input, extra_args, config)`` (TL-driven single forward) and
14 ``_generate_eval(input, config, tools)`` (multi-token chat generation).
15"""
16from __future__ import annotations
18import json
19import re
20import uuid
21import warnings
22from typing import Any, Mapping
24import torch
25from inspect_ai.model import GenerateConfig, Logprob, ModelAPI, TopLogprob
26from inspect_ai.tool import ToolCall
28from . import hooks
31def _message_text(message: Any) -> str:
32 """Text of an Inspect chat message — its ``.text`` (handles multimodal content),
33 falling back to string content, else ''."""
34 text = getattr(message, "text", None)
35 if isinstance(text, str): 35 ↛ 37line 35 didn't jump to line 37 because the condition on line 35 was always true
36 return text
37 content = getattr(message, "content", None)
38 return content if isinstance(content, str) else ""
41def _tool_schema(tool: Any) -> dict[str, Any]:
42 """Inspect ``ToolInfo`` → OpenAI-style function schema for ``apply_chat_template``."""
43 params = tool.parameters
44 params = params.model_dump() if hasattr(params, "model_dump") else dict(params)
45 return {
46 "type": "function",
47 "function": {"name": tool.name, "description": tool.description, "parameters": params},
48 }
51# Tool-call blocks emitted by common instruct templates (Qwen/Hermes-style); the bare-JSON
52# fallback covers models that emit a single {"name", "arguments"} object.
53_TOOL_CALL_BLOCK = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)
56def _parse_tool_calls(text: str) -> list[ToolCall] | None:
57 """Best-effort parse of tool calls from a completion. Model-specific formats vary; this
58 handles ``<tool_call>{json}</tool_call>`` blocks and a single bare ``{name, arguments}``."""
59 blocks = _TOOL_CALL_BLOCK.findall(text)
60 if not blocks:
61 match = re.search(r"\{.*\}", text, re.S)
62 blocks = [match.group(0)] if match else []
63 calls = []
64 for block in blocks:
65 try:
66 obj = json.loads(block)
67 except (ValueError, TypeError):
68 continue
69 name = obj.get("name") if isinstance(obj, dict) else None
70 if not isinstance(name, str): 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 continue
72 args = obj.get("arguments") or obj.get("parameters") or {}
73 calls.append(
74 ToolCall(
75 id=uuid.uuid4().hex[:8],
76 function=name,
77 arguments=args if isinstance(args, dict) else {},
78 )
79 )
80 return calls or None
83# Generation-semantics GenerateConfig fields neither provider maps; warn (once per field
84# per process) instead of silently ignoring them.
85_UNSUPPORTED_GENERATE_FIELDS = (
86 "frequency_penalty",
87 "presence_penalty",
88 "logit_bias",
89 "best_of",
90 "num_choices",
91)
92_WARNED_UNSUPPORTED: set[str] = set()
95def _warn_unsupported_config(config: GenerateConfig, provider: str) -> None:
96 """Warn once per process per set-but-unsupported GenerateConfig field."""
97 new = [
98 field
99 for field in _UNSUPPORTED_GENERATE_FIELDS
100 if getattr(config, field, None) is not None and field not in _WARNED_UNSUPPORTED
101 ]
102 if new:
103 _WARNED_UNSUPPORTED.update(new)
104 warnings.warn(
105 f"{provider}: ignoring unsupported GenerateConfig field(s) {new}.",
106 UserWarning,
107 stacklevel=3,
108 )
111def _require_served(kind: str, served: frozenset[str], note: str, context: str) -> None:
112 """Raise if ``kind`` was gated by the structural self-check — without this the eval path
113 would silently return a derivation (e.g. ``resid_mid``) the driver path excludes."""
114 if kind not in served:
115 raise ValueError(
116 f"{context} requests kind {kind!r} which this model gated. {note} "
117 f"Served kinds: {sorted(served)}."
118 )
121def _require_interveneable(kind: str, served: frozenset[str], note: str, context: str) -> None:
122 """``_require_served`` plus the capture-only gate — shared by every provider's
123 intervention entry point so gated/capture-only kinds fail identically."""
124 from .hooks import INTERVENEABLE_KINDS
126 _require_served(kind, served, note, context)
127 if kind not in INTERVENEABLE_KINDS:
128 raise ValueError(
129 f"{context}: kind {kind!r} is capture-only "
130 f"(interveneable: {sorted(INTERVENEABLE_KINDS)})."
131 )
134class _InspectModelAPIBase(ModelAPI):
135 """Shared Inspect ModelAPI scaffolding for ``tl_bridge``-style providers.
137 Subclasses populate ``self._tokenizer``/``_device``/``_kinds``/``_capability_note``/
138 ``_eval_capture`` in ``__init__`` and implement the backend-specific generate paths.
139 """
141 # Attributes set by subclass __init__ before any generate() call (declared here so the
142 # shared helpers' type-checking sees them; not initialized to avoid masking bugs).
143 _tokenizer: Any
144 _device: Any
145 _kinds: frozenset
146 _capability_note: str
147 _eval_capture: dict[str, str]
149 # Class-level capability flag, read by source.py → TLBridgeProfile → InspectDriver →
150 # RemoteBridge.forward to gate return_type ∈ {loss, both}. Must be set by every
151 # subclass: True iff every position 0..n-1 of metadata['tl_logits'] holds real values
152 # (not -inf padding). HF does a true forward → full logits; vLLM's sampler bypasses
153 # lm_head and only the generated position has logprobs, so vLLM=False.
154 provides_sequence_logits: bool
156 # --- subclass contract -------------------------------------------------------------
158 def _generate_capture(
159 self, input: Any, extra_args: Mapping[str, Any], config: GenerateConfig
160 ) -> Any:
161 """TL-driven single-forward capture (residual/attn/mlp boundaries + full logits)."""
162 raise NotImplementedError
164 def _generate_eval(self, input: Any, config: GenerateConfig, tools: Any) -> Any:
165 """Plain Inspect generation: chat input → multi-token completion + Logprobs +
166 ModelUsage (+ parsed tool calls when ``tools`` is non-empty + per-turn capture)."""
167 raise NotImplementedError
169 # --- shared API --------------------------------------------------------------------
171 def supported_kinds(self) -> frozenset:
172 """Boundary kinds this model is structurally able to serve."""
173 return self._kinds
175 def capability_note(self) -> str:
176 """Human-readable reason for any gated boundary, or '' if all are served."""
177 return self._capability_note
179 async def generate(self, input, tools, tool_choice, config): # type: ignore[override]
180 # Two callers: the TL driver (extra_args carries input_ids/capture/interventions —
181 # single-forward activation capture) and a plain Inspect eval (chat messages, real
182 # multi-token generation). Branch on whether a TL request is present.
183 extra_args: Mapping[str, Any] = (config.extra_body or {}).get("extra_args", {})
184 if extra_args.get("input_ids") is not None or extra_args.get("capture"):
185 return self._generate_capture(input, extra_args, config)
186 return self._generate_eval(input, config, tools or [])
188 # --- shared helpers ----------------------------------------------------------------
190 def _parse_eval_capture(self, model_args: dict[str, Any]) -> dict[str, str]:
191 """Validate ``model_args["capture"]`` against the structural self-check (same
192 protection as the driver path) and key by wire key. Returns ``{wire_key: name}``."""
193 eval_capture: dict[str, str] = {}
194 for name in model_args.pop("capture", None) or []:
195 resolved = hooks.resolve(name)
196 if resolved is None:
197 raise ValueError(f"capture={name!r} is not a fireable hook name.")
198 _require_served(resolved[1], self._kinds, self._capability_note, f"capture={name!r}")
199 eval_capture[hooks.wire_key(*resolved)] = name
200 return eval_capture
202 def _messages_to_ids(self, input: Any, tools: Any = ()) -> Any:
203 """Render Inspect chat messages (+ any ``tools``) to input ids — chat template
204 when the tokenizer has one, else newline-joined message text (e.g. gpt2)."""
205 if isinstance(input, str):
206 messages = [{"role": "user", "content": input}]
207 else:
208 messages = [
209 {"role": getattr(m, "role", "user"), "content": _message_text(m)} for m in input
210 ]
211 template = getattr(self._tokenizer, "chat_template", None)
212 if len(tools) and not template:
213 raise NotImplementedError(
214 f"tl_bridge: tool use needs a tool-aware chat template; {self.model_name} has "
215 "none. Serve a tool-capable instruct model for agentic evals."
216 )
217 if template: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 kwargs: dict[str, Any] = {"add_generation_prompt": True}
219 if len(tools):
220 kwargs["tools"] = [_tool_schema(t) for t in tools]
221 token_ids = self._tokenizer.apply_chat_template(messages, **kwargs)
222 else:
223 token_ids = self._tokenizer("\n".join(m["content"] for m in messages)).input_ids
224 return torch.tensor([list(token_ids)], device=self._device)
226 def _logprob_entry(self, token_id: int, step_logits: Any, top_n: Any) -> Logprob:
227 """One token's log-prob (+ top-k alternatives) from a position's logits."""
228 lp = torch.log_softmax(step_logits.float(), dim=-1)
229 top = []
230 if top_n: 230 ↛ 238line 230 didn't jump to line 238 because the condition on line 230 was always true
231 vals, idx = lp.topk(int(top_n))
232 top = [
233 TopLogprob(
234 token=str(self._tokenizer.decode([int(i)])), logprob=float(v), bytes=None
235 )
236 for v, i in zip(vals.tolist(), idx.tolist())
237 ]
238 return Logprob(
239 token=str(self._tokenizer.decode([int(token_id)])),
240 logprob=float(lp[int(token_id)]),
241 bytes=None,
242 top_logprobs=top,
243 )