Skip to content

Commit c2c37e3

Browse files
dni138claude
andauthored
fix: gli_guard and wild_guard both work regardless of transformers major version (#222)
* fix(gli_guard): tolerate list-shaped extra_special_tokens on transformers<5 fastino/gliguard-LLMGuardrails-300M ships tokenizer_config.json's extra_special_tokens as a list. transformers<5 passes that straight to _set_model_specific_special_tokens, which assumes a dict and crashes with AttributeError: 'list' object has no attribute 'keys'. transformers>=5 added an isinstance(list, tuple) branch that handles this natively. Shim the same handling for <5, scoped to the one GLiNER2.from_pretrained call. gliner2's own processor hardcodes and re-injects its special-token strings by value, never reading these dict keys, so synthesized key names are safe. Verified against real transformers 4.57.6 and 5.8.0. Part of #221. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(deps): declare sentencepiece/protobuf in the huggingface extra allenai/wildguard ships only a raw SentencePiece tokenizer.model (no fast tokenizer.json). Without sentencepiece + protobuf installed, transformers can't extract it and misdetects the file as tiktoken-formatted, failing with "'tiktoken' is required to read a 'tiktoken' file" on every transformers major version, not just one. Both packages were previously only present transitively via the gliner extra, so installing huggingface alone left WildGuard broken. Verified by reproducing the exact failure (uninstalling both packages) and the fix (reinstalling them) against real transformers 5.8.0, and separately against 5.14.1 in an isolated venv — no version gate is needed, this was never actually a transformers-major-version conflict. Part of #221. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(gli_guard): address Copilot review — thread-safety and test brittleness Serialize _tolerate_list_extra_special_tokens with a module-level lock: it mutates a class-wide transformers method for its duration, so concurrent GliGuard() constructions on transformers<5 must not interleave patch/restore. Rewrite the shim's unit test to simulate transformers<5's dict-only implementation with a controlled fake instead of relying on the real installed transformers' _set_model_specific_special_tokens raising AttributeError on a list — that's an internal detail that could change upstream independent of the shim's own correctness. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 455ec50 commit c2c37e3

3 files changed

Lines changed: 137 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ huggingface = [
4646
"huggingface-hub>=0.33.4",
4747
"transformers>=4.53.2",
4848
"torch>=2.7.1",
49-
"hf-xet>=1.1.5"
49+
"hf-xet>=1.1.5",
50+
# Needed by transformers to convert SentencePiece-only tokenizers (e.g. allenai/wildguard,
51+
# which ships no fast tokenizer.json) into a usable tokenizer; without these, transformers
52+
# misdetects the file as tiktoken-formatted and fails on every transformers major version.
53+
"sentencepiece",
54+
"protobuf"
5055
]
5156

5257
azure-content-safety = [

src/any_guardrail/guardrails/gli_guard/gli_guard.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import threading
12
import time
3+
from collections.abc import Iterator
4+
from contextlib import contextmanager
25
from typing import Any, ClassVar
36

47
from any_guardrail.base import GuardrailName
@@ -59,6 +62,51 @@
5962
}
6063

6164

65+
def _transformers_major() -> int:
66+
"""Return the installed transformers major version (0 if transformers isn't importable)."""
67+
try:
68+
from transformers import __version__ as transformers_version
69+
except ImportError:
70+
return 0
71+
return int(transformers_version.split(".")[0])
72+
73+
74+
# _tolerate_list_extra_special_tokens mutates a class-wide transformers method for its
75+
# duration, so concurrent GliGuard() constructions on transformers<5 must not interleave.
76+
_PATCH_LOCK = threading.Lock()
77+
78+
79+
@contextmanager
80+
def _tolerate_list_extra_special_tokens() -> Iterator[None]:
81+
"""Backport transformers>=5's list/tuple handling for ``extra_special_tokens`` onto <5.
82+
83+
``fastino/gliguard-LLMGuardrails-300M`` ships ``extra_special_tokens`` in
84+
``tokenizer_config.json`` as a JSON list. transformers<5's
85+
``PreTrainedTokenizerBase.__init__`` passes that value straight to
86+
``_set_model_specific_special_tokens``, which assumes a dict and calls ``.keys()``
87+
on it, raising ``AttributeError: 'list' object has no attribute 'keys'``.
88+
transformers>=5 added an ``isinstance(value, (list, tuple))`` branch upstream that
89+
handles this natively, so this shim is only needed below that version. ``gliner2``'s
90+
own processor hardcodes and re-injects its special-token strings by value — it never
91+
reads these dict keys — so any synthesized key names are safe here.
92+
"""
93+
from transformers import PreTrainedTokenizerBase
94+
95+
with _PATCH_LOCK:
96+
original = PreTrainedTokenizerBase._set_model_specific_special_tokens
97+
98+
def _patched(self: Any, special_tokens: Any) -> None:
99+
if isinstance(special_tokens, (list, tuple)):
100+
special_tokens = {f"extra_special_token_{i}": tok for i, tok in enumerate(special_tokens)}
101+
original(self, special_tokens)
102+
103+
PreTrainedTokenizerBase._set_model_specific_special_tokens = _patched # type: ignore[method-assign]
104+
try:
105+
yield
106+
finally:
107+
PreTrainedTokenizerBase._set_model_specific_special_tokens = original # type: ignore[method-assign]
108+
109+
62110
class GliGuard(Guardrail):
63111
"""Schema-driven safety, toxicity, jailbreak, and refusal detector.
64112
@@ -131,7 +179,11 @@ def __init__(self, model_id: str | None = None, threshold: float = 0.5) -> None:
131179
raise ImportError(msg) from MISSING_PACKAGES_ERROR
132180
self.model_id = default(model_id, self.SUPPORTED_MODELS)
133181
self.threshold = threshold
134-
self.model = GLiNER2.from_pretrained(self.model_id)
182+
if _transformers_major() < 5:
183+
with _tolerate_list_extra_special_tokens():
184+
self.model = GLiNER2.from_pretrained(self.model_id)
185+
else:
186+
self.model = GLiNER2.from_pretrained(self.model_id)
135187

136188
def validate(self, input_text: str, **kwargs: Any) -> GuardrailOutput:
137189
"""Classify ``input_text`` across the safety / toxicity / jailbreak / refusal schema.

tests/unit/test_unit_special_guardrails.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from unittest.mock import MagicMock, patch
66

77
import pytest
8+
from transformers import PreTrainedTokenizerBase
89

910
from any_guardrail.guardrails.flowjudge.flowjudge import MISSING_PACKAGES_ERROR, Flowjudge
10-
from any_guardrail.guardrails.gli_guard.gli_guard import GliGuard
11+
from any_guardrail.guardrails.gli_guard.gli_guard import GliGuard, _tolerate_list_extra_special_tokens
1112
from any_guardrail.guardrails.lettuce_detect.lettuce_detect import LettuceDetect
1213

1314
# --- LettuceDetect -------------------------------------------------------------
@@ -93,6 +94,82 @@ def test_gli_guard_safe() -> None:
9394
assert guard.validate("what's the weather?").valid is True
9495

9596

97+
def _legacy_set_model_specific_special_tokens(self: Any, special_tokens: Any) -> None:
98+
"""Mirror transformers<5's dict-only implementation, independent of the installed version.
99+
100+
Real transformers (both <5 and >=5) implements this by calling ``.keys()``/``.items()``
101+
unconditionally, so a raw list crashes with ``AttributeError``. Pinning that behavior here
102+
(rather than calling the real, installed method) keeps this test from becoming brittle to
103+
upstream changes in transformers' own implementation.
104+
"""
105+
self.SPECIAL_TOKENS_ATTRIBUTES = self.SPECIAL_TOKENS_ATTRIBUTES + list(special_tokens.keys())
106+
for key, value in special_tokens.items():
107+
self._special_tokens_map[key] = value
108+
109+
110+
def test_tolerate_list_extra_special_tokens_converts_list_to_dict() -> None:
111+
"""The shim backports transformers>=5's list/tuple handling onto a transformers<5-style callable."""
112+
fake: Any = SimpleNamespace(SPECIAL_TOKENS_ATTRIBUTES=[], _special_tokens_map={})
113+
with patch.object(
114+
PreTrainedTokenizerBase, "_set_model_specific_special_tokens", _legacy_set_model_specific_special_tokens
115+
):
116+
with pytest.raises(AttributeError):
117+
# Unpatched, the raw list crashes exactly like transformers<5 does.
118+
PreTrainedTokenizerBase._set_model_specific_special_tokens(fake, ["[SEP_STRUCT]", "[SEP_TEXT]"]) # type: ignore[arg-type]
119+
120+
with _tolerate_list_extra_special_tokens():
121+
PreTrainedTokenizerBase._set_model_specific_special_tokens(fake, ["[SEP_STRUCT]", "[SEP_TEXT]"]) # type: ignore[arg-type]
122+
123+
assert PreTrainedTokenizerBase._set_model_specific_special_tokens is _legacy_set_model_specific_special_tokens
124+
125+
assert fake._special_tokens_map == {"extra_special_token_0": "[SEP_STRUCT]", "extra_special_token_1": "[SEP_TEXT]"}
126+
127+
128+
def test_gli_guard_transformers_5_skips_patch() -> None:
129+
"""On transformers>=5, GLiNER2.from_pretrained is called directly, unpatched."""
130+
with (
131+
patch("any_guardrail.guardrails.gli_guard.gli_guard._transformers_major", return_value=5),
132+
patch("any_guardrail.guardrails.gli_guard.gli_guard._tolerate_list_extra_special_tokens") as mock_shim,
133+
patch("any_guardrail.guardrails.gli_guard.gli_guard.GLiNER2") as mock_gliner2,
134+
):
135+
mock_gliner2.from_pretrained.return_value = MagicMock()
136+
GliGuard()
137+
mock_shim.assert_not_called()
138+
mock_gliner2.from_pretrained.assert_called_once_with("fastino/gliguard-LLMGuardrails-300M")
139+
140+
141+
def test_gli_guard_transformers_below_5_applies_and_restores_patch() -> None:
142+
"""On transformers<5, the shim wraps the load and is restored afterward."""
143+
original = PreTrainedTokenizerBase._set_model_specific_special_tokens
144+
145+
def _assert_patched_during_call(_model_id: str) -> MagicMock:
146+
assert PreTrainedTokenizerBase._set_model_specific_special_tokens is not original
147+
return MagicMock()
148+
149+
with (
150+
patch("any_guardrail.guardrails.gli_guard.gli_guard._transformers_major", return_value=4),
151+
patch("any_guardrail.guardrails.gli_guard.gli_guard.GLiNER2") as mock_gliner2,
152+
):
153+
mock_gliner2.from_pretrained.side_effect = _assert_patched_during_call
154+
GliGuard()
155+
156+
assert PreTrainedTokenizerBase._set_model_specific_special_tokens is original
157+
158+
159+
def test_gli_guard_patch_restored_on_load_failure() -> None:
160+
"""The shim is restored via `finally` even when GLiNER2.from_pretrained raises."""
161+
original = PreTrainedTokenizerBase._set_model_specific_special_tokens
162+
with (
163+
patch("any_guardrail.guardrails.gli_guard.gli_guard._transformers_major", return_value=4),
164+
patch("any_guardrail.guardrails.gli_guard.gli_guard.GLiNER2") as mock_gliner2,
165+
):
166+
mock_gliner2.from_pretrained.side_effect = RuntimeError("boom")
167+
with pytest.raises(RuntimeError, match="boom"):
168+
GliGuard()
169+
170+
assert PreTrainedTokenizerBase._set_model_specific_special_tokens is original
171+
172+
96173
# --- FlowJudge new init paths --------------------------------------------------
97174

98175
flowjudge_available = pytest.mark.skipif(MISSING_PACKAGES_ERROR is not None, reason="flow-judge not installed")

0 commit comments

Comments
 (0)