diff --git a/README.md b/README.md index 81745d1..7e1b642 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,28 @@ Standalone on PyPI, and portable across training and inference stacks (transform uv add renderers ``` +The base install supports text renderers with a bring-your-own tokenizer. Add +the Hugging Face integration for the tokenizer-loading helpers, or the complete +media stack for image/audio rendering: + +```bash +uv add 'renderers[transformers]' +uv add 'renderers[multimodal]' +``` + +A BYO tokenizer must expose `encode`, `decode`, `convert_tokens_to_ids`, token +IDs such as `eos_token_id`, and `return_offsets_mapping=True` through its call +interface. `DefaultRenderer` additionally requires `apply_chat_template`. +This includes text-only Inkling training: `InklingRenderer` loads its +Transformers processor only when image or audio content is actually rendered. + ## At a glance ```python -from transformers import AutoTokenizer from renderers import create_renderer +from renderers.base import load_tokenizer -tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B") +tok = load_tokenizer("Qwen/Qwen3-8B") # renderers[transformers] r = create_renderer(tok) # → Qwen3Renderer (auto-resolved) prompt_ids = r.render_ids( @@ -76,17 +91,10 @@ r = create_renderer(tok) # AutoRendererConfig is the implicit def Auto-detect matches `tokenizer.name_or_path` against `MODEL_RENDERER_MAP` by **exact match**. Prefix matching is intentionally off — same architecture can ship different chat templates (base vs instruct, fine-tune renames). Fine-tunes must pass an explicit typed config (e.g. `Qwen3RendererConfig()`). Unknown text-only names fall back to `DefaultRenderer`, unless `AutoRendererConfig(thinking_retention=...)` was set; the default renderer cannot implement that bridge policy. -### Pools - -```python -from renderers import create_renderer_pool - -pool = create_renderer_pool("Qwen/Qwen3-8B", size=16) -with pool.checkout() as r: - ids = r.render_ids(messages) -``` - -Each slot owns its own tokenizer copy. Construction fans out across a thread pool so a 32-slot pool doesn't serially eat ~10–15s of `from_pretrained` calls at startup. +Without the `transformers` extra, exact-match registered models still +auto-resolve. For an unknown name, renderers cannot safely probe `AutoConfig` +to distinguish a text model from an unknown VLM; pass an explicit typed config +such as `DefaultRendererConfig()` for a known text-only model. ## Why use a renderer @@ -109,7 +117,7 @@ Each break fragments a rollout into multiple training samples — every fragment ## Typed renderer configs -Each renderer accepts a typed pydantic config at construction. Some fields mirror chat-template kwargs; others configure renderer-only behavior such as image caching, parsers, or Harmony preamble construction. `create_renderer` and `create_renderer_pool` take one positional `config` argument and an optional keyword-only `chat_template_kwargs` mapping: +Each renderer accepts a typed pydantic config at construction. Some fields mirror chat-template kwargs; others configure renderer-only behavior such as image caching, parsers, or Harmony preamble construction. `create_renderer` takes one positional `config` argument and an optional keyword-only `chat_template_kwargs` mapping: ```python from renderers import ( @@ -159,7 +167,7 @@ Fallback for unsupported text-only models. Wraps `apply_chat_template` and accep ## Roadmap -- **VLM expansion.** `ImagePart` support exists for Qwen3-VL, Qwen3.5-family, Gemma 4, and Kimi K2.5 / K2.6 multimodal templates. Remaining work: audio/video support, broader VLM coverage, and more RL validation. Gemma 4 image preprocessing requires a Transformers release that provides `Gemma4Processor`. +- **VLM expansion.** `ImagePart` support exists for Qwen3-VL, Qwen3.5-family, Gemma 4, and Kimi K2.5 / K2.6 multimodal templates. Install `renderers[multimodal]` for Pillow and the Hugging Face processors. Remaining work: audio/video support, broader VLM coverage, and more RL validation. Gemma 4 image preprocessing requires a Transformers release that provides `Gemma4Processor`. - **Patched chat templates.** Some shipped templates re-tokenize history or normalize JSON in ways that break token identity. Plan: a `use_patched` opt-in per renderer that renders the same surface form while avoiding known-bad patterns. (Auto-stripping thinking from past turns is *not* one of these — that's intended template behaviour the renderer reproduces; use `thinking_retention` to override it.) ## Testing diff --git a/docs/renderer-config.md b/docs/renderer-config.md index 7c02746..91bc62e 100644 --- a/docs/renderer-config.md +++ b/docs/renderer-config.md @@ -1,8 +1,7 @@ # Renderer config -`renderers.RendererConfig` is the typed input to `create_renderer` and -`create_renderer_pool`. It pins the renderer choice and its config at -construction time. +`renderers.RendererConfig` is the typed input to `create_renderer`. It pins the +renderer choice and its config at construction time. ```python from renderers import create_renderer, Qwen35RendererConfig @@ -77,10 +76,6 @@ r = create_renderer( tokenizer, chat_template_kwargs={"enable_thinking": False}, ) -pool = create_renderer_pool( - "Qwen/Qwen3-8B", - chat_template_kwargs={"enable_thinking": False}, -) ``` Renderers resolves auto configs before applying `chat_template_kwargs`, so the diff --git a/pyproject.toml b/pyproject.toml index 5905ec0..845e70a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,11 +19,6 @@ dependencies = [ "openai>=1.108.1", "tiktoken", "jinja2", - # Keep this floor compatible with prime-rl's transformers pin. Inkling's - # tokenizer and text-only renderer work on older releases; image/audio - # processing fails lazily with an upgrade message when InklingProcessor is - # unavailable (native support starts in transformers 5.14). - "transformers>=4.50.0", # Used by GptOssRenderer to render and parse harmony tokens. Vendoring # OpenAI's reference implementation keeps us byte-identical with vLLM # (which also uses it) and saves us mirroring a 330-line Jinja template. @@ -42,6 +37,25 @@ dependencies = [ "prime-pydantic-config>=0.3.0.dev83", ] +[project.optional-dependencies] +# Tokenizer loading uses Hugging Face. Text-only renderers can instead be +# constructed with an offset-capable BYO tokenizer and do not import this +# dependency. +transformers = [ + # Keep this floor compatible with prime-rl's transformers pin. Inkling's + # tokenizer and text-only renderer work on older releases; image/audio + # processing fails lazily with an upgrade message when InklingProcessor is + # unavailable (native support starts in transformers 5.14). + "transformers>=4.50.0", +] + +# Image/audio renderers also need Pillow to resolve media inputs. Keep this as +# a separate convenience extra so tokenizer-only users do not pull it in. +multimodal = [ + "pillow>=12.2.0", + "transformers>=4.50.0", +] + [tool.hatch.version] source = "vcs" # Tags look like ``renderers-v0.1.8`` (prefix matches the publish.yml @@ -90,6 +104,8 @@ dev = [ "torch>=2.11.0", "torchvision>=0.26.0", "ty>=0.0.1a29,<0.0.22", + # Optional for consumers, but required by tokenizer/parity/VLM tests. + "transformers>=4.50.0", ] [tool.uv] diff --git a/renderers/__init__.py b/renderers/__init__.py index a71e6aa..7635ddc 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -7,6 +7,7 @@ __version__ = "0+unknown" from renderers.base import ( + ChatTemplateTokenizer, Content, ContentPart, ImagePart, @@ -21,9 +22,9 @@ RenderedTokens, RenderedTrainingSample, Renderer, - RendererPool, TextPart, ThinkingPart, + Tokenizer, ToolCall, ToolCallFunction, ToolCallParseStatus, @@ -33,7 +34,6 @@ build_training_sample, build_trajectory_step, create_renderer, - create_renderer_pool, extract_message_tool_names, is_multimodal, reject_assistant_in_extension, @@ -74,17 +74,13 @@ RendererConfig, ) -# Concrete renderer classes are lazy-loaded so that consumers needing -# only the config layer (``RendererConfig`` discriminated union) don't -# pay the ``transformers`` import cost. Each renderer module does -# ``from transformers.tokenization_utils import PreTrainedTokenizer`` -# at module level, so eager imports here would drag ``transformers`` -# into every downstream ``import renderers``. ``__getattr__`` (PEP 562) -# resolves the names on first attribute access, so ``from renderers -# import DefaultRenderer`` and ``renderers.DefaultRenderer`` both work -# transparently. ``create_renderer`` doesn't depend on these eager -# imports — ``renderers.base._populate_registry`` lazy-imports the -# concrete classes itself when a renderer is instantiated. +# Concrete renderer classes are lazy-loaded so that consumers needing only the +# config layer (``RendererConfig`` discriminated union) don't import every +# renderer module. Renderer tokenizer annotations use the local ``Tokenizer`` +# protocols, so resolving a text renderer remains safe when the optional +# ``transformers`` dependency is absent. ``__getattr__`` (PEP 562) resolves the +# names on first attribute access, while ``renderers.base._populate_registry`` +# handles lazy registration for ``create_renderer``. _LAZY_RENDERERS: dict[str, str] = { "DeepSeekR1Renderer": "renderers.deepseek_r1", "DeepSeekV3Renderer": "renderers.deepseek_v3", @@ -134,6 +130,7 @@ def __dir__() -> list[str]: __all__ = [ "AutoRendererConfig", "BaseRendererConfig", + "ChatTemplateTokenizer", "Content", "ContentPart", "DeepSeekR1Renderer", @@ -205,9 +202,9 @@ def __dir__() -> list[str]: "RenderedTrainingSample", "Renderer", "RendererConfig", - "RendererPool", "TextPart", "ThinkingPart", + "Tokenizer", "ToolCall", "ToolCallFunction", "ToolCallParseStatus", @@ -219,7 +216,6 @@ def __dir__() -> list[str]: "build_trajectory_step", "config_from_name", "create_renderer", - "create_renderer_pool", "extract_message_tool_names", "is_multimodal", "reject_assistant_in_extension", diff --git a/renderers/base.py b/renderers/base.py index 20e85b0..e82eb7d 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -2,10 +2,7 @@ import enum import logging -import queue -import threading from collections.abc import Mapping -from contextlib import contextmanager from dataclasses import dataclass, field from typing import ( TYPE_CHECKING, @@ -663,6 +660,37 @@ def with_completion( ) +@runtime_checkable +class Tokenizer(Protocol): + """Structural tokenizer surface used by hand-coded renderers. + + Hugging Face tokenizers satisfy this protocol, as can lightweight BYO + adapters around ``tokenizers.Tokenizer`` or another tokenizer backend. + Keeping the renderer-facing contract here makes ``transformers`` optional + for text rendering. Offset-capable ``__call__`` behavior is required by + :func:`attribute_text_segments` to preserve BPE boundary attribution. + """ + + name_or_path: str + unk_token_id: int | None + eos_token_id: int | None + + def encode(self, text: str, *args: Any, **kwargs: Any) -> list[int]: ... + + def decode(self, token_ids: Any, *args: Any, **kwargs: Any) -> str: ... + + def convert_tokens_to_ids(self, tokens: Any) -> Any: ... + + def __call__(self, *args: Any, **kwargs: Any) -> Any: ... + + +@runtime_checkable +class ChatTemplateTokenizer(Tokenizer, Protocol): + """Tokenizer surface required by :class:`DefaultRenderer`.""" + + def apply_chat_template(self, *args: Any, **kwargs: Any) -> Any: ... + + @runtime_checkable class Renderer(Protocol): """Owns message ↔ token conversion for a specific model family.""" @@ -679,10 +707,9 @@ def render( Behaviour around historical ``reasoning_content`` is owned by the renderer instance — the ``thinking_retention`` level is resolved at construction, not passed per call. To render with a different - configuration, build a different renderer (or different pool). When - ``thinking_retention`` is left unset, full renders follow the model's - chat template and bridge policy is derived from that template's own - history-retention knobs. + configuration, build a different renderer. When ``thinking_retention`` + is left unset, full renders follow the model's chat template and bridge + policy is derived from that template's own history-retention knobs. """ ... @@ -839,9 +866,7 @@ def bridge_to_next_turn( # Per-type cache for ``is_multimodal``. The ``runtime_checkable`` Protocol # isinstance check walks every protocol member via ``hasattr`` on each # call; per-type caching collapses that to a single dict lookup on the -# hot path (e.g. per-bridge dispatch). Pools expose ``is_multimodal`` -# directly as a snapshot attribute (different pools share a class but -# wrap different renderer types), so we don't need to special-case them. +# hot path (e.g. per-bridge dispatch). _IS_MULTIMODAL_BY_TYPE: dict[type, bool] = {} @@ -863,127 +888,6 @@ def is_multimodal(r: object) -> bool: return cached -class RendererPool: - """Pool of Renderer instances that itself satisfies the Renderer protocol. - - Callers treat a pool like a single renderer — ``pool.render_ids(...)``, - ``pool.bridge_to_next_turn(...)``, ``isinstance(pool, MultimodalRenderer)`` - all work via structural delegation. The pool internally serializes - access to its inner renderers (each wraps its own tokenizer copy). - - Concurrency model: - - ``size == 1``: a single inner renderer guarded by a ``threading.Lock``. - Avoids the queue's per-call overhead on the common default config. - - ``size > 1``: a ``queue.Queue`` of independent renderers, checked out - one at a time. HuggingFace fast tokenizers release the GIL during - Rust encoding, so threads achieve real parallelism. - - Construction parallelism for ``size > 1``: ``AutoTokenizer.from_pretrained`` - takes hundreds of ms per call (JSON parse + Rust tokenizer build + HF - cache lookup), so populating a 32-slot pool serially costs ~10-15s on - startup and shows up directly as a step-0 stall. We fan the factory out - across a short-lived thread pool; the GIL-bound Python portion stops - scaling past ~8 workers, so we clamp there. - """ - - def __init__(self, factory: Callable[[], Renderer], size: int): - from concurrent.futures import ThreadPoolExecutor - - self._factory = factory - self._size = size - - if size == 1: - renderer = factory() - self._sole: Renderer | None = renderer - self._lock: threading.Lock | None = threading.Lock() - self._pool: queue.Queue[Renderer] | None = None - sample: Renderer = renderer - else: - self._sole = None - self._lock = None - self._pool = queue.Queue(maxsize=size) - workers = min(size, 8) - with ThreadPoolExecutor(max_workers=workers) as executor: - for renderer in executor.map(lambda _: factory(), range(size)): - self._pool.put(renderer) - # Peek without removing — safe at construction time before any - # checkout has been served. - sample = self._pool.queue[0] - - # Snapshot the protocol-shaped attributes from a sample renderer. - # They are constant per renderer class, so resolving them once at - # construction (a) eliminates per-call ``getattr``/``isinstance`` - # overhead and (b) lets a future out-of-process pool variant skip - # holding a live tokenizer in the parent process. - self._renderer_cls: type[Renderer] = type(sample) - self.supports_tools: bool = getattr(sample, "supports_tools", True) - self.is_multimodal: bool = is_multimodal(sample) - # ``mm_token_type_id_map`` is set ONLY on pools wrapping a - # ``MultimodalRenderer``. We deliberately don't expose this as a - # class-level property: ``runtime_checkable`` Protocol's - # isinstance check uses ``inspect.getattr_static``, which finds - # property descriptors on the class regardless of whether their - # fget raises. Conditional instance attributes (present in - # ``self.__dict__`` only when applicable) are the only way to - # make ``isinstance(pool, MultimodalRenderer)`` reflect the - # inner renderer's actual protocol conformance. - if isinstance(sample, MultimodalRenderer): - self.mm_token_type_id_map: dict[int, int] = sample.mm_token_type_id_map - - @contextmanager - def checkout(self): - if self._sole is not None: - assert self._lock is not None - with self._lock: - yield self._sole - return - assert self._pool is not None - renderer = self._pool.get() - try: - yield renderer - finally: - self._pool.put(renderer) - - @property - def size(self) -> int: - return self._size - - @property - def renderer_cls(self) -> type[Renderer]: - """Class of the renderers in this pool (uniform across all slots).""" - return self._renderer_cls - - # ── Renderer protocol delegation ──────────────────────────────────── - # Pool structurally satisfies ``Renderer`` (and ``MultimodalRenderer`` - # when its slots wrap multimodal renderers). Callers can call methods - # directly and dispatch with ``isinstance(pool, MultimodalRenderer)`` - # without reaching into ``checkout()``. - - def render(self, *args: Any, **kwargs: Any) -> "RenderedTokens": - with self.checkout() as r: - return r.render(*args, **kwargs) - - def render_ids(self, *args: Any, **kwargs: Any) -> list[int]: - with self.checkout() as r: - return r.render_ids(*args, **kwargs) - - def parse_response(self, *args: Any, **kwargs: Any) -> "ParsedResponse": - with self.checkout() as r: - return r.parse_response(*args, **kwargs) - - def get_stop_token_ids(self) -> list[int]: - with self.checkout() as r: - return r.get_stop_token_ids() - - def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | None": - with self.checkout() as r: - return r.bridge_to_next_turn(*args, **kwargs) - - # ``mm_token_type_id_map`` (the MultimodalRenderer protocol attribute) - # is set in ``__init__`` only for pools wrapping multimodal renderers; - # see the comment there for why this isn't a class-level property. - - RENDERER_REGISTRY: dict[str, type] = {} # Exact canonical HF model names → renderer. We do NOT use prefix @@ -1156,6 +1060,25 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No } +_TRANSFORMERS_INSTALL_HINT = ( + "Install the optional dependency with " + "`pip install 'renderers[transformers]'` (or " + "`uv add 'renderers[transformers]'`). Text-only renderers work without " + "it when constructed with an offset-capable tokenizer object." +) + + +def _require_transformers(feature: str) -> Any: + """Return ``transformers`` or raise an actionable optional-extra error.""" + try: + import transformers + except ImportError as exc: + raise ImportError( + f"{feature} requires Transformers. {_TRANSFORMERS_INSTALL_HINT}" + ) from exc + return transformers + + def _model_has_vision_config(model_name: str) -> bool: """Return True if the HF config for ``model_name`` declares vision inputs. @@ -1166,13 +1089,26 @@ def _model_has_vision_config(model_name: str) -> bool: match what the trainer reconstructs — a class of bug the renderer abstraction exists to prevent. - Returns False on any AutoConfig failure (offline, gated, missing) so - a flaky HF probe never blocks a legitimate text-only fine-tune. + Returns False on remote/config failures so a flaky HF probe never blocks a + legitimate text-only fine-tune. When Transformers itself is unavailable, + however, auto-resolution cannot safely distinguish an unknown text model + from an unknown VLM; callers must install the extra or choose an explicit + renderer config. """ try: - from transformers import AutoConfig - - cfg = AutoConfig.from_pretrained(model_name, trust_remote_code=False) + transformers = _require_transformers("Auto-resolving an unregistered model") + except ImportError as exc: + raise ImportError( + f"Cannot auto-resolve unregistered model {model_name!r} without " + "checking whether it is multimodal. Install " + "`renderers[transformers]`, or pass an explicit typed renderer " + "config such as `DefaultRendererConfig()` for a known text-only " + "model." + ) from exc + try: + cfg = transformers.AutoConfig.from_pretrained( + model_name, trust_remote_code=False + ) except Exception: return False # Most VLM configs nest a vision tower as ``vision_config`` (Qwen-VL, @@ -1196,8 +1132,8 @@ def _model_has_vision_config(model_name: str) -> bool: # Pinning the revision keeps the trust narrow: even with # ``trust_remote_code=True``, transformers downloads / executes the # tokenizer Python from this exact commit only. A future malicious push -# to the Moonshot HF repo doesn't auto-propagate to anyone using -# ``create_renderer_pool``. Bump these SHAs deliberately, with review. +# to the Moonshot HF repo doesn't auto-propagate to callers of +# ``load_tokenizer``. Bump these SHAs deliberately, with review. TRUSTED_REVISIONS: dict[str, str] = { "moonshotai/Kimi-K2-Instruct": "fd1984e2b7a3350dbf7305fe73a4ede25c14de50", "moonshotai/Kimi-K2.5": "4d01dfe0332d63057c186e0b262165819efb6611", @@ -1330,7 +1266,9 @@ def load_tokenizer(model_name_or_path: str): those exact IDs we load tokenizer files from the audited unrestricted ``unsloth`` mirrors instead, then restore ``tokenizer.name_or_path`` to the requested Meta ID so auto-resolution still selects ``Llama3Renderer``. + Requires the ``renderers[transformers]`` extra. """ + _require_transformers("Loading a tokenizer") load_name_or_path = _tokenizer_source_for(model_name_or_path) kwargs = _tokenizer_load_kwargs(load_name_or_path) tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs) @@ -1408,44 +1346,6 @@ def _populate_registry(): ) -def create_renderer_pool( - tokenizer_name_or_path: str, - config: RendererConfig | None = None, - *, - size: int = 16, - chat_template_kwargs: Mapping[str, Any] | None = None, -) -> RendererPool: - """Create a RendererPool with *size* independent tokenizer copies. - - Each slot loads its own tokenizer so threads never share mutable - state. HuggingFace fast tokenizers release the GIL during Rust - encoding, so threads achieve real parallelism. - - ``config`` is the typed renderer config (one of the variants of - :data:`renderers.RendererConfig`). Defaults to - :class:`AutoRendererConfig`, which resolves to a concrete renderer - via ``MODEL_RENDERER_MAP`` at construction time using the loaded - tokenizer's name. ``chat_template_kwargs`` are merged into the - resolved concrete config and validated before renderer construction. - Every slot in the pool shares the same config; to run a different - config, build a different pool. - - Tokenizers load via ``load_tokenizer`` — see its docstring for the - ``trust_remote_code`` policy (default off; Moonshot Kimi-K2 family - opts in with a pinned ``revision``). - """ - - def factory() -> Renderer: - tokenizer = load_tokenizer(tokenizer_name_or_path) - return create_renderer( - tokenizer, - config, - chat_template_kwargs=chat_template_kwargs, - ) - - return RendererPool(factory, size=size) - - def create_renderer( tokenizer, config: RendererConfig | None = None, @@ -1455,7 +1355,8 @@ def create_renderer( """Create a Renderer from a typed config. Args: - tokenizer: HuggingFace tokenizer instance. + tokenizer: An object satisfying :class:`Tokenizer`; the generic + fallback additionally requires :class:`ChatTemplateTokenizer`. config: Typed renderer config — one of the variants of :data:`renderers.RendererConfig`. ``None`` defaults to :class:`AutoRendererConfig`, which resolves to a concrete @@ -1471,10 +1372,11 @@ def create_renderer( config from ``tokenizer.name_or_path`` and then validates these kwargs against that config. - Selecting the auto-renderer for a model without a registered - renderer falls back to :class:`DefaultRenderer` for text-only models - and raises for VLMs (where ``apply_chat_template`` would silently - drop images). + Selecting the auto-renderer for a model without a registered renderer + probes Hugging Face ``AutoConfig`` before falling back to + :class:`DefaultRenderer`, so unknown VLMs fail instead of silently dropping + media. Without the ``transformers`` extra, pass an explicit renderer config + for unregistered model names. """ _populate_registry() diff --git a/renderers/client.py b/renderers/client.py index aa632a6..56cb22a 100644 --- a/renderers/client.py +++ b/renderers/client.py @@ -1,12 +1,7 @@ """Renderer-based generate client for vLLM's /inference/v1/generate. - messages → Renderer.render_ids() → token IDs → POST /inference/v1/generate - → completion tokens → Renderer.parse_response() → structured message - -When a RendererPool is passed instead of a single Renderer, the sync tokenization -and parsing work is offloaded to threads for parallel execution across rollouts. -HuggingFace fast tokenizers release the GIL during Rust encoding, so threads -achieve real parallelism. +messages → Renderer.render_ids() → token IDs → POST /inference/v1/generate +→ completion tokens → Renderer.parse_response() → structured message """ from __future__ import annotations @@ -26,9 +21,9 @@ MultiModalData, RenderedTokens, Renderer, - RendererPool, ToolCallParseStatus, ToolSpec, + _require_transformers, ) _request_logger = logging.getLogger("renderers.client") @@ -115,20 +110,6 @@ async def _resolve_max_prompt_len(client: AsyncOpenAI, model: str) -> int | None return value -async def _maybe_offload(renderer: Renderer | RendererPool, fn): - """Run sync renderer work on a thread iff ``renderer`` is a pool. - - A pool's methods can block on its internal queue/lock (size>1 / size=1 - fast path respectively), so we ``asyncio.to_thread`` to avoid stalling - the event loop. A bare ``Renderer`` runs inline — used in tests where - event-loop responsiveness isn't a concern and the thread hop would - be pure overhead. - """ - if isinstance(renderer, RendererPool): - return await asyncio.to_thread(fn) - return fn() - - def _strip_base64_field(raw: bytes, prefix: bytes) -> tuple[bytes, memoryview | None]: """Splice a large base64 string field out of raw JSON bytes. @@ -214,7 +195,7 @@ def _parse_completion_logprobs( async def generate( *, client: AsyncOpenAI, - renderer: Renderer | RendererPool, + renderer: Renderer, messages: list[Message], model: str, prompt_ids: list[int] | None = None, @@ -297,9 +278,7 @@ def _prepare(): rendered, ) - prompt_ids, stop_token_ids, mm_data, prompt_attr = await _maybe_offload( - renderer, _prepare - ) + prompt_ids, stop_token_ids, mm_data, prompt_attr = _prepare() if max_prompt_len is None: max_prompt_len = await _resolve_max_prompt_len(client, model) @@ -355,9 +334,7 @@ def _prepare(): completion_logprobs = _parse_completion_logprobs(choice, completion_ids) - parsed = await _maybe_offload( - renderer, lambda: renderer.parse_response(completion_ids, tools=tools) - ) + parsed = renderer.parse_response(completion_ids, tools=tools) routed_experts = choice.get("routed_experts") kept_tokens = choice.get("kept_tokens") @@ -404,7 +381,7 @@ def _prepare(): def _build_mm_features( - renderer: Renderer | RendererPool, + renderer: Renderer, mm_data: MultiModalData, ) -> dict[str, Any] | None: """Serialize ``MultiModalData`` to vLLM's ``/inference/v1/generate`` features payload. @@ -432,12 +409,7 @@ def _build_mm_features( from renderers.qwen3_vl import Qwen3VLRenderer from renderers.qwen35 import Qwen35Renderer - # Type dispatch only needs the renderer class. Pools expose - # ``renderer_cls`` as a snapshot attribute, so we don't have to check - # out a slot just to read ``type(r)``. - renderer_cls = ( - renderer.renderer_cls if isinstance(renderer, RendererPool) else type(renderer) - ) + renderer_cls = type(renderer) # Qwen3-VL and Qwen3.5 both ship ``pixel_values`` + ``image_grid_thw`` # via the shared Qwen2-VL field factory. ``spatial_merge_size=2`` is @@ -461,6 +433,7 @@ def _build_gemma4_features(mm_data: MultiModalData) -> dict[str, Any]: renderer output faithful to the HF processor and translate at this engine-specific boundary. """ + _require_transformers("Encoding Gemma 4 multimodal features for vLLM") try: import torch from transformers.feature_extraction_utils import BatchFeature @@ -529,6 +502,7 @@ def _build_qwen_vl_features( Returns ``None`` semantics live one level up — this helper assumes the caller already verified ``mm_data`` is non-empty. """ + _require_transformers("Encoding Qwen-VL multimodal features for vLLM") try: import torch from transformers.feature_extraction_utils import BatchFeature diff --git a/renderers/deepseek_v3.py b/renderers/deepseek_v3.py index a00f1f2..56b901f 100644 --- a/renderers/deepseek_v3.py +++ b/renderers/deepseek_v3.py @@ -14,13 +14,12 @@ import json -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -63,7 +62,7 @@ class DeepSeekV3Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: DeepSeekV3RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/default.py b/renderers/default.py index 785a537..76494e1 100644 --- a/renderers/default.py +++ b/renderers/default.py @@ -11,9 +11,8 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( + ChatTemplateTokenizer, Message, ParsedResponse, RenderedTokens, @@ -92,7 +91,7 @@ class DefaultRenderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: ChatTemplateTokenizer, config: DefaultRendererConfig | None = None, ): cfg = config or DefaultRendererConfig() diff --git a/renderers/gemma4.py b/renderers/gemma4.py index 4c9175b..c519465 100644 --- a/renderers/gemma4.py +++ b/renderers/gemma4.py @@ -24,8 +24,6 @@ from collections.abc import Mapping, Sequence from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, MultiModalData, @@ -35,6 +33,8 @@ RenderedTokens, ToolCallParseStatus, ToolSpec, + Tokenizer, + _require_transformers, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -431,7 +431,7 @@ class Gemma4Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: Gemma4RendererConfig | None = None, *, processor: Any = None, @@ -500,8 +500,6 @@ def mm_token_type_id_map(self) -> dict[int, int]: def _get_processor(self): if self._processor is not None: return self._processor - from transformers import AutoProcessor - name = getattr(self._tokenizer, "name_or_path", None) if not name: raise RuntimeError( @@ -509,8 +507,9 @@ def _get_processor(self): "processor=AutoProcessor.from_pretrained(...) or use a tokenizer " "with a known name_or_path." ) + transformers = _require_transformers("Auto-loading a Gemma 4 processor") try: - self._processor = AutoProcessor.from_pretrained(name) + self._processor = transformers.AutoProcessor.from_pretrained(name) except (ImportError, ValueError) as exc: raise RuntimeError( "Gemma 4 image rendering requires a Transformers release with " diff --git a/renderers/glm45.py b/renderers/glm45.py index bfc5f09..d1b72c1 100644 --- a/renderers/glm45.py +++ b/renderers/glm45.py @@ -13,13 +13,12 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -55,7 +54,7 @@ class GLM45Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: GLM45RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/glm5.py b/renderers/glm5.py index 4f34d98..961c002 100644 --- a/renderers/glm5.py +++ b/renderers/glm5.py @@ -14,13 +14,12 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -64,7 +63,7 @@ class GLM5Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: GLM5RendererConfig | GLM51RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/gpt_oss.py b/renderers/gpt_oss.py index 6165ed0..9cbf316 100644 --- a/renderers/gpt_oss.py +++ b/renderers/gpt_oss.py @@ -49,13 +49,12 @@ ToolDescription, load_harmony_encoding, ) -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, extract_message_tool_names, reject_assistant_in_extension, resolve_thinking_retention, @@ -123,7 +122,7 @@ class GptOssRenderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: GptOssRendererConfig | None = None, ): """Initialise the renderer. diff --git a/renderers/hy3.py b/renderers/hy3.py index 7eaef65..04a327f 100644 --- a/renderers/hy3.py +++ b/renderers/hy3.py @@ -27,13 +27,12 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -74,7 +73,7 @@ class Hy3Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: Hy3RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/inkling.py b/renderers/inkling.py index ff21c0d..80f2f2b 100644 --- a/renderers/inkling.py +++ b/renderers/inkling.py @@ -39,8 +39,6 @@ from typing import Any import numpy as np -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Content, Message, @@ -49,6 +47,8 @@ PlaceholderRange, RenderedTokens, ToolSpec, + Tokenizer, + _require_transformers, extract_message_tool_names, reject_assistant_in_extension, resolve_thinking_retention, @@ -172,7 +172,7 @@ class InklingRenderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: InklingRendererConfig | None = None, *, processor: Any = None, @@ -251,8 +251,6 @@ def _encode(self, text: str) -> list[int]: def _get_processor(self): if self._processor is not None: return self._processor - from transformers import AutoProcessor - name = getattr(self._tokenizer, "name_or_path", None) if not name: raise RuntimeError( @@ -265,8 +263,9 @@ def _get_processor(self): # trust_remote_code is required. Keep text-only rendering installable # with older downstream pins and fail only when multimodal processing # is actually requested. + transformers = _require_transformers("Auto-loading an Inkling processor") try: - self._processor = AutoProcessor.from_pretrained(name) + self._processor = transformers.AutoProcessor.from_pretrained(name) except (ImportError, KeyError, ValueError) as exc: raise RuntimeError( "Inkling image/audio rendering requires Transformers >=5.14 " diff --git a/renderers/kimi_k2.py b/renderers/kimi_k2.py index 7337600..64f4d00 100644 --- a/renderers/kimi_k2.py +++ b/renderers/kimi_k2.py @@ -16,13 +16,12 @@ import json -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, extract_message_tool_names, reject_assistant_in_extension, resolve_thinking_retention, @@ -47,7 +46,7 @@ class KimiK2Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: KimiK2RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/kimi_k25.py b/renderers/kimi_k25.py index 48ea426..ef7e4e3 100644 --- a/renderers/kimi_k25.py +++ b/renderers/kimi_k25.py @@ -25,8 +25,6 @@ import re from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, MultiModalData, @@ -36,6 +34,8 @@ RenderedTokens, ToolCallParseStatus, ToolSpec, + Tokenizer, + _require_transformers, extract_message_tool_names, reject_assistant_in_extension, resolve_thinking_retention, @@ -592,7 +592,7 @@ class KimiK25Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: KimiK25RendererConfig | None = None, *, processor: Any = None, @@ -655,8 +655,6 @@ def mm_token_type_id_map(self) -> dict[int, int]: def _get_processor(self): if self._processor is not None: return self._processor - from transformers import AutoProcessor - name = getattr(self._tokenizer, "name_or_path", None) if not name: raise RuntimeError( @@ -666,10 +664,12 @@ def _get_processor(self): "known name_or_path so the processor can be auto-loaded." ) # Kimi's processor is custom Python in the model repo and requires - # trust_remote_code=True. Callers using ``create_renderer_pool`` go - # through ``load_tokenizer`` which already pins the revision; for - # auto-load here, we delegate to AutoProcessor with the same flag. - self._processor = AutoProcessor.from_pretrained(name, trust_remote_code=True) + # trust_remote_code=True, so auto-loading delegates to AutoProcessor + # with that flag. + transformers = _require_transformers("Auto-loading a Kimi K2.5 processor") + self._processor = transformers.AutoProcessor.from_pretrained( + name, trust_remote_code=True + ) return self._processor def _process_image(self, part: dict[str, Any]): diff --git a/renderers/laguna_s21.py b/renderers/laguna_s21.py index d39e11a..c960d23 100644 --- a/renderers/laguna_s21.py +++ b/renderers/laguna_s21.py @@ -9,8 +9,7 @@ from __future__ import annotations -from transformers.tokenization_utils import PreTrainedTokenizer - +from renderers.base import Tokenizer from renderers.configs import LagunaS21RendererConfig from renderers.laguna_xs2 import LagunaXS21Renderer @@ -24,7 +23,7 @@ class LagunaS21Renderer(LagunaXS21Renderer): def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: LagunaS21RendererConfig | None = None, ): super().__init__(tokenizer, config or LagunaS21RendererConfig()) diff --git a/renderers/laguna_xs2.py b/renderers/laguna_xs2.py index 303ada3..a112f76 100644 --- a/renderers/laguna_xs2.py +++ b/renderers/laguna_xs2.py @@ -50,14 +50,13 @@ import json -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Content, Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -119,7 +118,7 @@ class LagunaXS2Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: ( LagunaXS2RendererConfig | LagunaM1RendererConfig @@ -652,7 +651,7 @@ class LagunaM1Renderer(LagunaXS2Renderer): def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: LagunaM1RendererConfig | None = None, ): super().__init__(tokenizer, config or LagunaM1RendererConfig()) @@ -692,7 +691,7 @@ class LagunaXS21Renderer(LagunaXS2Renderer): def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: LagunaXS21RendererConfig | LagunaS21RendererConfig | None = None, ): super().__init__(tokenizer, config or LagunaXS21RendererConfig()) diff --git a/renderers/llama_3.py b/renderers/llama_3.py index d18d8c8..950d1f2 100644 --- a/renderers/llama_3.py +++ b/renderers/llama_3.py @@ -41,13 +41,12 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -93,7 +92,7 @@ class Llama3Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: Llama3RendererConfig | None = None, ): # ``thinking_retention`` is accepted but a no-op: Llama-3 ships no diff --git a/renderers/minimax_m2.py b/renderers/minimax_m2.py index a7f0bc7..e9a2bda 100644 --- a/renderers/minimax_m2.py +++ b/renderers/minimax_m2.py @@ -14,13 +14,12 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -57,7 +56,7 @@ class MiniMaxM2Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: MiniMaxM2RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/nemotron3.py b/renderers/nemotron3.py index 97abb34..97a53c1 100644 --- a/renderers/nemotron3.py +++ b/renderers/nemotron3.py @@ -17,13 +17,12 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -118,7 +117,7 @@ class Nemotron3Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: Nemotron3RendererConfig | Nemotron3UltraRendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/prime_qwen3.py b/renderers/prime_qwen3.py index ba639a0..8ff88f5 100644 --- a/renderers/prime_qwen3.py +++ b/renderers/prime_qwen3.py @@ -6,13 +6,12 @@ from collections.abc import Mapping, Sequence from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -120,7 +119,7 @@ def _tool_definition(tool: ToolSpec) -> str: class _TokenBuilder: - def __init__(self, tokenizer: PreTrainedTokenizer): + def __init__(self, tokenizer: Tokenizer): self.tokenizer = tokenizer self.token_ids: list[int] = [] self.message_indices: list[int] = [] @@ -192,7 +191,7 @@ class PrimeQwen3Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: PrimeQwen3RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/qwen3.py b/renderers/qwen3.py index d85d161..97c21f4 100644 --- a/renderers/qwen3.py +++ b/renderers/qwen3.py @@ -20,13 +20,12 @@ import json -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, ParsedResponse, RenderedTokens, ToolSpec, + Tokenizer, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -59,7 +58,7 @@ class Qwen3Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: Qwen3RendererConfig | None = None, ): self._tokenizer = tokenizer diff --git a/renderers/qwen35.py b/renderers/qwen35.py index e63c691..2e9de34 100644 --- a/renderers/qwen35.py +++ b/renderers/qwen35.py @@ -25,8 +25,6 @@ import json from typing import Any -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, MultiModalData, @@ -34,6 +32,8 @@ PlaceholderRange, RenderedTokens, ToolSpec, + Tokenizer, + _require_transformers, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -126,7 +126,7 @@ class Qwen35Renderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: Qwen35RendererConfig | None = None, *, processor: Any = None, @@ -185,8 +185,6 @@ def mm_token_type_id_map(self) -> dict[int, int]: def _get_processor(self): if self._processor is not None: return self._processor - from transformers import AutoProcessor - name = getattr(self._tokenizer, "name_or_path", None) if not name: raise RuntimeError( @@ -195,7 +193,8 @@ def _get_processor(self): "constructor, or load the tokenizer with a known name_or_path " "so the processor can be auto-loaded." ) - self._processor = AutoProcessor.from_pretrained(name) + transformers = _require_transformers("Auto-loading a Qwen3.5-family processor") + self._processor = transformers.AutoProcessor.from_pretrained(name) return self._processor def _process_image(self, part: dict[str, Any]): diff --git a/renderers/qwen3_vl.py b/renderers/qwen3_vl.py index 97072d2..121a77f 100644 --- a/renderers/qwen3_vl.py +++ b/renderers/qwen3_vl.py @@ -33,8 +33,6 @@ from typing import Any from urllib.parse import urlparse -from transformers.tokenization_utils import PreTrainedTokenizer - from renderers.base import ( Message, MultiModalData, @@ -42,6 +40,8 @@ PlaceholderRange, RenderedTokens, ToolSpec, + Tokenizer, + _require_transformers, attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, @@ -311,7 +311,7 @@ class Qwen3VLRenderer: def __init__( self, - tokenizer: PreTrainedTokenizer, + tokenizer: Tokenizer, config: Qwen3VLRendererConfig | None = None, *, processor: Any = None, @@ -375,8 +375,6 @@ def _encode(self, text: str) -> list[int]: def _get_processor(self): if self._processor is not None: return self._processor - from transformers import AutoProcessor - name = getattr(self._tokenizer, "name_or_path", None) if not name: raise RuntimeError( @@ -385,7 +383,8 @@ def _get_processor(self): "constructor, or load the tokenizer with a known name_or_path " "so the processor can be auto-loaded." ) - self._processor = AutoProcessor.from_pretrained(name) + transformers = _require_transformers("Auto-loading a Qwen3-VL processor") + self._processor = transformers.AutoProcessor.from_pretrained(name) return self._processor @staticmethod diff --git a/tests/test_renderer_config.py b/tests/test_renderer_config.py index b4b0433..ac66768 100644 --- a/tests/test_renderer_config.py +++ b/tests/test_renderer_config.py @@ -21,7 +21,6 @@ RendererConfig, base, create_renderer, - create_renderer_pool, ) @@ -141,31 +140,6 @@ def __init__(self, tokenizer, config): assert renderer.config.enable_thinking is False -def test_create_renderer_pool_forwards_chat_template_kwargs(monkeypatch): - """Pool construction uses the same renderer-owned config resolution.""" - - class _FakeQwen3: - def __init__(self, tokenizer, config): - self.config = config - - monkeypatch.setitem(base.RENDERER_REGISTRY, "qwen3", _FakeQwen3) - monkeypatch.setitem(base.MODEL_RENDERER_MAP, "fake/qwen3", "qwen3") - monkeypatch.setattr( - base, - "load_tokenizer", - lambda name: SimpleNamespace(name_or_path=name), - ) - - pool = create_renderer_pool( - "fake/qwen3", - size=1, - chat_template_kwargs={"enable_thinking": False}, - ) - - assert isinstance(pool._sole.config, Qwen3RendererConfig) - assert pool._sole.config.enable_thinking is False - - def test_auto_unknown_model_rejects_chat_template_kwargs(): tok = SimpleNamespace(name_or_path="unknown/text-model") diff --git a/uv.lock b/uv.lock index 77d8c90..1f3c0fd 100644 --- a/uv.lock +++ b/uv.lock @@ -1345,6 +1345,14 @@ dependencies = [ { name = "openai-harmony" }, { name = "prime-pydantic-config" }, { name = "tiktoken" }, +] + +[package.optional-dependencies] +multimodal = [ + { name = "pillow" }, + { name = "transformers" }, +] +transformers = [ { name = "transformers" }, ] @@ -1357,6 +1365,7 @@ dev = [ { name = "ruff" }, { name = "torch" }, { name = "torchvision" }, + { name = "transformers" }, { name = "ty" }, ] @@ -1366,10 +1375,13 @@ requires-dist = [ { name = "numpy" }, { name = "openai", specifier = ">=1.108.1" }, { name = "openai-harmony", specifier = ">=0.0.4" }, + { name = "pillow", marker = "extra == 'multimodal'", specifier = ">=12.2.0" }, { name = "prime-pydantic-config", specifier = ">=0.3.0.dev83" }, { name = "tiktoken" }, - { name = "transformers", specifier = ">=4.50.0" }, + { name = "transformers", marker = "extra == 'multimodal'", specifier = ">=4.50.0" }, + { name = "transformers", marker = "extra == 'transformers'", specifier = ">=4.50.0" }, ] +provides-extras = ["multimodal", "transformers"] [package.metadata.requires-dev] dev = [ @@ -1380,6 +1392,7 @@ dev = [ { name = "ruff" }, { name = "torch", specifier = ">=2.11.0" }, { name = "torchvision", specifier = ">=0.26.0" }, + { name = "transformers", specifier = ">=4.50.0" }, { name = "ty", specifier = ">=0.0.1a29,<0.0.22" }, ]