CoolFace
Modelpublic

Basher17/Domyn-Small-v1.0-mlx-fp16

sourceHugging Faceotherupdated 4mo agoView on Hugging Face
0likes20downloads
reasoning_parser_plugin.py298 linesDownload Raw Back to root
1"""Reasoning parser plugin for Domyn-Small ``<think>...</think>`` outputs.2 3Loaded into vLLM with ``--reasoning-parser-plugin <path>`` and selected via4``--reasoning-parser think_block``. The parser splits each model output on5the literal ``</think>`` marker: everything before it is reasoning,6everything after is final content.7 8See :class:`ThinkBlockReasoningParser` for the streaming state machine and9how per-request thinking-on/off is discovered.10"""11 12from __future__ import annotations13 14from collections.abc import Iterable, Sequence15from typing import TYPE_CHECKING16 17from vllm.reasoning import ReasoningParser, ReasoningParserManager18 19if TYPE_CHECKING:20    from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest21    from vllm.entrypoints.openai.engine.protocol import DeltaMessage22    from vllm.entrypoints.openai.responses.protocol import ResponsesRequest23 24# Literal markers emitted by the Domyn-Small chat template. `<think>` is25# pre-emitted by the prompt, so model output never starts with it; only `</think>`26# actually has to be detected at runtime.27START = "<think>"28END = "</think>"29 30 31def _max_suffix_prefix(s: str, marker: str) -> str:32    """Longest non-empty suffix of ``s`` that is also a prefix of ``marker``.33 34    Used to decide how many trailing bytes of the streaming buffer must be35    held back — if those bytes could still grow into ``marker`` on the next36    delta, releasing them now would fragment the marker across deltas (e.g.37    emitting ``</thi`` and then ``nk>``).38    """39    for i in range(min(len(marker) - 1, len(s)), 0, -1):40        if s.endswith(marker[:i]):41            return s[-i:]42    return ""43 44 45@ReasoningParserManager.register_module("think_block")46class ThinkBlockReasoningParser(ReasoningParser):47    """Splits model output on the literal ``</think>`` marker.48 49    **Streaming.** Olmo3-style buffered state machine: incoming text is50    accumulated in :attr:`_buffer` and only released when the marker is51    either confirmed (split point reached) or ruled out (the buffer tail52    can no longer be a prefix of ``</think>``). This guarantees the marker53    is never fragmented across deltas.54 55    **Per-request lane.** The initial lane (``"reasoning"`` vs56    ``"content"``) is set from the request itself: ``True`` if57    ``chat_template_kwargs.enable_thinking`` (or ``.thinking``) is truthy,58    or if any system message contains the literal ``"thinking on"``59    directive — mirroring the chat template's own detection.60 61    **Request discovery.** vLLM instantiates the parser per request from62    inside ``create_chat_completion(self, request, ...)``, but does not63    pass the request to the constructor. We recover it by walking the call64    stack at ``__init__`` time, inspecting only each frame's *function65    arguments* (so we don't accidentally match request-shaped objects in66    module globals or unrelated locals). If no request is found we fall67    back to ``thinking=off``, which keeps tool-call streaming working out68    of the box.69    """70 71    def __init__(self, tokenizer, *args, **kwargs) -> None:72        # Base ReasoningParser only accepts `tokenizer`; swallow any extras so73        # the registration signature stays compatible across vLLM versions.74        super().__init__(tokenizer)75        self._buffer: str = ""76        # Current lane for streaming output: "reasoning" while inside77        # <think>...</think>, "content" otherwise. Locked to "content" once78        # `</think>` is observed.79        self._state: str = "content"80        # Tracks whether we have applied per-request configuration yet —81        # stack-walking covers the streaming path; `extract_reasoning` also82        # configures on the first non-streaming call as a safety net.83        self._configured: bool = False84 85        request = self._find_request_in_stack()86        if request is not None:87            self._configure_for_request(request)88 89    @staticmethod90    def _looks_like_request(obj) -> bool:91        """Duck-typed check for ChatCompletionRequest / ResponsesRequest.92 93        Avoids importing vLLM's protocol module, which differs across forks94        and isn't guaranteed to be importable at plugin load time.95        """96        return hasattr(obj, "messages") and (97            hasattr(obj, "chat_template_kwargs") or hasattr(obj, "stream")98        )99 100    @classmethod101    def _find_request_in_stack(cls, max_depth: int = 12):102        """Locate the in-flight request by scanning caller-frame arguments.103 104        Walks a bounded number of caller frames via ``sys._getframe`` /105        ``frame.f_back`` and inspects only each frame's *function106        arguments* — never its full locals. This matches vLLM's107        ``create_chat_completion(self, request, ...)`` signature and avoids108        matching request-shaped objects that happen to live in module109        globals or unrelated locals (e.g. test fixtures).110 111        We deliberately avoid :func:`inspect.stack`, which reads source112        files via ``linecache`` and builds ``FrameInfo`` objects for the113        whole stack on every call — measurable overhead per request under114        high concurrency, since parser construction is per-request and115        runs under the GIL on the serving event loop.116        """117        import sys118        try:119            frame = sys._getframe(1)120        except Exception:121            return None122        depth = 0123        while frame is not None and depth < max_depth:124            code = frame.f_code125            n_args = code.co_argcount + code.co_kwonlyargcount126            for name in code.co_varnames[:n_args]:127                value = frame.f_locals.get(name)128                if cls._looks_like_request(value):129                    return value130            frame = frame.f_back131            depth += 1132        return None133 134    def _configure_for_request(self, request) -> None:135        """Set initial streaming lane from the request's thinking flag."""136        self._state = "reasoning" if self._thinking_was_enabled(request) else "content"137        self._configured = True138 139    def _decode(self, ids: Sequence[int]) -> str:140        # `skip_special_tokens=False` is required: `</think>` may be tokenized141        # as (or contain) special tokens that the default decode would strip,142        # which would silently break marker detection.143        try:144            return self.model_tokenizer.decode(list(ids), skip_special_tokens=False)145        except Exception:146            return ""147 148    @property149    def reasoning_start_str(self) -> str | None:150        return START151 152    @property153    def reasoning_end_str(self) -> str | None:154        return END155 156    def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:157        return END in self._decode(input_ids)158 159    def is_reasoning_end_streaming(160        self, input_ids: Sequence[int], delta_ids: Iterable[int]161    ) -> bool:162        # Decode a 64-token tail window so the marker is detected even when163        # it straddles the previous-vs-delta token boundary (BPE may split164        # `</think>` across multiple tokens, especially around punctuation).165        tail = list(input_ids)[-64:]166        return END in self._decode(tail)167 168    def extract_content_ids(self, input_ids: list[int]) -> list[int]:169        text = self._decode(input_ids)170        idx = text.rfind(END)171        if idx < 0:172            return []173        try:174            return self.model_tokenizer.encode(175                text[idx + len(END):], add_special_tokens=False176            )177        except Exception:178            return []179 180    def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:181        text = self._decode(token_ids)182        idx = text.find(END)183        prefix = text if idx < 0 else text[:idx]184        try:185            return len(self.model_tokenizer.encode(prefix, add_special_tokens=False))186        except Exception:187            return 0188 189    def extract_reasoning(190        self,191        model_output: str,192        request: "ChatCompletionRequest | ResponsesRequest",193    ) -> tuple[str | None, str | None]:194        """Split a full (non-streaming) output into ``(reasoning, content)``.195 196        Returns ``(None, content)`` when the request has thinking disabled197        and the output contains no marker — the chat template pre-emits198        ``<think></think>`` in the prompt in that case, so a marker-less199        output is purely the answer.200        """201        # Configure streaming state as a side effect: a fork's serving layer202        # may call this before streaming starts, and we don't want the203        # streaming path to fall back to the `thinking=off` default if the204        # request actually had thinking enabled.205        if not self._configured:206            self._configure_for_request(request)207 208        s = model_output209        if s.startswith(START):210            s = s[len(START):]211        if END in s:212            reasoning, _, content = s.partition(END)213            return (reasoning.strip("\n") or None, content.lstrip("\n") or None)214        # No `</think>` in output: only treat the text as truncated reasoning215        # if we have positive evidence that thinking was enabled — otherwise216        # it is the final answer.217        if self._thinking_was_enabled(request):218            return (s.strip("\n") or None, None)219        return (None, s.lstrip("\n") or None)220 221    @staticmethod222    def _thinking_was_enabled(request) -> bool:223        """Whether ``request`` asked for reasoning to be emitted.224 225        Mirrors the chat template's own detection so the parser stays in226        lockstep with prompt construction: enabled iff227        ``chat_template_kwargs.enable_thinking`` (or ``.thinking``) is228        truthy, or any system message contains the literal ``"thinking on"``229        directive (case-insensitive).230        """231        kwargs = getattr(request, "chat_template_kwargs", None) or {}232        if kwargs.get("enable_thinking") or kwargs.get("thinking"):233            return True234        messages = getattr(request, "messages", None) or []235        for m in messages:236            role = m.get("role") if isinstance(m, dict) else getattr(m, "role", None)237            if role != "system":238                continue239            content = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)240            if isinstance(content, str) and "thinking on" in content.lower():241                return True242        return False243 244    def extract_reasoning_streaming(245        self,246        previous_text: str,247        current_text: str,248        delta_text: str,249        previous_token_ids: Sequence[int],250        current_token_ids: Sequence[int],251        delta_token_ids: Sequence[int],252    ) -> "DeltaMessage | None":253        """Emit one ``DeltaMessage`` per delta, routed to reasoning or content.254 255        The marker ``</think>`` is never emitted to the client. Trailing256        bytes of the buffer that *could* still grow into the marker on the257        next delta are held back, so the marker is never fragmented across258        deltas (e.g. ``</thi`` ... ``nk>``). When the marker is observed,259        pre-marker bytes go to the current lane and post-marker bytes go260        to ``content``; the lane is then locked to ``content``.261        """262        from vllm.entrypoints.openai.engine.protocol import DeltaMessage263 264        self._buffer += delta_text265 266        # Case 1 — marker fully present in the buffer: split and switch lane.267        # The pre-marker chunk stays on the *current* lane (reasoning if we268        # were inside <think>, content otherwise); the post-marker chunk269        # always goes to content; the lane is locked to content afterwards.270        idx = self._buffer.find(END)271        if idx >= 0:272            pre = self._buffer[:idx]273            post = self._buffer[idx + len(END):]274            self._buffer = ""275            pre_lane = self._state276            self._state = "content"277            if not pre and not post:278                return None279            fields: dict = {}280            if pre:281                fields[pre_lane] = pre282            if post:283                # `.get` covers the edge case where pre_lane is already284                # "content" and both pre and post are non-empty — they get285                # concatenated into a single content delta.286                fields["content"] = fields.get("content", "") + post287            return DeltaMessage(**fields)288 289        # Case 2 — no marker yet: release everything except a possible290        # partial-marker tail, which we retain for the next delta.291        held = _max_suffix_prefix(self._buffer, END)292        safe_end = len(self._buffer) - len(held)293        if safe_end == 0:294            return None295        chunk = self._buffer[:safe_end]296        self._buffer = self._buffer[safe_end:]297        return DeltaMessage(**{self._state: chunk})298