diff --git a/README.md b/README.md index 383c77a..74515e0 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ next_prompt_ids = r.bridge_to_next_turn( ) ``` -Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. +Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, `gpt-oss`, `hy3`, `inkling`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `kimi-k2.5` / `kimi-k2.6`, and `inkling` are multimodal (`inkling` handles both image **and** audio). ## API diff --git a/docs/renderer-config.md b/docs/renderer-config.md index f60cd73..f061537 100644 --- a/docs/renderer-config.md +++ b/docs/renderer-config.md @@ -36,6 +36,7 @@ chat-template kwargs. Those fields are covered by parity tests against | Hy3 | `Hy3RendererConfig` | `reasoning_effort`, `preserved_thinking`, `is_training`, `raw_last_assistant`, `fallback_strategy` | - | | Kimi K2 | `KimiK2RendererConfig` | - | `enable_thinking` | | Kimi K2.5 / 2.6 | `KimiK25RendererConfig` | `thinking` | `image_cache_max` | +| Inkling | `InklingRendererConfig` | `reasoning_effort` | `image_cache_max` | | Laguna XS.2 | `LagunaXS2RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - | | Laguna XS-2.1 | `LagunaXS21RendererConfig` | `enable_thinking` | - | | Llama 3 | `Llama3RendererConfig` | `date_string`, `tools_in_user_message` | - | @@ -136,7 +137,7 @@ the knobs its template actually exposes: | Nemotron-3 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` | | DeepSeek R1 | `template` | | MiniMax M2 | `tool_cycle` | -| DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / XS-2.1, Llama 3 | `all` | +| DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / XS-2.1, Llama 3, Inkling | `all` | | PrimeIntellect Qwen3 | `all` | Config construction raises when an explicit template knob directly contradicts diff --git a/pyproject.toml b/pyproject.toml index dc75397..3e3fdf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,8 @@ dependencies = [ "openai>=1.108.1", "tiktoken", "jinja2", - "transformers>=4.50.0", + # >=5.14.0: first release with native Inkling processor support. + "transformers>=5.14.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. @@ -101,6 +102,9 @@ exclude-newer = "7 days" # fast-track so first-party releases can land same-day. Only packages that # appear in `uv tree` are listed. prime-pydantic-config = false +# transformers >=5.14 (native Inkling processor) lands inside the cooldown; +# fast-track it, and drop this once 5.14.x clears the 7-day window. +transformers = false [tool.ty.environment] python-version = "3.13" diff --git a/renderers/__init__.py b/renderers/__init__.py index 5baf242..efe10e4 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -52,6 +52,7 @@ GLM5RendererConfig, GptOssRendererConfig, Hy3RendererConfig, + InklingRendererConfig, KimiK25RendererConfig, KimiK2RendererConfig, LagunaXS2RendererConfig, @@ -88,6 +89,7 @@ "GLM5Renderer": "renderers.glm5", "GptOssRenderer": "renderers.gpt_oss", "Hy3Renderer": "renderers.hy3", + "InklingRenderer": "renderers.inkling", "KimiK25Renderer": "renderers.kimi_k25", "KimiK2Renderer": "renderers.kimi_k2", "LagunaXS21Renderer": "renderers.laguna_xs2", @@ -141,6 +143,8 @@ def __dir__() -> list[str]: "Hy3Renderer", "Hy3RendererConfig", "ImagePart", + "InklingRenderer", + "InklingRendererConfig", "KimiK25Renderer", "KimiK25RendererConfig", "KimiK2Renderer", diff --git a/renderers/base.py b/renderers/base.py index 304d6cc..648545c 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -1071,6 +1071,8 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No # GPT-OSS. "openai/gpt-oss-20b": "gpt-oss", "openai/gpt-oss-120b": "gpt-oss", + # Thinking Machines Inkling (vision + audio; native processor in transformers >= 5.14). + "thinkingmachines/Inkling": "inkling", # Tencent Hunyuan Hy3 (295B-A21B MoE). The FP8 checkpoint shares the same # tokenizer and chat template. Hy3-preview is deliberately unmapped: it # ships an older, incompatible template (un-suffixed special tokens, @@ -1115,6 +1117,7 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No # ``grid_thws``. "moonshotai/Kimi-K2.5": {"image"}, "moonshotai/Kimi-K2.6": {"image"}, + "thinkingmachines/Inkling": {"image", "audio"}, } @@ -1313,6 +1316,7 @@ def _populate_registry(): from renderers.glm45 import GLM45Renderer from renderers.gpt_oss import GptOssRenderer from renderers.hy3 import Hy3Renderer + from renderers.inkling import InklingRenderer from renderers.kimi_k2 import KimiK2Renderer from renderers.kimi_k25 import KimiK25Renderer from renderers.laguna_xs2 import LagunaXS2Renderer, LagunaXS21Renderer @@ -1340,6 +1344,7 @@ def _populate_registry(): "deepseek-v3": DeepSeekV3Renderer, "deepseek-r1": DeepSeekR1Renderer, "hy3": Hy3Renderer, + "inkling": InklingRenderer, "kimi-k2": KimiK2Renderer, "kimi-k2.5": KimiK25Renderer, "laguna-xs.2": LagunaXS2Renderer, diff --git a/renderers/configs.py b/renderers/configs.py index eefd078..662671d 100644 --- a/renderers/configs.py +++ b/renderers/configs.py @@ -380,6 +380,64 @@ def _check_thinking_retention(self): return self +# Inkling ``reasoning_effort`` label → float (the template's own effort_map). +INKLING_EFFORT_MAP: dict[str, float] = { + "none": 0.0, + "minimal": 0.1, + "low": 0.2, + "medium": 0.7, + "high": 0.9, + "max": 0.99, +} + + +class InklingRendererConfig(BaseRendererConfig): + """Inkling renderer config (``thinkingmachines/Inkling``). + + Inkling gates reasoning depth via a ``reasoning_effort`` knob rather + than a boolean ``enable_thinking``. It accepts either a string label + from :data:`INKLING_EFFORT_MAP` (``none`` … ``max``) or a raw float in + ``[0.0, 0.99]``; the renderer emits ``Thinking effort level: {N}`` in a + leading system message exactly as the chat template does (label mapped + to its float, ``0.0`` printed as ``"0"``). The template default — + applied when no ``reasoning_effort`` is passed — is ``0.9``, which this + config mirrors. + + Reasoning is preserved on every historical assistant turn (the template + has no history-dropping knob), so the effective bridge policy is + ``"all"``. + """ + + name: Literal["inkling"] = "inkling" + + reasoning_effort: str | float = 0.9 + """Reasoning-effort gate. Mirrors the chat template's ``reasoning_effort`` + kwarg: a label in :data:`INKLING_EFFORT_MAP` or a float in ``[0.0, 0.99]``. + Default ``0.9`` matches the template's own default (equivalent to + ``"high"``).""" + + image_cache_max: int = 256 + """FIFO bound on the per-renderer image-processor cache. Renderer- + internal — not a Jinja chat-template kwarg.""" + + _internal_fields = frozenset({"image_cache_max"}) + + @model_validator(mode="after") + def _check_reasoning_effort(self): + eff = self.reasoning_effort + if isinstance(eff, str): + if eff.strip() not in INKLING_EFFORT_MAP: + raise ValueError( + f"reasoning_effort={eff!r} is not a known label. " + f"Use one of {sorted(INKLING_EFFORT_MAP)} or a float in [0.0, 0.99]." + ) + else: + num = float(eff) + if num < 0.0 or num > 0.99: + raise ValueError(f"reasoning_effort={eff!r} must be in [0.0, 0.99].") + return self + + class GptOssRendererConfig(BaseRendererConfig): """OpenAI gpt-oss (harmony) renderer config. @@ -664,6 +722,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): GLM45RendererConfig, GptOssRendererConfig, Hy3RendererConfig, + InklingRendererConfig, KimiK2RendererConfig, KimiK25RendererConfig, LagunaXS2RendererConfig, @@ -704,6 +763,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): "glm-4.5": GLM45RendererConfig, "gpt-oss": GptOssRendererConfig, "hy3": Hy3RendererConfig, + "inkling": InklingRendererConfig, "kimi-k2": KimiK2RendererConfig, "kimi-k2.5": KimiK25RendererConfig, "laguna-xs.2": LagunaXS2RendererConfig, @@ -752,6 +812,8 @@ def config_from_name(name: str) -> BaseRendererConfig | None: "GLM5RendererConfig", "GptOssRendererConfig", "Hy3RendererConfig", + "INKLING_EFFORT_MAP", + "InklingRendererConfig", "KimiK25RendererConfig", "KimiK2RendererConfig", "LagunaXS2RendererConfig", diff --git a/renderers/inkling.py b/renderers/inkling.py new file mode 100644 index 0000000..64dabbb --- /dev/null +++ b/renderers/inkling.py @@ -0,0 +1,964 @@ +"""Inkling renderer — ``thinkingmachines/Inkling`` (text + image + audio). + +Inkling's chat template is **token-delimited**: every structural piece is a +single atomic special token, so there is no BOS and text between markers BPEs +independently. The message stream is a flat sequence of role-tagged, +``<|end_message|>``-terminated blocks: + + <|message_{role}|> [ <|content_{kind}|> {body} ] <|end_message|> + +with these pieces (all single tokens): + +- Roles: ``<|message_user|>`` / ``<|message_model|>`` (assistant) / + ``<|message_system|>`` / ``<|message_tool|>``. +- Content kinds: ``<|content_text|>``, ``<|content_thinking|>`` (assistant + reasoning), ``<|content_image|>``, ``<|content_audio_input|>`` (… ``<|audio_end|>``), + ``<|content_invoke_tool_json|>`` (tool call), ``<|content_xml|>`` (tools decl). +- ``<|content_model_end_sampling|>`` closes an assistant turn (also the eos). + +Ordering: an optional tools declaration comes first, then a one-off +``Thinking effort level: {N}`` system message emitted immediately before the +first non-system message (or at the very end if the whole conversation is +system-only). See :class:`renderers.configs.InklingRendererConfig` for the +effort knob. + +Multimodal parity matches the native ``InklingProcessor`` (transformers +≥ 5.14): the template emits a single placeholder per media item, and the +processor **expands** it — image → ``num_patches`` copies of ``<|unused_200054|>``, +audio → one ``<|unused_200053|>`` per mel frame. This renderer expands the same +way (counts taken straight from the processor) and ships ``pixel_values`` / +``audio_input_ids`` in :class:`~renderers.base.MultiModalData`. The processor is +a native transformers class, so it loads with **no** ``trust_remote_code``. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import numpy as np +from transformers.tokenization_utils import PreTrainedTokenizer + +from renderers.base import ( + Content, + Message, + MultiModalData, + ParsedResponse, + PlaceholderRange, + RenderedTokens, + ToolSpec, + extract_message_tool_names, + reject_assistant_in_extension, + resolve_thinking_retention, + should_rerender_for_thinking_retention, + trim_to_turn_close, +) +from renderers.configs import INKLING_EFFORT_MAP, InklingRendererConfig +from renderers.parsing import parse_inkling +from renderers.qwen3_vl import _image_hash, _load_pil_image + +# Content-part ``type`` values the template maps to each modality. Untyped +# parts (``type`` absent) are treated as text — matching the Jinja template's +# ``part.type is not defined or part.type in ("text", "input_text")`` gate. +_TEXT_TYPES = frozenset({"text", "input_text"}) +_IMAGE_TYPES = frozenset({"image", "input_image", "image_url"}) +_AUDIO_TYPES = frozenset({"audio", "input_audio", "audio_url"}) +_VIDEO_TYPES = frozenset({"video", "input_video", "video_url"}) + + +def _format_effort_number(num: float) -> str: + """Render the effort number exactly as the chat template does. + + ``0.0`` prints as ``"0"``; every other value uses Python ``str`` of the + float (``0.9`` → ``"0.9"``), which matches Jinja's float stringification + for the values in :data:`INKLING_EFFORT_MAP` and arbitrary user floats. + """ + return "0" if num == 0.0 else str(num) + + +def _classify_part(part: Any) -> tuple[str, Any]: + """Classify a content-list part → ``(kind, data)``. + + ``kind`` is one of ``"text"``/``"image"``/``"audio"``/``"video"``/``"skip"``. + Mirrors the template's part dispatch: a bare string or an untyped/text part + is text; unknown part types are skipped (the template raises — we skip so a + stray part never crashes a render, and the parity fixtures never hit it). + """ + if isinstance(part, str): + return "text", part + if not isinstance(part, dict): + return "skip", None + ptype = part.get("type") + if ptype is None or ptype in _TEXT_TYPES: + text = part.get("text") + return "text", text if isinstance(text, str) else "" + if ptype in _IMAGE_TYPES: + return "image", part + if ptype in _AUDIO_TYPES: + return "audio", part + if ptype in _VIDEO_TYPES: + return "video", part + return "skip", None + + +def _load_audio(part: Any) -> tuple[np.ndarray, int]: + """Resolve an audio content part to ``(waveform, sampling_rate)``. + + Accepts a raw mono waveform (``np.ndarray`` / list of floats) or a + HuggingFace-``datasets``-style ``{"array", "sampling_rate"}`` dict, under + ``part["audio"]`` / ``["input_audio"]`` / ``["audio_url"]`` or the part + itself. Decoding bytes / paths / URLs is out of scope (the processor's + ``apply_chat_template`` does that upstream) — those raise so the caller + passes a decoded waveform. + """ + data: Any = part + if isinstance(part, dict): + for key in ("audio", "input_audio", "audio_url"): + if key in part: + data = part[key] + break + sampling_rate = 16000 + if isinstance(data, dict): + sampling_rate = int(data.get("sampling_rate", sampling_rate)) + data = data.get("array") + if isinstance(data, str) or data is None: + raise NotImplementedError( + "InklingRenderer needs a decoded audio waveform (np.ndarray or a " + "{'array', 'sampling_rate'} dict); byte/path/URL decoding is not " + "supported here." + ) + return np.asarray(data, dtype=np.float32), sampling_rate + + +def _audio_hash(wav: np.ndarray, sampling_rate: int) -> str: + # Sampling rate is part of the identity: identical samples at different + # rates yield different mel frames / sidecar tensors, so they must not + # share an ``mm_hashes`` entry (media IDs must be unique). + h = hashlib.sha256(str(sampling_rate).encode()) + h.update(np.ascontiguousarray(wav).tobytes()) + return h.hexdigest() + + +class InklingRenderer: + """Deterministic message → token renderer for ``thinkingmachines/Inkling``. + + Text / tools / reasoning render to byte-parity with + ``tokenizer.apply_chat_template``; image / audio render to byte-parity with + the native ``InklingProcessor`` (placeholder expansion included). Satisfies + the :class:`~renderers.base.MultimodalRenderer` protocol. + """ + + def __init__( + self, + tokenizer: PreTrainedTokenizer, + config: InklingRendererConfig | None = None, + *, + processor: Any = None, + ): + self._tokenizer = tokenizer + self._processor = processor + self.config = config or InklingRendererConfig() + # Inkling always renders historical reasoning (the template has no + # history-dropping knob), so the effective bridge policy is "all". + self.effective_thinking_retention = resolve_thinking_retention( + self.config, "all" + ) + + eff = self.config.reasoning_effort + self._effort_num: float = ( + INKLING_EFFORT_MAP[eff.strip()] if isinstance(eff, str) else float(eff) + ) + + # Role markers. + self._message_user = self._token_id("<|message_user|>") + self._message_model = self._token_id("<|message_model|>") + self._message_system = self._token_id("<|message_system|>") + self._message_tool = self._token_id("<|message_tool|>") + # Content-kind markers. + self._content_text = self._token_id("<|content_text|>") + self._content_thinking = self._token_id("<|content_thinking|>") + self._content_image = self._token_id("<|content_image|>") + self._content_audio_input = self._token_id("<|content_audio_input|>") + self._content_xml = self._token_id("<|content_xml|>") + self._content_invoke_tool_json = self._token_id("<|content_invoke_tool_json|>") + self._content_invoke_tool_text = self._token_id("<|content_invoke_tool_text|>") + self._content_model_end_sampling = self._token_id( + "<|content_model_end_sampling|>" + ) + self._end_message = self._token_id("<|end_message|>") + self._audio_end = self._token_id("<|audio_end|>") + # Media soft-token placeholders (expanded per patch / mel frame). + self._image_pad = self._token_id("<|unused_200054|>") + self._audio_pad = self._token_id("<|unused_200053|>") + # eos: the model closes its turn with <|content_model_end_sampling|>. + self._endoftext = self._try_token_id("<|endoftext|>") + + # Per-instance FIFO media caches (keyed by content hash). + self._image_cache: dict[str, tuple[Any, int]] = {} + self._audio_cache: dict[str, dict[str, Any]] = {} + + @property + def mm_token_type_id_map(self) -> dict[int, int]: + """Token-id → modality marker. Only the image placeholder carries a + vision marker (1); audio has no entry in the orchestrator's + image/video type map (the model consumes ``audio_input_ids`` directly).""" + return {self._image_pad: 1} + + # ------------------------------------------------------------------ + # Token / processor helpers + # ------------------------------------------------------------------ + + def _token_id(self, token: str) -> int: + tid = self._tokenizer.convert_tokens_to_ids(token) + assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( + f"Special token {token!r} not found in tokenizer vocabulary" + ) + return tid + + def _try_token_id(self, token: str) -> int | None: + tid = self._tokenizer.convert_tokens_to_ids(token) + if not isinstance(tid, int) or tid == self._tokenizer.unk_token_id: + return None + return tid + + def _encode(self, text: str) -> list[int]: + if not text: + return [] + return self._tokenizer.encode(text, add_special_tokens=False) + + 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( + "InklingRenderer needs a processor to render image/audio " + "content. Pass processor=AutoProcessor.from_pretrained(name), " + "or load the tokenizer with a known name_or_path so the " + "processor can be auto-loaded." + ) + # InklingProcessor is a native transformers class (>= 5.14), so no + # trust_remote_code is required. + self._processor = AutoProcessor.from_pretrained(name) + return self._processor + + def _process_image(self, pil, image_hash: str) -> tuple[Any, int]: + """Return ``(processor_out, num_patches)`` for one image (cached). + + ``processor_out`` carries ``pixel_values`` (shape + ``(num_patches, temporal, 40, 40, 3)``); ``num_patches`` is the count + of ``<|unused_200054|>`` placeholders the processor expands this image + into (``ceil(H/40) * (W//40 + 1)``).""" + cached = self._image_cache.get(image_hash) + if cached is not None: + return cached + out = self._get_processor().image_processor.preprocess( + [pil], return_tensors="pt" + ) + num_patches = int(out["num_patches"][0]) + if len(self._image_cache) >= self.config.image_cache_max: + self._image_cache.pop(next(iter(self._image_cache))) + self._image_cache[image_hash] = (out, num_patches) + return out, num_patches + + def _process_audio(self, wav: np.ndarray, sampling_rate: int) -> dict[str, Any]: + """Return the processor's ``{audio_input_ids, audio_input_ids_mask}`` + for one clip (cached). The number of ``<|unused_200053|>`` placeholders + is ``audio_input_ids_mask[0].sum()`` (one per valid mel frame).""" + key = _audio_hash(wav, sampling_rate) + cached = self._audio_cache.get(key) + if cached is not None: + return cached + processed, _ = self._get_processor()._process_audio( + [wav], sampling_rate=sampling_rate + ) + if len(self._audio_cache) >= self.config.image_cache_max: + self._audio_cache.pop(next(iter(self._audio_cache))) + self._audio_cache[key] = processed + return processed + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render( + self, + messages: list[Message], + *, + tools: list[ToolSpec] | None = None, + add_generation_prompt: bool = False, + ) -> RenderedTokens: + if not messages: + raise ValueError("No messages provided.") + + tokens: list[int] = [] + indices: list[int] = [] + sampled: list[bool] = [] + content_mask: list[bool] = [] + mm_hashes: dict[str, list[str]] = {} + mm_placeholders: dict[str, list[PlaceholderRange]] = {} + mm_items: dict[str, list[dict[str, Any]]] = {} + + def emit_special( + token_id: int, msg_idx: int, *, is_sampled: bool, is_content: bool + ) -> None: + tokens.append(token_id) + indices.append(msg_idx) + sampled.append(is_sampled) + content_mask.append(is_content) + + def emit_text( + text: str, msg_idx: int, *, is_sampled: bool, is_content: bool + ) -> None: + ids = self._encode(text) + tokens.extend(ids) + indices.extend([msg_idx] * len(ids)) + sampled.extend([is_sampled] * len(ids)) + content_mask.extend([is_content] * len(ids)) + + def emit_image( + part: Any, + msg_idx: int, + role_open, + *, + is_sampled: bool, + marker_is_content: bool, + ) -> None: + pil = _load_pil_image(part) + h = _image_hash(pil) + out, num_patches = self._process_image(pil, h) + role_open() + emit_special( + self._content_image, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + offset = len(tokens) + for _ in range(num_patches): + emit_special( + self._image_pad, msg_idx, is_sampled=is_sampled, is_content=True + ) + emit_special( + self._end_message, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + mm_hashes.setdefault("image", []).append(h) + mm_placeholders.setdefault("image", []).append( + PlaceholderRange(offset=offset, length=num_patches) + ) + mm_items.setdefault("image", []).append( + {"pixel_values": out["pixel_values"]} + ) + + def emit_audio( + part: Any, + msg_idx: int, + role_open, + *, + is_sampled: bool, + marker_is_content: bool, + ) -> None: + wav, sr = _load_audio(part) + processed = self._process_audio(wav, sr) + n_frames = int(processed["audio_input_ids_mask"][0].sum()) + role_open() + emit_special( + self._content_audio_input, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + offset = len(tokens) + for _ in range(n_frames): + emit_special( + self._audio_pad, msg_idx, is_sampled=is_sampled, is_content=True + ) + emit_special( + self._audio_end, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + emit_special( + self._end_message, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + mm_hashes.setdefault("audio", []).append(_audio_hash(wav, sr)) + mm_placeholders.setdefault("audio", []).append( + PlaceholderRange(offset=offset, length=n_frames) + ) + mm_items.setdefault("audio", []).append( + { + "audio_input_ids": processed["audio_input_ids"], + "audio_input_ids_mask": processed["audio_input_ids_mask"], + } + ) + + tool_names = extract_message_tool_names(messages) + + # ── Tools declaration (first, before the effort line) ── + if tools: + self._emit_tools(tools, emit_special, emit_text) + + # ── Message loop; effort emitted once before the first non-system ── + effort_emitted = False + for i, msg in enumerate(messages): + role = msg.get("role", "") + if not effort_emitted and role != "system": + self._emit_effort(emit_special, emit_text) + effort_emitted = True + + if role == "assistant": + self._render_assistant( + msg, i, emit_special, emit_text, emit_image, emit_audio + ) + elif role == "tool": + self._render_tool(msg, i, tool_names[i], emit_special, emit_text) + elif role in ("system", "user"): + role_id = ( + self._message_system if role == "system" else self._message_user + ) + + def role_open(_role_id=role_id, _i=i): + emit_special(_role_id, _i, is_sampled=False, is_content=False) + + self._emit_content( + msg.get("content"), + i, + role_open=role_open, + is_sampled=False, + marker_is_content=False, + emit_special=emit_special, + emit_text=emit_text, + emit_image=emit_image, + emit_audio=emit_audio, + ) + else: + raise ValueError(f"Unknown message role: {role!r}") + + if not effort_emitted: + self._emit_effort(emit_special, emit_text) + + # ── Generation prompt ── + if add_generation_prompt: + emit_special(self._message_model, -1, is_sampled=False, is_content=False) + + mm_data: MultiModalData | None = None + if mm_hashes or mm_placeholders or mm_items: + mm_data = MultiModalData( + mm_hashes=mm_hashes, + mm_placeholders=mm_placeholders, + mm_items=mm_items, + ) + + return RenderedTokens( + token_ids=tokens, + message_indices=indices, + sampled_mask=sampled, + is_content=content_mask, + message_roles=[m.get("role") or "" for m in messages], + message_tool_names=tool_names, + multi_modal_data=mm_data, + ) + + def render_ids( + self, + messages: list[Message], + *, + tools: list[ToolSpec] | None = None, + add_generation_prompt: bool = False, + ) -> list[int]: + return self.render( + messages, tools=tools, add_generation_prompt=add_generation_prompt + ).token_ids + + def parse_response( + self, + token_ids: list[int], + *, + tools: list[ToolSpec] | None = None, # noqa: ARG002 — args are native JSON, no schema coercion + ) -> ParsedResponse: + return parse_inkling( + self._tokenizer, + token_ids, + stop_ids=set(self.get_stop_token_ids()), + message_model_id=self._message_model, + content_text_id=self._content_text, + content_thinking_id=self._content_thinking, + invoke_json_id=self._content_invoke_tool_json, + invoke_text_id=self._content_invoke_tool_text, + end_message_id=self._end_message, + ) + + def get_stop_token_ids(self) -> list[int]: + stop = [self._content_model_end_sampling] + if self._endoftext is not None: + stop.append(self._endoftext) + return stop + + # ------------------------------------------------------------------ + # Per-message emitters (shared by render + bridge) + # ------------------------------------------------------------------ + + def _emit_effort(self, emit_special, emit_text) -> None: + emit_special(self._message_system, -1, is_sampled=False, is_content=False) + emit_special(self._content_text, -1, is_sampled=False, is_content=False) + emit_text( + "Thinking effort level: " + _format_effort_number(self._effort_num), + -1, + is_sampled=False, + is_content=False, + ) + emit_special(self._end_message, -1, is_sampled=False, is_content=False) + + def _emit_tools(self, tools, emit_special, emit_text) -> None: + specs: list[dict[str, Any]] = [] + for tool in tools: + fn = tool.get("function", tool) if isinstance(tool, dict) else tool + if not isinstance(fn, dict): + fn = {} + specs.append( + { + "description": fn.get("description") or "", + "name": fn.get("name"), + "parameters": fn.get("parameters") or {}, + "type": (tool.get("type") if isinstance(tool, dict) else None) + or "function", + } + ) + tools_json = json.dumps( + specs, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) + emit_special(self._message_system, -1, is_sampled=False, is_content=False) + emit_text("tool_declare", -1, is_sampled=False, is_content=False) + emit_special(self._content_xml, -1, is_sampled=False, is_content=False) + emit_text(tools_json, -1, is_sampled=False, is_content=False) + emit_special(self._end_message, -1, is_sampled=False, is_content=False) + + def _emit_content( + self, + content: Content | None, + msg_idx: int, + *, + role_open, + is_sampled: bool, + marker_is_content: bool, + emit_special, + emit_text, + emit_image, + emit_audio, + ) -> None: + """Emit a message's content as one or more role-tagged blocks. + + String content is one ``<|content_text|>`` block (emitted even when + empty). List content is one block per part, each re-opening the role + via ``role_open`` — matching the template's per-part role re-emission. + ``marker_is_content`` sets the ``is_content`` bit on the structural + markers (True for assistant, where every sampled token counts as body; + False for user/system, where markers are scaffold).""" + if isinstance(content, str): + role_open() + emit_special( + self._content_text, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + if content: + emit_text(content, msg_idx, is_sampled=is_sampled, is_content=True) + emit_special( + self._end_message, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + return + if not content: + return + for part in content: + kind, data = _classify_part(part) + if kind == "text": + role_open() + emit_special( + self._content_text, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + if data: + emit_text(data, msg_idx, is_sampled=is_sampled, is_content=True) + emit_special( + self._end_message, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + elif kind == "image": + emit_image( + data, + msg_idx, + role_open, + is_sampled=is_sampled, + marker_is_content=marker_is_content, + ) + elif kind == "audio": + emit_audio( + data, + msg_idx, + role_open, + is_sampled=is_sampled, + marker_is_content=marker_is_content, + ) + elif kind == "video": + raise NotImplementedError( + "Video parts are not supported by InklingRenderer." + ) + # "skip": unknown part type — ignore (matches template's text-or-drop). + + def _render_tool(self, msg, msg_idx, inline_name, emit_special, emit_text) -> None: + """Tool response: ``<|message_tool|>{name?}<|content_text|>{content}<|end_message|>``. + + The name (resolved from ``msg['name']`` or the matching prior + assistant tool-call id) is structural routing info, so it is scaffold. + Only string content is rendered — the template drops list content in + tool messages.""" + emit_special(self._message_tool, msg_idx, is_sampled=False, is_content=False) + if inline_name: + emit_text(inline_name, msg_idx, is_sampled=False, is_content=False) + emit_special(self._content_text, msg_idx, is_sampled=False, is_content=False) + content = msg.get("content") + if isinstance(content, str) and content: + emit_text(content, msg_idx, is_sampled=False, is_content=True) + emit_special(self._end_message, msg_idx, is_sampled=False, is_content=False) + + def _render_assistant( + self, msg, msg_idx, emit_special, emit_text, emit_image, emit_audio + ) -> None: + """Assistant turn: optional reasoning block, content block(s), tool-call + blocks, then the ``<|content_model_end_sampling|>`` close. + + The very first ``<|message_model|>`` is the generation-prompt-equivalent + (scaffold, never sampled); every token after it through the close is the + model's sampled emission, so ``is_content == sampled_mask`` holds across + the whole turn.""" + reasoning = msg.get("reasoning_content") + reasoning = reasoning if isinstance(reasoning, str) and reasoning else "" + content = msg.get("content") + tool_calls = msg.get("tool_calls") or [] + + first = True + + def model_tag(): + nonlocal first + if first: + emit_special( + self._message_model, msg_idx, is_sampled=False, is_content=False + ) + first = False + else: + emit_special( + self._message_model, msg_idx, is_sampled=True, is_content=True + ) + + if reasoning: + model_tag() + emit_special( + self._content_thinking, msg_idx, is_sampled=True, is_content=True + ) + emit_text(reasoning, msg_idx, is_sampled=True, is_content=True) + emit_special(self._end_message, msg_idx, is_sampled=True, is_content=True) + + self._emit_content( + content, + msg_idx, + role_open=model_tag, + is_sampled=True, + marker_is_content=True, + emit_special=emit_special, + emit_text=emit_text, + emit_image=emit_image, + emit_audio=emit_audio, + ) + + for tc in tool_calls: + name, args = self._tool_call_name_args(tc) + model_tag() + if name: + emit_text(name, msg_idx, is_sampled=True, is_content=True) + emit_special( + self._content_invoke_tool_json, + msg_idx, + is_sampled=True, + is_content=True, + ) + emit_text( + self._invoke_json(name, args), + msg_idx, + is_sampled=True, + is_content=True, + ) + emit_special(self._end_message, msg_idx, is_sampled=True, is_content=True) + + emit_special( + self._content_model_end_sampling, msg_idx, is_sampled=True, is_content=True + ) + + @staticmethod + def _tool_call_name_args(tc: Any) -> tuple[str, Any]: + func = tc.get("function") if isinstance(tc, dict) else None + if not isinstance(func, dict): + func = tc if isinstance(tc, dict) else {} + name = func.get("name") or "" + args = func.get("arguments", {}) + if isinstance(args, str): + try: + args = json.loads(args) + except (json.JSONDecodeError, ValueError): + args = {} + if not isinstance(args, dict): + args = {} + return name, args + + @staticmethod + def _invoke_json(name: str, args: Any) -> str: + """``{"name":,"args":}`` with the template's compact, + key-sorted encoding.""" + return ( + '{"name":' + + json.dumps( + name, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) + + ',"args":' + + json.dumps( + args, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) + + "}" + ) + + # ------------------------------------------------------------------ + # Bridge + # ------------------------------------------------------------------ + + def bridge_to_next_turn( + self, + previous_prompt_ids: list[int], + previous_completion_ids: list[int], + new_messages: list[Message], + *, + tools: list[ToolSpec] | None = None, + previous_multi_modal_data: MultiModalData | None = None, + ) -> "RenderedTokens | None": + if ( + not previous_prompt_ids + or not new_messages + or reject_assistant_in_extension(new_messages) + ): + return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, new_messages + ): + return None + + # A tool message whose name would be recovered from a prior-turn + # assistant tool-call id can't be resolved from ``new_messages`` alone + # (assistants are excluded), so a bridge would diverge from a full + # re-render — refuse and let the caller re-render. + for msg in new_messages: + if ( + msg.get("role") == "tool" + and not msg.get("name") + and msg.get("tool_call_id") + ): + return None + + # Prior assistant turn closes with <|content_model_end_sampling|>; + # synthesise it on a truncated completion. + close_ids: set[int] = {self._content_model_end_sampling} + if self._endoftext is not None: + close_ids.add(self._endoftext) + previous_ids = trim_to_turn_close( + previous_prompt_ids, + previous_completion_ids, + close_ids, + synthesize_close=self._content_model_end_sampling, + ) + if previous_ids is None: + return None + + tokens: list[int] = list(previous_ids) + indices: list[int] = [-1] * len(previous_ids) + sampled: list[bool] = [False] * len(previous_ids) + content_mask: list[bool] = [False] * len(previous_ids) + new_hashes: dict[str, list[str]] = {} + new_placeholders: dict[str, list[PlaceholderRange]] = {} + new_items: dict[str, list[dict[str, Any]]] = {} + + def emit_special( + token_id: int, msg_idx: int, *, is_sampled: bool, is_content: bool + ) -> None: + tokens.append(token_id) + indices.append(msg_idx) + sampled.append(is_sampled) + content_mask.append(is_content) + + def emit_text( + text: str, msg_idx: int, *, is_sampled: bool, is_content: bool + ) -> None: + ids = self._encode(text) + tokens.extend(ids) + indices.extend([msg_idx] * len(ids)) + sampled.extend([is_sampled] * len(ids)) + content_mask.extend([is_content] * len(ids)) + + def emit_image( + part, msg_idx, role_open, *, is_sampled, marker_is_content + ) -> None: + pil = _load_pil_image(part) + h = _image_hash(pil) + out, num_patches = self._process_image(pil, h) + role_open() + emit_special( + self._content_image, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + offset = len(tokens) + for _ in range(num_patches): + emit_special( + self._image_pad, msg_idx, is_sampled=is_sampled, is_content=True + ) + emit_special( + self._end_message, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + new_hashes.setdefault("image", []).append(h) + new_placeholders.setdefault("image", []).append( + PlaceholderRange(offset=offset, length=num_patches) + ) + new_items.setdefault("image", []).append( + {"pixel_values": out["pixel_values"]} + ) + + def emit_audio( + part, msg_idx, role_open, *, is_sampled, marker_is_content + ) -> None: + wav, sr = _load_audio(part) + processed = self._process_audio(wav, sr) + n_frames = int(processed["audio_input_ids_mask"][0].sum()) + role_open() + emit_special( + self._content_audio_input, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + offset = len(tokens) + for _ in range(n_frames): + emit_special( + self._audio_pad, msg_idx, is_sampled=is_sampled, is_content=True + ) + emit_special( + self._audio_end, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + emit_special( + self._end_message, + msg_idx, + is_sampled=is_sampled, + is_content=marker_is_content, + ) + new_hashes.setdefault("audio", []).append(_audio_hash(wav, sr)) + new_placeholders.setdefault("audio", []).append( + PlaceholderRange(offset=offset, length=n_frames) + ) + new_items.setdefault("audio", []).append( + { + "audio_input_ids": processed["audio_input_ids"], + "audio_input_ids_mask": processed["audio_input_ids_mask"], + } + ) + + tool_names = extract_message_tool_names(new_messages) + for i, msg in enumerate(new_messages): + role = msg.get("role") + if role == "tool": + self._render_tool(msg, i, tool_names[i], emit_special, emit_text) + elif role in ("system", "user"): + role_id = ( + self._message_system if role == "system" else self._message_user + ) + + def role_open(_role_id=role_id, _i=i): + emit_special(_role_id, _i, is_sampled=False, is_content=False) + + self._emit_content( + msg.get("content"), + i, + role_open=role_open, + is_sampled=False, + marker_is_content=False, + emit_special=emit_special, + emit_text=emit_text, + emit_image=emit_image, + emit_audio=emit_audio, + ) + else: + return None + + emit_special(self._message_model, -1, is_sampled=False, is_content=False) + + # Merge carried-forward mm_data (prior turns) with this turn's items. + # Copy the per-modality lists (not just the outer dict) so appending + # this turn's items never mutates the caller's previous_multi_modal_data. + merged_hashes = ( + {k: list(v) for k, v in previous_multi_modal_data.mm_hashes.items()} + if previous_multi_modal_data + else {} + ) + merged_placeholders = ( + {k: list(v) for k, v in previous_multi_modal_data.mm_placeholders.items()} + if previous_multi_modal_data + else {} + ) + merged_items = ( + {k: list(v) for k, v in previous_multi_modal_data.mm_items.items()} + if previous_multi_modal_data + else {} + ) + for modality, vals in new_hashes.items(): + merged_hashes.setdefault(modality, []).extend(vals) + for modality, vals in new_placeholders.items(): + merged_placeholders.setdefault(modality, []).extend(vals) + for modality, vals in new_items.items(): + merged_items.setdefault(modality, []).extend(vals) + + mm_data: MultiModalData | None = None + if merged_hashes or merged_placeholders or merged_items: + mm_data = MultiModalData( + mm_hashes=merged_hashes, + mm_placeholders=merged_placeholders, + mm_items=merged_items, + ) + + return RenderedTokens( + token_ids=tokens, + message_indices=indices, + sampled_mask=sampled, + is_content=content_mask, + message_roles=[m.get("role") or "" for m in new_messages], + message_tool_names=tool_names, + multi_modal_data=mm_data, + ) diff --git a/renderers/parsing.py b/renderers/parsing.py index 6c05b1a..72d1129 100644 --- a/renderers/parsing.py +++ b/renderers/parsing.py @@ -1611,3 +1611,135 @@ def parse_llama_3( # "malformed attempt" against, so it falls through to content rather # than producing a non-OK ParsedToolCall. return ParsedResponse(content=text, reasoning_content=None) + + +def parse_inkling( + tokenizer, + token_ids: list[int], + *, + stop_ids: set[int], + message_model_id: int, + content_text_id: int, + content_thinking_id: int, + invoke_json_id: int, + invoke_text_id: int, + end_message_id: int, +) -> ParsedResponse: + """Parse Inkling completion tokens. + + Inkling's assistant turn is a sequence of ``<|end_message|>``-terminated + segments; the model re-emits ``<|message_model|>`` before each segment + after the first (the first's opener is the generation prompt). Each + segment is classified by its leading content marker: + + - ``<|content_thinking|>{reasoning}`` → reasoning + - ``<|content_text|>{content}`` → visible content + - ``{name}<|content_invoke_tool_json|>{"name":…,"args":…}`` → tool call + + Content and reasoning are decoded verbatim (the template renders them + without whitespace normalisation). Tool-call arguments arrive as native + JSON (the template ``tojson``-encodes them), so types are preserved + without the schema-driven coercion the XML formats need — ``tools`` is + accepted for signature uniformity but unused. + + ``token_span`` on each ``ParsedToolCall`` is the half-open slice of the + stop-stripped stream covering that tool-call segment (from its leading + ``<|message_model|>`` through the terminating ``<|end_message|>``). + """ + ids = _strip_stop_tokens(token_ids, stop_ids) + + reasoning_parts: list[str] = [] + content_parts: list[str] = [] + tool_calls: list[ParsedToolCall] = [] + + pos = 0 + n = len(ids) + while pos < n: + em = _find(ids, end_message_id, pos) + terminated = em != -1 + seg_end = em if terminated else n + seg = ids[pos:seg_end] + # A non-first segment re-opens with <|message_model|>; drop it so the + # content marker is at the head. (The first segment's opener lives in + # the prompt, so it is usually already absent.) + s = 1 if seg and seg[0] == message_model_id else 0 + body = seg[s:] + + if not body: + pass + elif body[0] == content_thinking_id: + reasoning_parts.append(_decode(tokenizer, body[1:])) + elif body[0] == content_text_id: + content_parts.append(_decode(tokenizer, body[1:])) + else: + marker = _find_any(body, {invoke_json_id, invoke_text_id}) + if marker != -1: + name = _decode(tokenizer, body[:marker]).strip() + payload = _decode(tokenizer, body[marker + 1 :]) + tool_calls.append( + _build_inkling_tool_call( + name=name, + payload=payload, + token_span=(pos, seg_end + 1 if terminated else n), + terminated=terminated, + ) + ) + else: + # No content marker and no invoke marker — treat the decoded + # bytes as content rather than dropping them. + content_parts.append(_decode(tokenizer, body)) + + if not terminated: + break + pos = em + 1 + + return ParsedResponse( + content="".join(content_parts), + reasoning_content="".join(reasoning_parts) or None, + tool_calls=tool_calls, + ) + + +def _build_inkling_tool_call( + *, name: str, payload: str, token_span: tuple[int, int], terminated: bool +) -> ParsedToolCall: + """Build a ``ParsedToolCall`` from an Inkling ``<|content_invoke_tool_json|>`` + payload — ``{"name": …, "args": …}`` (native JSON, types preserved). + + The function name inside the JSON is authoritative; the pre-marker text + (rendered as ``<|message_model|>{name}<|content_invoke_tool_json|>``) is a + fallback for a truncated / malformed payload. + """ + parsed: Any = None + invalid_json = False + try: + parsed = json.loads(payload) + except (json.JSONDecodeError, ValueError): + invalid_json = True + + fn_name: str | None = None + arguments: dict[str, Any] | str | None = None + if isinstance(parsed, dict): + raw_name = parsed.get("name") + fn_name = raw_name if isinstance(raw_name, str) and raw_name else (name or None) + arguments = parsed.get("args", {}) + else: + fn_name = name or None + arguments = payload + + if not terminated: + status = ToolCallParseStatus.UNCLOSED_BLOCK + elif invalid_json: + status = ToolCallParseStatus.INVALID_JSON + elif fn_name is None: + status = ToolCallParseStatus.MISSING_NAME + else: + status = ToolCallParseStatus.OK + + return ParsedToolCall( + raw=payload, + name=fn_name, + arguments=arguments, + token_span=token_span, + status=status, + ) diff --git a/tests/conftest.py b/tests/conftest.py index bd7672e..54948fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ ("meta-llama/Llama-3.2-1B-Instruct", "auto"), ("openai/gpt-oss-20b", "gpt-oss"), ("tencent/Hy3", "auto"), + ("thinkingmachines/Inkling", "auto"), ("Qwen/Qwen2.5-0.5B-Instruct", "default"), ] diff --git a/tests/test_inkling.py b/tests/test_inkling.py new file mode 100644 index 0000000..06ee8b2 --- /dev/null +++ b/tests/test_inkling.py @@ -0,0 +1,418 @@ +"""Inkling-focused tests (``thinkingmachines/Inkling``). + +The shared matrices (conftest render-parity, config-parity, multimodal) already +assert byte parity on the common shapes and image/audio parity against the +processor. This file pins the behaviours specific to Inkling's token-delimited +template that those matrices don't generate: + +- the ``Thinking effort level: {N}`` line (labels + raw floats, ``0`` special case), +- effort emitted once before the first non-system message (or at the end), +- the token-delimited assistant structure and its sampled/is_content masks, +- tool-name resolution via ``tool_call_id``, +- parse round-trips (reasoning / content / native-JSON tool calls), +- bridge extensions matching a fresh render byte-for-byte, +- config validation of ``reasoning_effort``. +""" + +from __future__ import annotations + +from functools import lru_cache + +import pytest + +from renderers import create_renderer +from renderers.base import ToolCallParseStatus, load_tokenizer +from renderers.configs import InklingRendererConfig +from renderers.inkling import InklingRenderer + +_MODEL = "thinkingmachines/Inkling" + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "The city name"}, + "days": {"type": "integer", "description": "Forecast days"}, + }, + "required": ["city"], + }, + }, + } +] + + +@lru_cache(maxsize=None) +def _tok(): + return load_tokenizer(_MODEL) + + +def _renderer(**config_kwargs) -> InklingRenderer: + renderer = create_renderer(_tok(), InklingRendererConfig(**config_kwargs)) + assert isinstance(renderer, InklingRenderer) + return renderer + + +def _expected(msgs, *, tools=None, add_generation_prompt=False, **template_kwargs): + result = _tok().apply_chat_template( + msgs, + tools=tools, + add_generation_prompt=add_generation_prompt, + tokenize=True, + return_dict=False, + **template_kwargs, + ) + if isinstance(result, dict): + return list(result["input_ids"]) + if isinstance(result, str): + return list(_tok().encode(result, add_special_tokens=False)) + return list(result) + + +def _decode(ids): + return _tok().decode(ids, skip_special_tokens=False) + + +# ── Reasoning-effort line ───────────────────────────────────────────── + + +@pytest.mark.parametrize( + "effort,shown", + [ + ("none", "0"), + ("minimal", "0.1"), + ("low", "0.2"), + ("medium", "0.7"), + ("high", "0.9"), + ("max", "0.99"), + (0.0, "0"), + (0.5, "0.5"), + (0.33, "0.33"), + (0.99, "0.99"), + ], +) +def test_effort_line_format_and_parity(effort, shown): + msgs = [{"role": "user", "content": "Hi"}] + r = _renderer(reasoning_effort=effort) + ours = r.render_ids(msgs, add_generation_prompt=True) + assert ours == _expected(msgs, add_generation_prompt=True, reasoning_effort=effort) + assert f"Thinking effort level: {shown}<|end_message|>" in _decode(ours) + + +def test_default_effort_is_high(): + """No ``reasoning_effort`` set mirrors the template default (0.9).""" + msgs = [{"role": "user", "content": "Hi"}] + ours = _renderer().render_ids(msgs, add_generation_prompt=True) + assert ours == _expected(msgs, add_generation_prompt=True) + assert "Thinking effort level: 0.9<|end_message|>" in _decode(ours) + + +def test_effort_emitted_once_before_first_non_system(): + msgs = [ + {"role": "system", "content": "SYS"}, + {"role": "user", "content": "Hi"}, + ] + ours = _renderer().render_ids(msgs, add_generation_prompt=True) + assert ours == _expected(msgs, add_generation_prompt=True) + text = _decode(ours) + # System body precedes the effort line, which precedes the user turn. + assert ( + text.index("SYS") + < text.index("Thinking effort level") + < text.index("<|message_user|>") + ) + assert text.count("Thinking effort level") == 1 + + +def test_effort_emitted_at_end_when_all_system(): + msgs = [{"role": "system", "content": "only sys"}] + ours = _renderer().render_ids(msgs) + assert ours == _expected(msgs) + text = _decode(ours) + assert text.index("only sys") < text.index("Thinking effort level") + + +# ── Tool declaration precedes everything ────────────────────────────── + + +def test_tool_declaration_comes_first(): + msgs = [ + {"role": "system", "content": "SYS"}, + {"role": "user", "content": "Weather?"}, + ] + ours = _renderer().render_ids(msgs, tools=TOOLS, add_generation_prompt=True) + assert ours == _expected(msgs, tools=TOOLS, add_generation_prompt=True) + text = _decode(ours) + assert text.startswith("<|message_system|>tool_declare<|content_xml|>") + # The tools block precedes the system body and the effort line. + assert ( + text.index("tool_declare") + < text.index("SYS") + < text.index("Thinking effort level") + ) + + +# ── Assistant structure + masks ─────────────────────────────────────── + + +def test_assistant_structure_and_masks(): + msgs = [ + {"role": "user", "content": "2+2?"}, + { + "role": "assistant", + "reasoning_content": "add them", + "content": "4", + }, + ] + r = _renderer() + rendered = r.render(msgs) + assert rendered.token_ids == _expected(msgs) + + pos = [k for k, i in enumerate(rendered.message_indices) if i == 1] + sampled = [rendered.sampled_mask[k] for k in pos] + # Only the leading <|message_model|> (gen-prompt-equivalent) is scaffold. + assert sampled[0] is False + assert all(sampled[1:]) + # On assistant tokens is_content == sampled_mask by construction. + assert [rendered.is_content[k] for k in pos] == sampled + # The turn ends on the sampled <|content_model_end_sampling|>. + assert rendered.token_ids[pos[-1]] == r._content_model_end_sampling + assert rendered.sampled_mask[pos[-1]] is True + + +def test_tool_call_invoke_json_shape(): + msgs = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "get_weather", + "arguments": {"city": "Paris", "days": 3}, + } + } + ], + }, + ] + ours = _renderer().render_ids(msgs, tools=TOOLS) + assert ours == _expected(msgs, tools=TOOLS) + assert ( + "<|message_model|>get_weather<|content_invoke_tool_json|>" + '{"name":"get_weather","args":{"city":"Paris","days":3}}<|end_message|>' + ) in _decode(ours) + + +# ── Tool-name resolution ────────────────────────────────────────────── + + +def test_tool_name_resolved_via_tool_call_id(): + msgs = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "c1", + "function": {"name": "get_weather", "arguments": {"city": "Paris"}}, + } + ], + }, + {"role": "tool", "tool_call_id": "c1", "content": "sunny"}, + ] + ours = _renderer().render_ids(msgs, tools=TOOLS) + assert ours == _expected(msgs, tools=TOOLS) + assert "<|message_tool|>get_weather<|content_text|>sunny<|end_message|>" in _decode( + ours + ) + + +# ── Parse ───────────────────────────────────────────────────────────── + + +def _completion(prompt, asst, *, tools=None): + r = _renderer() + pp = r.render_ids(prompt, tools=tools, add_generation_prompt=True) + full = r.render_ids([*prompt, asst], tools=tools) + return r, full[len(pp) :] + + +def test_parse_reasoning_and_content(): + r, comp = _completion( + [{"role": "user", "content": "2+2?"}], + { + "role": "assistant", + "reasoning_content": "Let me think.", + "content": "The answer is 4.", + }, + ) + parsed = r.parse_response(comp) + assert parsed.reasoning_content == "Let me think." + assert parsed.content == "The answer is 4." + assert not parsed.tool_calls + + +def test_parse_tool_call_preserves_types(): + r, comp = _completion( + [{"role": "user", "content": "weather?"}], + { + "role": "assistant", + "content": "checking", + "tool_calls": [ + { + "function": { + "name": "get_weather", + "arguments": {"city": "Paris", "days": 3}, + } + } + ], + }, + tools=TOOLS, + ) + parsed = r.parse_response(comp, tools=TOOLS) + assert parsed.content == "checking" + assert len(parsed.tool_calls) == 1 + tc = parsed.tool_calls[0] + assert tc.name == "get_weather" + assert tc.arguments == {"city": "Paris", "days": 3} # int preserved + assert tc.status == ToolCallParseStatus.OK + assert tc.token_span is not None + + +def test_parse_multiple_tool_calls(): + r, comp = _completion( + [{"role": "user", "content": "hi"}], + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"function": {"name": "f", "arguments": {"a": 1}}}, + {"function": {"name": "g", "arguments": {}}}, + ], + }, + ) + parsed = r.parse_response(comp) + assert [tc.name for tc in parsed.tool_calls] == ["f", "g"] + assert parsed.tool_calls[0].arguments == {"a": 1} + assert parsed.tool_calls[1].arguments == {} + + +# ── Bridge ──────────────────────────────────────────────────────────── + + +def _bridge_case(r, prev, asst, ext, *, tools=None): + pp = r.render_ids(prev, tools=tools, add_generation_prompt=True) + full = r.render_ids([*prev, asst], tools=tools) + completion = full[len(pp) :] + assert completion[-1] == r._content_model_end_sampling + bridged = r.bridge_to_next_turn(pp, completion, ext, tools=tools) + fresh = r.render_ids([*prev, asst, *ext], tools=tools, add_generation_prompt=True) + return bridged, fresh + + +def test_bridge_user_extension_matches_fresh_render(): + r = _renderer() + bridged, fresh = _bridge_case( + r, + [{"role": "user", "content": "Hi"}], + {"role": "assistant", "content": "Hello!"}, + [{"role": "user", "content": "Bye"}], + ) + assert bridged is not None + assert bridged.token_ids == fresh + + +def test_bridge_tool_extension_matches_fresh_render(): + r = _renderer() + bridged, fresh = _bridge_case( + r, + [{"role": "user", "content": "Weather in Tokyo?"}], + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"function": {"name": "get_weather", "arguments": {"city": "Tokyo"}}} + ], + }, + # Tool message carries an explicit name (bridge can't resolve a + # prior-turn tool_call_id since the assistant is outside new_messages). + [ + {"role": "tool", "name": "get_weather", "content": "Sunny"}, + {"role": "user", "content": "And Paris?"}, + ], + tools=TOOLS, + ) + assert bridged is not None + assert bridged.token_ids == fresh + + +def test_bridge_synthesizes_close_on_truncation(): + r = _renderer() + prev = [{"role": "user", "content": "Hi"}] + asst = {"role": "assistant", "content": "Hello!"} + ext = [{"role": "user", "content": "Bye"}] + pp = r.render_ids(prev, add_generation_prompt=True) + full = r.render_ids([*prev, asst]) + # Truncated: drop the terminating <|content_model_end_sampling|>. + completion = full[len(pp) : -1] + assert r._content_model_end_sampling not in completion + bridged = r.bridge_to_next_turn(pp, completion, ext) + assert bridged is not None + fresh = r.render_ids([*prev, asst, *ext], add_generation_prompt=True) + assert bridged.token_ids == fresh + + +def test_bridge_rejects_assistant_extension(): + r = _renderer() + pp = r.render_ids([{"role": "user", "content": "Hi"}], add_generation_prompt=True) + assert ( + r.bridge_to_next_turn( + pp, [r._content_model_end_sampling], [{"role": "assistant", "content": "x"}] + ) + is None + ) + + +def test_bridge_refuses_tool_needing_prior_name_resolution(): + """A tool message with only ``tool_call_id`` (no explicit name) can't be + resolved from ``new_messages`` alone, so the bridge refuses (the caller + re-renders).""" + r = _renderer() + pp = r.render_ids([{"role": "user", "content": "Hi"}], add_generation_prompt=True) + bridged = r.bridge_to_next_turn( + pp, + [r._content_model_end_sampling], + [{"role": "tool", "tool_call_id": "c1", "content": "sunny"}], + ) + assert bridged is None + + +# ── Config validation ───────────────────────────────────────────────── + + +def test_config_accepts_labels_and_floats(): + for eff in ("none", "minimal", "low", "medium", "high", "max", 0.0, 0.5, 0.99): + InklingRendererConfig(reasoning_effort=eff) + + +@pytest.mark.parametrize("bad", ["no_think", "bogus", 1.5, -0.1]) +def test_config_rejects_bad_effort(bad): + with pytest.raises(Exception): + InklingRendererConfig(reasoning_effort=bad) + + +# ── Stop tokens ─────────────────────────────────────────────────────── + + +def test_stop_tokens(): + r = _renderer() + stop = r.get_stop_token_ids() + assert r._content_model_end_sampling in stop + # eos is <|content_model_end_sampling|> (config.json eos_token_id = 200006). + assert stop[0] == r._content_model_end_sampling diff --git a/tests/test_multimodal.py b/tests/test_multimodal.py index 2bba9d2..a7b60a1 100644 --- a/tests/test_multimodal.py +++ b/tests/test_multimodal.py @@ -52,6 +52,7 @@ def _config_with_add_vision_id(model_name: str, add_vision_id: bool): pytest.importorskip("PIL", reason="Pillow required for multimodal tests") pytest.importorskip("torch", reason="torch required for multimodal tests") +import numpy as np # noqa: E402 from PIL import Image # noqa: E402 @@ -172,6 +173,10 @@ def _detect_family(model_name: str) -> str: "moonshotai/Kimi-K2.6" ): return "kimi_k25" + if model_name == "thinkingmachines/Inkling": + # Two-step processor call like Qwen-VL — processor(images=/audio=, text=) + # — but different placeholder tokens and a distinct audio modality. + return "inkling" return "qwen_vl" @@ -204,6 +209,45 @@ def _qwen_vl_processor_input_ids(processor, messages, add_gp): ].tolist() +def _audio_content_part(audio): + return {"type": "audio", "audio": audio} + + +def _inkling_audio_processor_input_ids(processor, messages, add_gp): + """Run Inkling's processor on audio-bearing ``messages``. + + Two-step like the Qwen-VL image path, but collects audio waveforms and + passes them via ``audio=``. The template puts one ``<|unused_200053|>`` + per audio placeholder; the processor expands it to one per mel frame. + """ + text = processor.apply_chat_template( + messages, tokenize=False, add_generation_prompt=add_gp + ) + audios = [] + for msg in messages: + content = msg.get("content") + if not isinstance(content, list): + continue + for item in content: + if not ( + isinstance(item, dict) + and item.get("type") + in ( + "audio", + "input_audio", + "audio_url", + ) + ): + continue + for key in ("audio", "input_audio", "audio_url"): + if key in item: + audios.append(item[key]) + break + return processor(audio=audios, text=text, return_tensors="pt")["input_ids"][ + 0 + ].tolist() + + def _kimi_processor_input_ids(processor, messages, add_gp): """Run Kimi K2.5's processor on ``messages`` (one-shot template+vision). @@ -218,6 +262,22 @@ def _kimi_processor_input_ids(processor, messages, add_gp): return out["input_ids"][0].tolist() +def _audio_sample(): + """A 1-second 440 Hz mono tone at 16 kHz (Inkling's expected rate).""" + sr = 16000 + t = np.arange(sr, dtype=np.float32) / sr + return (0.1 * np.sin(2 * np.pi * 440 * t)).astype(np.float32) + + +def _sample_for(modality: str, tiny_image): + """The per-modality media item cases are built from.""" + if modality == "image": + return tiny_image + if modality == "audio": + return _audio_sample() + raise NotImplementedError(f"No sample for modality {modality!r}.") + + def _modality_kit(modality: str, model_name: str): family = _detect_family(model_name) if modality == "image": @@ -227,12 +287,31 @@ def _modality_kit(modality: str, model_name: str): "placeholder_token": "<|media_pad|>", "processor_input_ids": _kimi_processor_input_ids, } + if family == "inkling": + # Inkling expands the single image placeholder to ``num_patches`` + # ``<|unused_200054|>`` tokens; the processor call matches the + # Qwen-VL two-step (images=, text=). + return { + "make_part": _image_content_part, + "placeholder_token": "<|unused_200054|>", + "processor_input_ids": _qwen_vl_processor_input_ids, + } # Default: Qwen-VL family (Qwen3-VL, Qwen3.5, Qwen3.6). return { "make_part": _image_content_part, "placeholder_token": "<|image_pad|>", "processor_input_ids": _qwen_vl_processor_input_ids, } + if modality == "audio": + if family == "inkling": + return { + "make_part": _audio_content_part, + "placeholder_token": "<|unused_200053|>", + "processor_input_ids": _inkling_audio_processor_input_ids, + } + raise NotImplementedError( + f"Audio test kit not implemented for family {family!r}." + ) raise NotImplementedError( f"Test kit for modality {modality!r} not implemented yet." ) @@ -463,8 +542,9 @@ def test_multimodal_byte_parity_vs_processor(mm_model_name, modality, tiny_image kit = _modality_kit(modality, mm_model_name) tokenizer, processor, renderer = _load_processor_and_renderer(mm_model_name) + sample = _sample_for(modality, tiny_image) - for case in _build_cases(kit["make_part"], tiny_image): + for case in _build_cases(kit["make_part"], sample): messages, add_gp = case.values # Ours. @@ -494,8 +574,9 @@ def test_multimodal_placeholders_match_pad_runs(mm_model_name, modality, tiny_im kit = _modality_kit(modality, mm_model_name) tokenizer, _, renderer = _load_processor_and_renderer(mm_model_name) pad_id = tokenizer.convert_tokens_to_ids(kit["placeholder_token"]) + sample = _sample_for(modality, tiny_image) - for case in _build_cases(kit["make_part"], tiny_image): + for case in _build_cases(kit["make_part"], sample): messages, add_gp = case.values rendered = renderer.render(messages, add_generation_prompt=add_gp) @@ -562,12 +643,13 @@ def test_multimodal_bridge_extends_and_carries_mm_data( ) if hasattr(renderer, "_processor") and renderer._processor is None: renderer._processor = processor + sample = _sample_for(modality, tiny_image) initial = [ { "role": "user", "content": [ - kit["make_part"](tiny_image), + kit["make_part"](sample), {"type": "text", "text": "Turn one."}, ], } @@ -576,7 +658,7 @@ def test_multimodal_bridge_extends_and_carries_mm_data( { "role": "user", "content": [ - kit["make_part"](tiny_image), + kit["make_part"](sample), {"type": "text", "text": "Turn two."}, ], } @@ -589,11 +671,12 @@ def test_multimodal_bridge_extends_and_carries_mm_data( len(prior_mm.mm_items.get(modality, [])), len(prior_mm.mm_hashes.get(modality, [])), ) - # ``previous_completion_ids`` mirrors what a sampler would emit - # starting AFTER the prompt's assistant role opener — i.e. the - # response text followed by ``<|im_end|>``. - im_end_id = tokenizer.convert_tokens_to_ids("<|im_end|>") - completion_ids = tokenizer.encode("Saw it.", add_special_tokens=False) + [im_end_id] + # ``previous_completion_ids`` mirrors what a sampler would emit starting + # AFTER the prompt's assistant opener — response text then the renderer's + # turn-close token (``<|im_end|>`` for Qwen/Kimi, + # ``<|content_model_end_sampling|>`` for Inkling). + close_id = renderer.get_stop_token_ids()[0] + completion_ids = tokenizer.encode("Saw it.", add_special_tokens=False) + [close_id] bridged_raw = renderer.bridge_to_next_turn( previous_prompt_ids=initial_rendered.token_ids, diff --git a/tests/test_renderer_config_parity.py b/tests/test_renderer_config_parity.py index cb2f949..fd597fc 100644 --- a/tests/test_renderer_config_parity.py +++ b/tests/test_renderer_config_parity.py @@ -66,6 +66,7 @@ ("poolside/Laguna-XS.2", "auto"), ("poolside/Laguna-XS-2.1", "auto"), ("tencent/Hy3", "auto"), + ("thinkingmachines/Inkling", "auto"), ("openai/gpt-oss-20b", "gpt-oss"), ] diff --git a/uv.lock b/uv.lock index 2c6f5e6..e4438c2 100644 --- a/uv.lock +++ b/uv.lock @@ -9,11 +9,12 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-19T02:36:32.208558271Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" [options.exclude-newer-package] prime-pydantic-config = false +transformers = false [[package]] name = "annotated-doc" @@ -173,7 +174,7 @@ name = "cuda-bindings" version = "13.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" }, @@ -204,37 +205,37 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cublas" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -260,7 +261,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -778,7 +779,7 @@ name = "nvidia-cudnn-cu13" version = "9.19.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, @@ -790,7 +791,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -820,9 +821,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -834,7 +835,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1380,7 +1381,7 @@ requires-dist = [ { name = "openai-harmony", specifier = ">=0.0.4" }, { name = "prime-pydantic-config", specifier = ">=0.3.0.dev83" }, { name = "tiktoken" }, - { name = "transformers", specifier = ">=4.50.0" }, + { name = "transformers", specifier = ">=5.14.0" }, ] [package.metadata.requires-dev] @@ -1450,28 +1451,26 @@ wheels = [ [[package]] name = "safetensors" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, - { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, - { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, - { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6a/4d08d89a6fcbe905c5ae68b8b34f0791850882fc19782d0d02c65abbdf3b/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4729811a6640d019a4b7ba8638ee2fd21fa5ca8c7e7bdf0fed62068fcaac737", size = 492430, upload-time = "2025-11-19T15:18:11.884Z" }, - { url = "https://files.pythonhosted.org/packages/dd/29/59ed8152b30f72c42d00d241e58eaca558ae9dbfa5695206e2e0f54c7063/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12f49080303fa6bb424b362149a12949dfbbf1e06811a88f2307276b0c131afd", size = 503977, upload-time = "2025-11-19T15:18:17.523Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/4811bfec67fa260e791369b16dab105e4bae82686120554cc484064e22b4/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0071bffba4150c2f46cae1432d31995d77acfd9f8db598b5d1a2ce67e8440ad2", size = 623890, upload-time = "2025-11-19T15:18:22.666Z" }, - { url = "https://files.pythonhosted.org/packages/58/5b/632a58724221ef03d78ab65062e82a1010e1bef8e8e0b9d7c6d7b8044841/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473b32699f4200e69801bf5abf93f1a4ecd432a70984df164fc22ccf39c4a6f3", size = 531885, upload-time = "2025-11-19T15:18:27.146Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] [[package]] @@ -1717,7 +1716,7 @@ wheels = [ [[package]] name = "transformers" -version = "5.6.2" +version = "5.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, @@ -1731,9 +1730,9 @@ dependencies = [ { name = "tqdm" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/e9/c6c80a07690142a7d05444271f47b9f3c8aac7dea01d52e1137ee480ad78/transformers-5.6.2.tar.gz", hash = "sha256:e657134c3e5a6bc00a3c35f4e2674bb51adfcd89898495b788a18552bac2b91a", size = 8311867, upload-time = "2026-04-23T18:33:29.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/fb/2a2ba88f325e68a921d8b69ff63b477830b2e73ade9a3c8c8cab2f06d741/transformers-5.14.1.tar.gz", hash = "sha256:60d196c27781eacf8637e2b533f517582907ad6f9ae142046d6b69431a5b2173", size = 9295927, upload-time = "2026-07-16T09:41:57.773Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/0b0218149b0d6f14df35f5b8f676fa83df4f19ed253c3cc447107ef86eca/transformers-5.6.2-py3-none-any.whl", hash = "sha256:f8d3a1bb96778fed9b8aabfd0dd6e19843e4b0f2bb6b59f32b8a92051b0f348f", size = 10364898, upload-time = "2026-04-23T18:33:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl", hash = "sha256:9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725", size = 11625234, upload-time = "2026-07-16T09:41:54.143Z" }, ] [[package]]