brainpatch 1.2.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 (82) hide show
  1. brainpatch/__init__.py +92 -0
  2. brainpatch/backends/__init__.py +19 -0
  3. brainpatch/backends/llamacpp.py +383 -0
  4. brainpatch/backends/mlx_backend.py +213 -0
  5. brainpatch/backends/transformers_backend.py +473 -0
  6. brainpatch/backends/vllm_backend.py +299 -0
  7. brainpatch/backends/vllm_worker.py +129 -0
  8. brainpatch/cli.py +825 -0
  9. brainpatch/config.py +245 -0
  10. brainpatch/datasets/__init__.py +20 -0
  11. brainpatch/datasets/contrast_sets.py +64 -0
  12. brainpatch/evaluation/__init__.py +28 -0
  13. brainpatch/evaluation/metrics.py +223 -0
  14. brainpatch/patch/__init__.py +64 -0
  15. brainpatch/patch/compiler.py +324 -0
  16. brainpatch/patch/format.py +489 -0
  17. brainpatch/patch/loader.py +312 -0
  18. brainpatch/patch/registry.py +300 -0
  19. brainpatch/patch/tensors.py +236 -0
  20. brainpatch/patch/validation.py +157 -0
  21. brainpatch/paths.py +184 -0
  22. brainpatch/py.typed +0 -0
  23. brainpatch/research/__init__.py +16 -0
  24. brainpatch/research/antisycophancy.py +348 -0
  25. brainpatch/research/behaviour_eval.py +711 -0
  26. brainpatch/research/generation_eval.py +346 -0
  27. brainpatch/research/ml/__init__.py +35 -0
  28. brainpatch/research/ml/activation_store.py +232 -0
  29. brainpatch/research/ml/causal.py +386 -0
  30. brainpatch/research/ml/corpus.py +165 -0
  31. brainpatch/research/ml/evaluation.py +188 -0
  32. brainpatch/research/ml/extraction.py +464 -0
  33. brainpatch/research/ml/feature_analysis.py +317 -0
  34. brainpatch/research/ml/generation.py +109 -0
  35. brainpatch/research/ml/hooks.py +183 -0
  36. brainpatch/research/ml/intervention.py +274 -0
  37. brainpatch/research/ml/model.py +219 -0
  38. brainpatch/research/ml/patch_search.py +337 -0
  39. brainpatch/research/ml/runtime.py +343 -0
  40. brainpatch/research/ml/sae.py +383 -0
  41. brainpatch/research/ml/training.py +376 -0
  42. brainpatch/research/stance_rubric.py +170 -0
  43. brainpatch/research/sycophancy_data.py +982 -0
  44. brainpatch/research/sycophancy_data_r1.py +1701 -0
  45. brainpatch/research/sycophancy_data_v2.py +1649 -0
  46. brainpatch/research/sycophancy_data_v3.py +2288 -0
  47. brainpatch/research/sycophancy_v2_build.py +362 -0
  48. brainpatch/research/sycophancy_v3_build.py +188 -0
  49. brainpatch/research/utility_probe.py +139 -0
  50. brainpatch/runtime/__init__.py +50 -0
  51. brainpatch/runtime/auto.py +157 -0
  52. brainpatch/runtime/base.py +311 -0
  53. brainpatch/runtime/capabilities.py +96 -0
  54. brainpatch/runtime/model.py +260 -0
  55. brainpatch/runtime/scheduling.py +13 -0
  56. brainpatch/schemas/__init__.py +35 -0
  57. brainpatch/schemas/contrast.py +161 -0
  58. brainpatch/schemas/feature.py +193 -0
  59. brainpatch/schemas/manifest.py +167 -0
  60. brainpatch/schemas/patch.py +379 -0
  61. brainpatch/schemas/patch_io.py +88 -0
  62. brainpatch/schemas/sae.py +146 -0
  63. brainpatch/server/__init__.py +11 -0
  64. brainpatch/server/app.py +269 -0
  65. brainpatch/steering/__init__.py +13 -0
  66. brainpatch/steering/plan.py +177 -0
  67. brainpatch/steering/schedule.py +138 -0
  68. brainpatch/ui/__init__.py +11 -0
  69. brainpatch/ui/app.py +201 -0
  70. brainpatch/verify/__init__.py +66 -0
  71. brainpatch/verify/behavioural.py +156 -0
  72. brainpatch/verify/checks.py +204 -0
  73. brainpatch/verify/corruptions.py +335 -0
  74. brainpatch/verify/report.py +133 -0
  75. brainpatch/verify/vectors.py +95 -0
  76. brainpatch/verify/workflow.py +331 -0
  77. brainpatch-1.2.0.dist-info/METADATA +556 -0
  78. brainpatch-1.2.0.dist-info/RECORD +82 -0
  79. brainpatch-1.2.0.dist-info/WHEEL +5 -0
  80. brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
  81. brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
  82. brainpatch-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,274 @@
1
+ """Runtime feature injection into the residual stream.
2
+
3
+ A :class:`FeatureSteerer` owns one SAE and one hook site, and turns an
4
+ :class:`~brainpatch.steering.plan.InterventionPlan` into a concrete vector added
5
+ to the residual stream on each forward pass.
6
+
7
+ Scale
8
+ -----
9
+ The SAE was trained on activations multiplied by ``input_scale`` so that
10
+ ``E[||x||] == sqrt(d_in)``. Decoder columns therefore live in normalized space.
11
+ To inject a direction back into the *raw* residual stream we divide by that
12
+ scale::
13
+
14
+ delta_raw = (coefficient * unit_direction) / input_scale
15
+
16
+ This is the step that makes ``strength`` portable: strength 1.0 adds one unit of
17
+ normalized activation-space distance regardless of how large the raw residual
18
+ stream happens to be at that layer.
19
+
20
+ Token indexing
21
+ --------------
22
+ With a KV cache, generation runs one forward pass over the whole prompt and then
23
+ one pass per new token. The steerer counts passes so that "generated token
24
+ index" means what a user expects: index 0 is the first token the model emits,
25
+ and the prompt is not counted.
26
+
27
+ Controls
28
+ --------
29
+ :class:`RandomDirectionSteerer` and unrelated-feature interventions use the same
30
+ coefficient path as the real thing. Since both decoder columns and the random
31
+ directions are unit-norm, the injected vectors have *identical* L2 norms by
32
+ construction -- the control differs only in direction. That is the comparison
33
+ that makes an effect attributable to the feature rather than to the magnitude of
34
+ the perturbation.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass, field
40
+ from typing import Any
41
+
42
+ import torch
43
+
44
+ from brainpatch.research.ml.hooks import ResidualInjector
45
+ from brainpatch.research.ml.sae import TopKSAE
46
+ from brainpatch.steering.plan import InterventionPlan, PlannedEdit
47
+
48
+
49
+ @dataclass
50
+ class SteeringStats:
51
+ """Bookkeeping that lets a caller verify an intervention actually happened."""
52
+
53
+ forward_passes: int = 0
54
+ applied_passes: int = 0
55
+ total_delta_norm: float = 0.0
56
+ max_delta_norm: float = 0.0
57
+ #: ``(generated_token_index, delta_norm)`` for every forward pass, including
58
+ #: passes where nothing was applied (norm 0.0). Recording the skipped passes
59
+ #: too is what makes this a usable trace of a dynamic schedule -- otherwise
60
+ #: the list index would silently stop matching the token index.
61
+ per_token_norms: list[tuple[int, float]] = field(default_factory=list)
62
+
63
+ @property
64
+ def mean_delta_norm(self) -> float:
65
+ return self.total_delta_norm / self.applied_passes if self.applied_passes else 0.0
66
+
67
+ def to_dict(self, *, include_per_token: bool = False) -> dict[str, Any]:
68
+ data: dict[str, Any] = {
69
+ "forward_passes": self.forward_passes,
70
+ "applied_passes": self.applied_passes,
71
+ "mean_delta_norm": self.mean_delta_norm,
72
+ "max_delta_norm": self.max_delta_norm,
73
+ }
74
+ if include_per_token:
75
+ data["per_token_norms"] = [[i, round(n, 5)] for i, n in self.per_token_norms]
76
+ return data
77
+
78
+
79
+ class FeatureSteerer:
80
+ """Builds and applies residual-stream deltas for one hook site."""
81
+
82
+ def __init__(
83
+ self,
84
+ sae: TopKSAE,
85
+ plan: InterventionPlan,
86
+ *,
87
+ layer: int,
88
+ input_scale: float,
89
+ device: torch.device | str = "cuda",
90
+ apply_to_prompt: bool = True,
91
+ ) -> None:
92
+ self.sae = sae
93
+ self.plan = plan
94
+ self.layer = layer
95
+ self.input_scale = float(input_scale)
96
+ if self.input_scale == 0:
97
+ raise ValueError("input_scale must be non-zero")
98
+ self.device = torch.device(device)
99
+ self.apply_to_prompt = apply_to_prompt
100
+
101
+ self._pass_index = 0
102
+ self.stats = SteeringStats()
103
+ #: Cache of unit decoder directions, so a long generation does not
104
+ #: re-slice and re-normalize the same columns thousands of times.
105
+ self._direction_cache: dict[int, torch.Tensor] = {}
106
+
107
+ # -- lifecycle -------------------------------------------------------------
108
+
109
+ def reset(self) -> None:
110
+ """Reset token counting and stats. Call before every generation."""
111
+ self._pass_index = 0
112
+ self.stats = SteeringStats()
113
+
114
+ @property
115
+ def generated_index(self) -> int:
116
+ """Generated-token index for the pass currently being processed.
117
+
118
+ ``-1`` during the prompt pass, then 0, 1, 2, ...
119
+ """
120
+ return self._pass_index - 1
121
+
122
+ # -- directions ------------------------------------------------------------
123
+
124
+ def direction(self, feature_id: int) -> torch.Tensor:
125
+ """Unit-norm decoder column for ``feature_id``, cached and on-device."""
126
+ cached = self._direction_cache.get(feature_id)
127
+ if cached is None:
128
+ cached = self.sae.feature_direction(feature_id, normalize=True).to(self.device)
129
+ self._direction_cache[feature_id] = cached
130
+ return cached
131
+
132
+ def build_delta(self, edits: list[PlannedEdit], hidden: torch.Tensor) -> torch.Tensor | None:
133
+ """Combine planned edits into one residual-stream delta.
134
+
135
+ Returns ``None`` when there is nothing to do, which propagates through
136
+ :class:`~brainpatch.research.ml.hooks.ResidualInjector` as "leave the tensor
137
+ completely untouched".
138
+ """
139
+ additive = [e for e in edits if e.mode == "add"]
140
+ ablations = [e for e in edits if e.mode == "ablate"]
141
+ if not additive and not ablations:
142
+ return None
143
+
144
+ delta = torch.zeros(self.sae.d_in, dtype=torch.float32, device=self.device)
145
+ for edit in additive:
146
+ delta += edit.coefficient * self.direction(edit.feature_id)
147
+ delta = delta / self.input_scale
148
+
149
+ if ablations:
150
+ delta = delta + self._ablation_delta(ablations, hidden)
151
+ return delta
152
+
153
+ def _ablation_delta(self, edits: list[PlannedEdit], hidden: torch.Tensor) -> torch.Tensor:
154
+ """Subtract each feature's *measured* contribution at this position.
155
+
156
+ Unlike additive steering, ablation depends on the current activation:
157
+ it encodes the residual stream, reads how strongly the feature is
158
+ firing right now, and removes exactly that much of its direction.
159
+ """
160
+ with torch.no_grad():
161
+ x = hidden[:, -1, :].to(torch.float32) * self.input_scale
162
+ sparse, _, _ = self.sae.encode(x)
163
+ delta = torch.zeros(self.sae.d_in, dtype=torch.float32, device=self.device)
164
+ for edit in edits:
165
+ magnitude = sparse[:, edit.feature_id].mean()
166
+ # coefficient scales how much of the contribution is removed:
167
+ # -1.0 removes it entirely, -0.5 halves it.
168
+ delta += edit.coefficient * magnitude * self.direction(edit.feature_id)
169
+ return delta / self.input_scale
170
+
171
+ # -- hook callback ---------------------------------------------------------
172
+
173
+ def __call__(self, hidden: torch.Tensor) -> torch.Tensor | None:
174
+ """Delta callback for :class:`~brainpatch.research.ml.hooks.ResidualInjector`."""
175
+ self.stats.forward_passes += 1
176
+ is_prompt_pass = self._pass_index == 0
177
+ self._pass_index += 1
178
+ token_index = max(0, self.generated_index)
179
+
180
+ def skip() -> None:
181
+ self.stats.per_token_norms.append((self.generated_index, 0.0))
182
+
183
+ if is_prompt_pass and not self.apply_to_prompt:
184
+ skip()
185
+ return None
186
+
187
+ edits = self.plan.edits_at(token_index, layer=self.layer)
188
+ if not edits:
189
+ skip()
190
+ return None
191
+
192
+ delta = self.build_delta(edits, hidden)
193
+ if delta is None:
194
+ skip()
195
+ return None
196
+
197
+ norm = float(delta.norm().item())
198
+ self.stats.applied_passes += 1
199
+ self.stats.total_delta_norm += norm
200
+ self.stats.max_delta_norm = max(self.stats.max_delta_norm, norm)
201
+ self.stats.per_token_norms.append((self.generated_index, norm))
202
+
203
+ # Broadcast over [batch, seq, hidden]. During the prompt pass this
204
+ # steers every prompt position; afterwards there is only one position.
205
+ return delta.view(1, 1, -1)
206
+
207
+ def make_injector(self) -> ResidualInjector:
208
+ return ResidualInjector(self, name=f"steer-L{self.layer}")
209
+
210
+
211
+ class RandomDirectionSteerer(FeatureSteerer):
212
+ """Scale-matched random-direction control.
213
+
214
+ Replaces every decoder column with a fixed random unit vector drawn from a
215
+ seeded generator. Because both are unit-norm and the coefficient path is
216
+ unchanged, the injected delta has *exactly* the same L2 norm as the real
217
+ intervention it controls for. Any behavioural difference between the two is
218
+ therefore attributable to direction, not magnitude.
219
+ """
220
+
221
+ def __init__(self, *args, control_seed: int = 1234, **kwargs) -> None:
222
+ super().__init__(*args, **kwargs)
223
+ self.control_seed = control_seed
224
+
225
+ def direction(self, feature_id: int) -> torch.Tensor:
226
+ cached = self._direction_cache.get(feature_id)
227
+ if cached is None:
228
+ generator = torch.Generator(device="cpu").manual_seed(self.control_seed + feature_id)
229
+ vector = torch.randn(self.sae.d_in, generator=generator)
230
+ cached = (vector / vector.norm()).to(self.device)
231
+ self._direction_cache[feature_id] = cached
232
+ return cached
233
+
234
+
235
+ def make_steerer(
236
+ sae: TopKSAE,
237
+ plan: InterventionPlan,
238
+ *,
239
+ layer: int,
240
+ input_scale: float,
241
+ device: torch.device | str = "cuda",
242
+ control: str = "none",
243
+ control_seed: int = 1234,
244
+ apply_to_prompt: bool = True,
245
+ ) -> FeatureSteerer:
246
+ """Build the steerer for a condition.
247
+
248
+ Parameters
249
+ ----------
250
+ control:
251
+ ``"none"`` for the real intervention, ``"random"`` for the scale-matched
252
+ random-direction control. The unrelated-feature control is expressed by
253
+ building a plan over different feature IDs, not by a different class.
254
+ """
255
+ if control == "random":
256
+ return RandomDirectionSteerer(
257
+ sae,
258
+ plan,
259
+ layer=layer,
260
+ input_scale=input_scale,
261
+ device=device,
262
+ apply_to_prompt=apply_to_prompt,
263
+ control_seed=control_seed,
264
+ )
265
+ if control != "none":
266
+ raise ValueError(f"unknown control type {control!r}; expected 'none' or 'random'")
267
+ return FeatureSteerer(
268
+ sae,
269
+ plan,
270
+ layer=layer,
271
+ input_scale=input_scale,
272
+ device=device,
273
+ apply_to_prompt=apply_to_prompt,
274
+ )
@@ -0,0 +1,219 @@
1
+ """Loading the frozen base model and discovering its architecture.
2
+
3
+ Two principles here:
4
+
5
+ 1. **Nothing is hardcoded that can be discovered.** Layer count, hidden size and
6
+ the list of decoder blocks are read off the loaded model, so the same code
7
+ works if the base model is swapped. A configured target layer is validated
8
+ against the real depth rather than assumed to exist.
9
+
10
+ 2. **The model is frozen.** ``requires_grad_(False)`` plus ``eval()`` on every
11
+ load path. BrainPatch never fine-tunes; every behavioural change comes from
12
+ activations, not weights.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ import torch
22
+ from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
23
+
24
+ DEFAULT_MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
25
+
26
+ #: Hook site naming. ``residual_post`` is the output of decoder block *i*,
27
+ #: i.e. the residual stream after that block has written to it. This is the
28
+ #: standard site for SAE work because it is where a block's contribution is
29
+ #: visible and where an injection propagates to every later block.
30
+ HOOK_RESIDUAL_POST = "residual_post"
31
+ SUPPORTED_HOOKS = (HOOK_RESIDUAL_POST,)
32
+
33
+
34
+ @dataclass
35
+ class ModelBundle:
36
+ """A loaded, frozen model with its tokenizer and discovered architecture."""
37
+
38
+ model: Any
39
+ tokenizer: Any
40
+ model_id: str
41
+ revision: str
42
+ hidden_size: int
43
+ num_layers: int
44
+ dtype: torch.dtype
45
+ device: torch.device
46
+
47
+ def decoder_layers(self) -> Any:
48
+ """The ``nn.ModuleList`` of decoder blocks."""
49
+ return _find_decoder_layers(self.model)
50
+
51
+ def layer_module(self, layer: int) -> Any:
52
+ """The decoder block at ``layer``, validated against the real depth."""
53
+ validate_layer(layer, self.num_layers)
54
+ return self.decoder_layers()[layer]
55
+
56
+ def describe(self) -> dict[str, Any]:
57
+ return {
58
+ "model": self.model_id,
59
+ "revision": self.revision,
60
+ "hidden_size": self.hidden_size,
61
+ "num_layers": self.num_layers,
62
+ "dtype": str(self.dtype).replace("torch.", ""),
63
+ "device": str(self.device),
64
+ }
65
+
66
+
67
+ def validate_layer(layer: int, num_layers: int) -> int:
68
+ """Raise if ``layer`` is not a real decoder block index.
69
+
70
+ Negative indices count from the end, matching Python convention, and are
71
+ resolved to a positive index.
72
+ """
73
+ resolved = layer if layer >= 0 else num_layers + layer
74
+ if not 0 <= resolved < num_layers:
75
+ raise ValueError(
76
+ f"layer {layer} does not exist: this model has {num_layers} decoder "
77
+ f"blocks (valid indices 0..{num_layers - 1})"
78
+ )
79
+ return resolved
80
+
81
+
82
+ def validate_hook(hook: str) -> str:
83
+ if hook not in SUPPORTED_HOOKS:
84
+ raise ValueError(f"unsupported hook site {hook!r}; supported: {SUPPORTED_HOOKS}")
85
+ return hook
86
+
87
+
88
+ def _find_decoder_layers(model: Any) -> Any:
89
+ """Locate the decoder-block ``ModuleList`` without hardcoding a path.
90
+
91
+ Tries the common Hugging Face layouts in order, then falls back to scanning
92
+ for the largest ``ModuleList`` of identically-typed modules.
93
+ """
94
+ for path in ("model.layers", "transformer.h", "gpt_neox.layers", "model.decoder.layers"):
95
+ node: Any = model
96
+ for part in path.split("."):
97
+ node = getattr(node, part, None)
98
+ if node is None:
99
+ break
100
+ if node is not None and isinstance(node, torch.nn.ModuleList) and len(node) > 0:
101
+ return node
102
+
103
+ best: Any = None
104
+ for module in model.modules():
105
+ if isinstance(module, torch.nn.ModuleList) and len(module) > 1:
106
+ types = {type(m) for m in module}
107
+ if len(types) == 1 and (best is None or len(module) > len(best)):
108
+ best = module
109
+ if best is None:
110
+ raise RuntimeError(
111
+ f"could not locate decoder blocks on {type(model).__name__}; "
112
+ "add its layout to _find_decoder_layers"
113
+ )
114
+ return best
115
+
116
+
117
+ def resolve_revision(model_id: str, revision: str | None = None) -> str:
118
+ """Resolve a branch name to an immutable commit SHA.
119
+
120
+ Pinning the SHA matters: feature directions are properties of a specific set
121
+ of weights, so an experiment that only records "main" is not reproducible.
122
+ """
123
+ from huggingface_hub import HfApi
124
+
125
+ info = HfApi().model_info(model_id, revision=revision or "main")
126
+ return info.sha
127
+
128
+
129
+ def load_model(
130
+ model_id: str = DEFAULT_MODEL,
131
+ *,
132
+ revision: str | None = None,
133
+ dtype: str = "bfloat16",
134
+ device: str = "cuda",
135
+ trust_remote_code: bool = False,
136
+ ) -> ModelBundle:
137
+ """Load a frozen causal LM from the Volume-backed Hugging Face cache.
138
+
139
+ The cache location comes from ``HF_HOME`` / ``HF_HUB_CACHE``, which the
140
+ Modal image points at ``/vol/hf-cache``. Nothing is downloaded twice and
141
+ nothing lands on ephemeral container storage.
142
+ """
143
+ torch_dtype = getattr(torch, dtype)
144
+ resolved_device = torch.device(device)
145
+
146
+ tokenizer = AutoTokenizer.from_pretrained(
147
+ model_id, revision=revision, trust_remote_code=trust_remote_code
148
+ )
149
+ model = AutoModelForCausalLM.from_pretrained(
150
+ model_id,
151
+ revision=revision,
152
+ torch_dtype=torch_dtype,
153
+ trust_remote_code=trust_remote_code,
154
+ low_cpu_mem_usage=True,
155
+ )
156
+ model.to(resolved_device)
157
+
158
+ # Frozen for the entire lifetime of the process. BrainPatch never trains
159
+ # the base model; every effect must come from activations.
160
+ model.eval()
161
+ model.requires_grad_(False)
162
+
163
+ config = model.config
164
+ hidden_size = int(getattr(config, "hidden_size", getattr(config, "n_embd", 0)))
165
+ if hidden_size <= 0:
166
+ raise RuntimeError(f"could not discover hidden size for {model_id}")
167
+ num_layers = len(_find_decoder_layers(model))
168
+
169
+ effective_revision = revision or _commit_hash_from_cache(model) or "unknown"
170
+
171
+ return ModelBundle(
172
+ model=model,
173
+ tokenizer=tokenizer,
174
+ model_id=model_id,
175
+ revision=effective_revision,
176
+ hidden_size=hidden_size,
177
+ num_layers=num_layers,
178
+ dtype=torch_dtype,
179
+ device=resolved_device,
180
+ )
181
+
182
+
183
+ def _commit_hash_from_cache(model: Any) -> str | None:
184
+ """Best-effort recovery of the commit SHA transformers actually loaded."""
185
+ name_or_path = getattr(model.config, "_name_or_path", "") or ""
186
+ # Cached snapshots live at .../snapshots/<sha>/...
187
+ parts = str(name_or_path).replace("\\", "/").split("/")
188
+ if "snapshots" in parts:
189
+ idx = parts.index("snapshots")
190
+ if idx + 1 < len(parts):
191
+ return parts[idx + 1]
192
+ commit = getattr(model.config, "_commit_hash", None)
193
+ return str(commit) if commit else None
194
+
195
+
196
+ def architecture_summary(model_id: str = DEFAULT_MODEL, revision: str | None = None) -> dict[str, Any]:
197
+ """Read architecture facts from the config alone -- no weights loaded.
198
+
199
+ Cheap enough to run on CPU, which is how the layer choice is validated
200
+ before any GPU time is spent.
201
+ """
202
+ config = AutoConfig.from_pretrained(model_id, revision=revision)
203
+ return {
204
+ "model": model_id,
205
+ "model_type": config.model_type,
206
+ "hidden_size": int(config.hidden_size),
207
+ "num_hidden_layers": int(config.num_hidden_layers),
208
+ "num_attention_heads": int(config.num_attention_heads),
209
+ "num_key_value_heads": int(getattr(config, "num_key_value_heads", config.num_attention_heads)),
210
+ "intermediate_size": int(config.intermediate_size),
211
+ "vocab_size": int(config.vocab_size),
212
+ "max_position_embeddings": int(config.max_position_embeddings),
213
+ "torch_dtype": str(getattr(config, "torch_dtype", "unknown")),
214
+ }
215
+
216
+
217
+ def hf_token_present() -> bool:
218
+ """Whether an HF token is available, without ever revealing it."""
219
+ return bool(os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"))