diff --git a/python/minisgl/message/frontend.py b/python/minisgl/message/frontend.py index d664ccf6..068c64e0 100644 --- a/python/minisgl/message/frontend.py +++ b/python/minisgl/message/frontend.py @@ -27,3 +27,4 @@ class UserReply(BaseFrontendMsg): uid: int incremental_output: str finished: bool + reasoning_output: str = "" diff --git a/python/minisgl/parser/__init__.py b/python/minisgl/parser/__init__.py new file mode 100644 index 00000000..9c6ebb05 --- /dev/null +++ b/python/minisgl/parser/__init__.py @@ -0,0 +1,12 @@ +""" +minisgl.parser +============== + +Top-level package for output-parsing utilities. + +Sub-packages +------------ +:mod:`minisgl.parser.reasoning` + Identify and split reasoning blocks (e.g. ````) from + model-generated text, in both streaming and non-streaming modes. +""" diff --git a/python/minisgl/parser/reasoning/__init__.py b/python/minisgl/parser/reasoning/__init__.py new file mode 100644 index 00000000..332f1762 --- /dev/null +++ b/python/minisgl/parser/reasoning/__init__.py @@ -0,0 +1,59 @@ +""" +minisgl.parser.reasoning +======================== + +Utilities for identifying and splitting reasoning blocks from model-generated +text. Supports both **non-streaming** (full-text) and **streaming** +(chunk-by-chunk) parsing. + +Quick start +----------- +:: + + from minisgl.parser.reasoning import ReasoningParser + + # Non-streaming + parser = ReasoningParser("qwen3") + result = parser.parse_full(full_text) + print(result.reasoning_text) # chain-of-thought + print(result.normal_text) # answer + + # Streaming + parser = ReasoningParser("qwen3") + for raw_chunk in token_stream: + sc = parser.parse_stream(raw_chunk) + ... + final = parser.flush() + +Public API +---------- +:class:`ReasoningParser` + Main entry point. Selects a detector by *model_type* and exposes + :meth:`~ReasoningParser.parse_full`, :meth:`~ReasoningParser.parse_stream`, + :meth:`~ReasoningParser.flush`, and :meth:`~ReasoningParser.stream_iter`. + +:class:`ParseResult` + Non-streaming result with ``reasoning_text`` and ``normal_text``. + +:class:`StreamChunk` + Streaming result with ``reasoning_delta`` and ``normal_delta``. + +:class:`BaseDetector` + ABC for custom detectors (subclass to add new reasoning formats). + +:class:`ThinkTagDetector` + Concrete detector for the ```` format used by + Qwen3-Thinking. +""" + +from .base import BaseDetector, ParseResult, StreamChunk +from .parser import ReasoningParser +from .think_tag import ThinkTagDetector + +__all__ = [ + "ReasoningParser", + "ParseResult", + "StreamChunk", + "BaseDetector", + "ThinkTagDetector", +] diff --git a/python/minisgl/parser/reasoning/base.py b/python/minisgl/parser/reasoning/base.py new file mode 100644 index 00000000..9704b8ae --- /dev/null +++ b/python/minisgl/parser/reasoning/base.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class ParseResult: + """Non-streaming parse output.""" + + reasoning_text: str + """Content extracted from inside the reasoning block.""" + + normal_text: str + """Remaining content outside the reasoning block.""" + + +@dataclass +class StreamChunk: + """Incremental output produced by a single :meth:`ReasoningParser.parse_stream` call.""" + + reasoning_delta: str + """New reasoning content decoded in this chunk (may be empty).""" + + normal_delta: str + """New normal content decoded in this chunk (may be empty).""" + + +class BaseDetector(ABC): + """ + Abstract base for reasoning-block detectors. + + A detector knows the start/end markers that delimit a reasoning block + and can parse both complete text (non-streaming) and incremental chunks + (streaming) through the helpers in :class:`ReasoningParser`. + + Concrete subclasses must implement :attr:`start_tag` and :attr:`end_tag`. + They may also override :meth:`parse_full` for model-specific logic. + """ + + @property + @abstractmethod + def start_tag(self) -> str: + """Opening delimiter of the reasoning block (e.g. ``""```).""" + + @property + @abstractmethod + def end_tag(self) -> str: + """Closing delimiter of the reasoning block (e.g. ``""```).""" + + def parse_full(self, text: str) -> ParseResult: + """ + Extract reasoning and normal text from a complete, fully-decoded + generation string (non-streaming path). + + The default implementation does a single forward scan for the + first occurrence of :attr:`start_tag` / :attr:`end_tag`. Subclasses + may override for more sophisticated (e.g. multi-block) handling. + """ + start = self.start_tag + end = self.end_tag + + start_idx = text.find(start) + if start_idx == -1: + return ParseResult(reasoning_text="", normal_text=text) + + content_start = start_idx + len(start) + end_idx = text.find(end, content_start) + + if end_idx == -1: + # Reasoning block was never closed – treat everything after the + # start tag as reasoning content and keep the preceding text as + # normal output. + return ParseResult( + reasoning_text=text[content_start:], + normal_text=text[:start_idx], + ) + + reasoning = text[content_start:end_idx] + normal = text[:start_idx] + text[end_idx + len(end):] + return ParseResult(reasoning_text=reasoning, normal_text=normal) diff --git a/python/minisgl/parser/reasoning/parser.py b/python/minisgl/parser/reasoning/parser.py new file mode 100644 index 00000000..d9856354 --- /dev/null +++ b/python/minisgl/parser/reasoning/parser.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from typing import Iterator + +from .base import BaseDetector, ParseResult, StreamChunk +from .think_tag import ThinkTagDetector + +# --------------------------------------------------------------------------- +# Model-type → detector mapping +# --------------------------------------------------------------------------- + +# Model types (lowercased, hyphens replaced by underscores) that are known +# to use the format. +_THINK_TAG_MODELS: frozenset[str] = frozenset( + { + "qwen3", + "qwen3_moe", + } +) + +# Prefixes that are also mapped to ThinkTagDetector even when not in the +# exact-match set above (handles future model variants automatically). +_THINK_TAG_PREFIXES: tuple[str, ...] = ("qwen3",) + + +# --------------------------------------------------------------------------- +# Streaming state machine +# --------------------------------------------------------------------------- + + +def _safe_prefix_len(buf: str, tag: str) -> int: + """ + Number of leading characters in *buf* that are guaranteed **not** to be + the beginning of an upcoming *tag*. + + We keep at most ``len(tag) - 1`` trailing characters buffered so that a + tag that is split across two successive chunks is never emitted early. + + Examples:: + + _safe_prefix_len("hello ") → 4 # "hell" is safe + _safe_prefix_len("hello", "") → 0 # all could match + _safe_prefix_len("hello world", "") → 5 + """ + return max(0, len(buf) - len(tag) + 1) + + +class _StreamingState: + """ + Internal stateful helper that processes incremental text chunks and + produces per-chunk ``(reasoning_delta, normal_delta)`` pairs. + + State machine + ------------- + ``before`` + The reasoning block has not started yet. Incoming text is treated as + normal output until the start tag is found. + + ``in_reasoning`` + We are inside the reasoning block. Incoming text is treated as + reasoning content until the end tag is found. + + ``after`` + The reasoning block has closed. All remaining text is normal output. + + Partial-tag handling + -------------------- + Because chunks can arrive mid-tag (e.g. ``""``), we + buffer the last ``len(tag) - 1`` characters in each state and only emit + characters that are definitively before any possible tag boundary. + """ + + def __init__(self, detector: BaseDetector) -> None: + self._detector = detector + self._state: str = "before" + self._buf: str = "" + + def feed(self, chunk: str) -> StreamChunk: + """ + Consume one text chunk and return the incremental reasoning / normal + content decoded so far. + + This method is **not** re-entrant; call it sequentially for each + streamed chunk and call :meth:`flush` once after the final chunk. + """ + reasoning_delta = "" + normal_delta = "" + + self._buf += chunk + + # ------------------------------------------------------------------ # + # Phase 1 – scan for the reasoning block start tag # + # ------------------------------------------------------------------ # + if self._state == "before": + start_tag = self._detector.start_tag + idx = self._buf.find(start_tag) + if idx != -1: + # Everything before the tag is normal text. + normal_delta += self._buf[:idx] + # Discard the tag itself and advance. + self._buf = self._buf[idx + len(start_tag):] + self._state = "in_reasoning" + else: + # Emit the safe prefix; keep a potential partial-tag suffix. + safe = _safe_prefix_len(self._buf, start_tag) + normal_delta += self._buf[:safe] + self._buf = self._buf[safe:] + + # ------------------------------------------------------------------ # + # Phase 2 – scan for the reasoning block end tag # + # (runs in the same call if the start tag was just found above) # + # ------------------------------------------------------------------ # + if self._state == "in_reasoning": + end_tag = self._detector.end_tag + idx = self._buf.find(end_tag) + if idx != -1: + reasoning_delta += self._buf[:idx] + self._buf = self._buf[idx + len(end_tag):] + self._state = "after" + else: + safe = _safe_prefix_len(self._buf, end_tag) + reasoning_delta += self._buf[:safe] + self._buf = self._buf[safe:] + + # ------------------------------------------------------------------ # + # Phase 3 – emit everything remaining as normal text # + # ------------------------------------------------------------------ # + if self._state == "after": + normal_delta += self._buf + self._buf = "" + + return StreamChunk(reasoning_delta=reasoning_delta, normal_delta=normal_delta) + + def flush(self) -> StreamChunk: + """ + Signal end-of-stream. + + Drains any text that was kept in the internal buffer to guard against + partial tags. Should be called **once** after the final chunk so that + callers never miss trailing content. + """ + remaining = self._buf + self._buf = "" + if self._state == "in_reasoning": + # Unclosed reasoning block – treat remainder as reasoning. + return StreamChunk(reasoning_delta=remaining, normal_delta="") + return StreamChunk(reasoning_delta="", normal_delta=remaining) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +class ReasoningParser: + """ + High-level reasoning-content parser. + + Selects the appropriate detector based on *model_type* and exposes a + unified interface for both **non-streaming** (single-call) and + **streaming** (chunk-by-chunk) use. + + Parameters + ---------- + model_type: + The ``model_type`` string from the HuggingFace ``config.json`` + (e.g. ``"qwen3"``). Pass ``None`` or ``"auto"`` + to use :class:`ThinkTagDetector` as a sensible default. + + Examples + -------- + Non-streaming:: + + parser = ReasoningParser("qwen3") + result = parser.parse_full(full_generated_text) + print(result.reasoning_text) # chain-of-thought + print(result.normal_text) # answer + + Streaming:: + + parser = ReasoningParser("qwen3") + for raw_chunk in token_stream: + chunk = parser.parse_stream(raw_chunk) + if chunk.reasoning_delta: + handle_reasoning(chunk.reasoning_delta) + if chunk.normal_delta: + handle_normal(chunk.normal_delta) + # drain remaining buffer + final = parser.flush() + if final.normal_delta: + handle_normal(final.normal_delta) + """ + + def __init__(self, model_type: str | None = None) -> None: + self._detector: BaseDetector = self._make_detector(model_type) + self._streaming_state = _StreamingState(self._detector) + + # ---------------------------------------------------------------------- # + # Non-streaming # + # ---------------------------------------------------------------------- # + + def parse_full(self, text: str) -> ParseResult: + """ + Parse a **complete** generation string. + + Returns a :class:`ParseResult` with ``reasoning_text`` (content + inside the reasoning block) and ``normal_text`` (everything else). + """ + return self._detector.parse_full(text) + + # ---------------------------------------------------------------------- # + # Streaming # + # ---------------------------------------------------------------------- # + + def parse_stream(self, chunk: str) -> StreamChunk: + """ + Feed one incremental text chunk from a streaming decode loop. + + Returns a :class:`StreamChunk` whose ``reasoning_delta`` and + ``normal_delta`` fields hold the content that can be safely attributed + to reasoning and normal output respectively for this chunk. + + Call :meth:`flush` after the **last** chunk to drain the internal + partial-tag buffer. + """ + return self._streaming_state.feed(chunk) + + def flush(self) -> StreamChunk: + """ + Signal end-of-stream and drain any buffered content. + + Must be called once after the final chunk to ensure no trailing text + is silently dropped. + """ + return self._streaming_state.flush() + + def stream_iter(self, chunks: Iterator[str]) -> Iterator[StreamChunk]: + """ + Convenience wrapper: iterate over *chunks* and yield one + :class:`StreamChunk` per input chunk, followed by the flush chunk. + + Usage:: + + for sc in parser.stream_iter(token_stream): + ... + """ + for chunk in chunks: + yield self.parse_stream(chunk) + final = self.flush() + if final.reasoning_delta or final.normal_delta: + yield final + + # ---------------------------------------------------------------------- # + # Internals # + # ---------------------------------------------------------------------- # + + @property + def detector(self) -> BaseDetector: + """The underlying :class:`BaseDetector` instance (read-only).""" + return self._detector + + @staticmethod + def _make_detector(model_type: str | None) -> BaseDetector: + """ + Resolve the detector for the given *model_type*. + + Resolution order + ---------------- + 1. ``None`` / ``"auto"`` → :class:`ThinkTagDetector` (safe default) + 2. Exact match in ``_THINK_TAG_MODELS`` set + 3. Prefix match against ``_THINK_TAG_PREFIXES`` + 4. Unrecognised model type → :class:`ThinkTagDetector` (safe fallback) + The caller is responsible for knowing whether the model actually + emits reasoning tags; an unknown type simply won't extract any + reasoning if no ```` tag is present. + """ + if model_type is None or model_type == "auto": + return ThinkTagDetector() + + normalized = model_type.lower().replace("-", "_") + + if normalized in _THINK_TAG_MODELS: + return ThinkTagDetector() + + if any(normalized.startswith(p) for p in _THINK_TAG_PREFIXES): + return ThinkTagDetector() + + # Unknown model type – fall back gracefully. + return ThinkTagDetector() diff --git a/python/minisgl/parser/reasoning/think_tag.py b/python/minisgl/parser/reasoning/think_tag.py new file mode 100644 index 00000000..1648dc55 --- /dev/null +++ b/python/minisgl/parser/reasoning/think_tag.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from .base import BaseDetector + + +class ThinkTagDetector(BaseDetector): + """ + Detector for the ```` reasoning format. + + Used by DeepSeek-R1, Qwen3-Thinking, QwQ, and other models that wrap + chain-of-thought content in ```` / ```` XML-like tags. + """ + + @property + def start_tag(self) -> str: + return "" + + @property + def end_tag(self) -> str: + return "" diff --git a/python/minisgl/server/api_server.py b/python/minisgl/server/api_server.py index 9318ccdd..0068afcd 100644 --- a/python/minisgl/server/api_server.py +++ b/python/minisgl/server/api_server.py @@ -167,6 +167,8 @@ async def stream_chat_completions(self, uid: int): first_chunk = False if ack.incremental_output: delta["content"] = ack.incremental_output + if ack.reasoning_output: + delta["reasoning_content"] = ack.reasoning_output chunk = { "id": f"cmpl-{uid}", diff --git a/python/minisgl/server/args.py b/python/minisgl/server/args.py index 3ec88f8d..77ec7379 100644 --- a/python/minisgl/server/args.py +++ b/python/minisgl/server/args.py @@ -17,6 +17,9 @@ class ServerArgs(SchedulerConfig): server_port: int = 1919 num_tokenizer: int = 0 silent_output: bool = False + # None → feature disabled + # str → model_type used to select detector (e.g. "qwen3", "deepseek_r1") + reasoning_parser: str | None = None @property def share_tokenizer(self) -> bool: @@ -223,6 +226,20 @@ def parse_args(args: List[str], run_shell: bool = False) -> Tuple[ServerArgs, bo help="Run the server in shell mode.", ) + parser.add_argument( + "--reasoning-parser", + dest="reasoning_parser", + nargs="?", + const="auto", # --reasoning-parser with no value → "auto" + default=None, # not given at all → None (disabled) + metavar="MODEL_TYPE", + help=( + "Enable reasoning-block parsing. " + "Optionally specify the model type (e.g. 'qwen3', 'deepseek_r1'). " + "Omit the value to auto-detect from the model config." + ), + ) + # Parse arguments kwargs = parser.parse_args(args).__dict__.copy() @@ -262,6 +279,13 @@ def parse_args(args: List[str], run_shell: bool = False) -> Tuple[ServerArgs, bo kwargs["tp_info"] = DistributedInfo(0, kwargs["tensor_parallel_size"]) del kwargs["tensor_parallel_size"] + # Resolve --reasoning-parser auto → actual model_type from HF config. + if kwargs.get("reasoning_parser") == "auto": + from minisgl.utils import cached_load_hf_config + + hf_cfg = cached_load_hf_config(kwargs["model_path"]) + kwargs["reasoning_parser"] = getattr(hf_cfg, "model_type", "auto") + result = ServerArgs(**kwargs) logger = init_logger(__name__) logger.info(f"Parsed arguments:\n{result}") diff --git a/python/minisgl/server/launch.py b/python/minisgl/server/launch.py index 2e9c2f10..11a9ac6f 100644 --- a/python/minisgl/server/launch.py +++ b/python/minisgl/server/launch.py @@ -81,6 +81,7 @@ def start_subprocess() -> None: "create": server_args.tokenizer_create_addr, "tokenizer_id": num_tokenizers, "ack_queue": ack_queue, + "reasoning_parser": server_args.reasoning_parser, }, daemon=False, name="minisgl-detokenizer-0", @@ -97,6 +98,7 @@ def start_subprocess() -> None: "create": server_args.tokenizer_create_addr, "tokenizer_id": i, "ack_queue": ack_queue, + "reasoning_parser": server_args.reasoning_parser, }, daemon=False, name=f"minisgl-tokenizer-{i}", diff --git a/python/minisgl/tokenizer/detokenize.py b/python/minisgl/tokenizer/detokenize.py index 9a7c006c..e1e412c6 100644 --- a/python/minisgl/tokenizer/detokenize.py +++ b/python/minisgl/tokenizer/detokenize.py @@ -1,7 +1,8 @@ from dataclasses import dataclass -from typing import Dict, List +from typing import Dict, List, Optional, Tuple from minisgl.message import DetokenizeMsg +from minisgl.parser.reasoning import ReasoningParser from transformers import PreTrainedTokenizerBase # Borrowed from sglang @@ -61,13 +62,21 @@ class DecodeStatus: class DetokenizeManager: - def __init__(self, tokenizer: PreTrainedTokenizerBase) -> None: + def __init__( + self, + tokenizer: PreTrainedTokenizerBase, + reasoning_parser_model_type: Optional[str] = None, + ) -> None: # uid -> DecodeStatus self.decode_map: Dict[int, DecodeStatus] = {} self.tokenizer = tokenizer self.eos_token_id = self.tokenizer.eos_token_id - def detokenize(self, msgs: List[DetokenizeMsg]) -> List[str]: + self._reasoning_model_type = reasoning_parser_model_type + # uid -> ReasoningParser (created lazily on first token for that uid) + self._reasoning_map: Dict[int, ReasoningParser] = {} + + def detokenize(self, msgs: List[DetokenizeMsg]) -> List[Tuple[str, str]]: read_ids: List[List[int]] = [] surr_ids: List[List[int]] = [] for msg in msgs: @@ -88,7 +97,8 @@ def detokenize(self, msgs: List[DetokenizeMsg]) -> List[str]: read_texts = self.tokenizer.batch_decode(read_ids) surr_texts = self.tokenizer.batch_decode(surr_ids) - incremental_strs: List[str] = [] + # List of (normal_delta, reasoning_delta) tuples + results: List[Tuple[str, str]] = [] for msg, read_str, surr_str in zip(msgs, read_texts, surr_texts, strict=True): s = self.decode_map[msg.uid] new_text = read_str[len(surr_str) :] @@ -104,8 +114,31 @@ def detokenize(self, msgs: List[DetokenizeMsg]) -> List[str]: incremental_output = output_str[s.sent_offset :] s.sent_offset = len(output_str) - incremental_strs.append(incremental_output) + + # ── Reasoning-parser split ──────────────────────────────────── # + normal_delta = incremental_output + reasoning_delta = "" + if self._reasoning_model_type is not None and incremental_output: + if msg.uid not in self._reasoning_map: + self._reasoning_map[msg.uid] = ReasoningParser( + self._reasoning_model_type + ) + rp = self._reasoning_map[msg.uid] + if msg.finished: + # Feed the last chunk then flush to drain the buffer. + sc = rp.parse_stream(incremental_output) + fl = rp.flush() + normal_delta = sc.normal_delta + fl.normal_delta + reasoning_delta = sc.reasoning_delta + fl.reasoning_delta + else: + sc = rp.parse_stream(incremental_output) + normal_delta = sc.normal_delta + reasoning_delta = sc.reasoning_delta + + results.append((normal_delta, reasoning_delta)) + if msg.finished: del self.decode_map[msg.uid] + self._reasoning_map.pop(msg.uid, None) - return incremental_strs + return results diff --git a/python/minisgl/tokenizer/server.py b/python/minisgl/tokenizer/server.py index c2d0e572..ccb77c8e 100644 --- a/python/minisgl/tokenizer/server.py +++ b/python/minisgl/tokenizer/server.py @@ -39,6 +39,7 @@ def tokenize_worker( tokenizer_id: int = -1, model_source: str = "huggingface", ack_queue: mp.Queue[str] | None = None, + reasoning_parser: str | None = None, ) -> None: send_backend = ZmqPushQueue(backend_addr, create=False, encoder=BaseBackendMsg.encoder) send_frontend = ZmqPushQueue(frontend_addr, create=False, encoder=BaseFrontendMsg.encoder) @@ -51,7 +52,9 @@ def tokenize_worker( from .tokenize import TokenizeManager tokenize_manager = TokenizeManager(tokenizer) - detokenize_manager = DetokenizeManager(tokenizer) + detokenize_manager = DetokenizeManager( + tokenizer, reasoning_parser_model_type=reasoning_parser + ) if ack_queue is not None: ack_queue.put(f"Tokenize server {tokenizer_id} is ready") @@ -74,10 +77,13 @@ def tokenize_worker( data=[ UserReply( uid=msg.uid, - incremental_output=reply, + incremental_output=normal_delta, finished=msg.finished, + reasoning_output=reasoning_delta, + ) + for msg, (normal_delta, reasoning_delta) in zip( + detokenize_msg, replies, strict=True ) - for msg, reply in zip(detokenize_msg, replies, strict=True) ] ) if len(batch_output.data) == 1: diff --git a/tests/misc/test_reasoning_parser.py b/tests/misc/test_reasoning_parser.py new file mode 100644 index 00000000..9a69c30e --- /dev/null +++ b/tests/misc/test_reasoning_parser.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +test_reasoning_parser.py +Send several requests to a running mini-sglang engine (with --reasoning-parser enabled), +to verify whether reasoning_content / content are split correctly. + +Usage: + python test_reasoning_parser.py [--host 127.0.0.1] [--port 1919] + +Dependency: requests (install via: pip install requests) +""" +from __future__ import annotations + +import argparse +import json +import sys +import textwrap +from typing import Iterator + +import requests + +RESET = "\033[0m" +BOLD = "\033[1m" +CYAN = "\033[36m" +YELLOW = "\033[33m" +GREEN = "\033[32m" +RED = "\033[31m" +DIM = "\033[2m" + +def _c(text: str, *codes: str) -> str: + if sys.stdout.isatty(): + return "".join(codes) + text + RESET + return text + +def _iter_sse_chunks(resp: requests.Response) -> Iterator[dict]: + """Parse text/event-stream line by line and yield JSON chunks.""" + for raw_line in resp.iter_lines(): + if isinstance(raw_line, bytes): + raw_line = raw_line.decode() + line = raw_line.strip() + if not line or not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload == "[DONE]": + break + try: + yield json.loads(payload) + except json.JSONDecodeError: + continue + +# ────────────────────────────────────────────────────────────────────────────── +# Single Request +# ────────────────────────────────────────────────────────────────────────────── +def chat_stream( + base_url: str, + messages: list[dict], + model: str, + max_tokens: int = 1024, + temperature: float = 0.6, +) -> tuple[str, str]: + """ + Send a streaming /v1/chat/completions request. + Returns (reasoning_text, normal_text) as complete strings. + """ + url = f"{base_url}/v1/chat/completions" + payload = { + "model": model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "stream": True, + } + + reasoning_buf = "" + normal_buf = "" + + with requests.post(url, json=payload, stream=True, timeout=120) as resp: + resp.raise_for_status() + for chunk in _iter_sse_chunks(resp): + choices = chunk.get("choices", []) + if not choices: + continue + delta = choices[0].get("delta", {}) + reasoning_buf += delta.get("reasoning_content") or "" + normal_buf += delta.get("content") or "" + + return reasoning_buf, normal_buf + +_W = 72 + +def _hr(char: str = "─") -> str: + return _c(char * _W, DIM) + +def _section(title: str) -> None: + print() + print(_c(f"{'─' * 3} {title} {'─' * (_W - 5 - len(title))}", BOLD + CYAN)) + +def _print_result(reasoning: str, normal: str) -> None: + if reasoning: + print(_c(" [reasoning_content]", YELLOW)) + wrapped = textwrap.fill(reasoning.strip(), width=_W - 4, + initial_indent=" ", subsequent_indent=" ") + print(_c(wrapped, YELLOW)) + else: + print(_c(" [reasoning_content] (Empty — model might not have output tags)", DIM)) + + print() + print(_c(" [content]", GREEN)) + wrapped = textwrap.fill(normal.strip() or "(Empty)", width=_W - 4, + initial_indent=" ", subsequent_indent=" ") + print(_c(wrapped, GREEN)) + +def _check(label: str, cond: bool) -> None: + icon = _c("✓", GREEN + BOLD) if cond else _c("✗", RED + BOLD) + print(f" {icon} {label}") + +CASES: list[dict] = [ + { + "name": "Simple Math Reasoning", + "messages": [ + {"role": "user", "content": "Which is larger, 9.11 or 9.9? Please think carefully before answering."}, + ], + "max_tokens": 4096, + }, + { + "name": "Logic Puzzle", + "messages": [ + { + "role": "user", + "content": ( + "There are 3 light bulbs in a room and 3 switches outside. " + "Each switch controls one bulb. You can only enter the room once. " + "How do you determine which switch controls which bulb?" + ), + } + ], + "max_tokens": 4096, + }, + { + "name": "Code Generation", + "messages": [ + { + "role": "user", + "content": "Write a concise Python function to check if a number is prime.", + } + ], + "max_tokens": 4096, + }, +] + +# ────────────────────────────────────────────────────────────────────────────── +# Main Flow +# ────────────────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser(description="Test mini-sglang reasoning parser") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=1919, type=int) + parser.add_argument("--max-tokens", default=None, type=int, + help="Override max_tokens for all cases") + parser.add_argument("--temperature", default=0.6, type=float) + args = parser.parse_args() + + base_url = f"http://{args.host}:{args.port}" + + print(_c(f"\nmini-sglang reasoning-parser test script", BOLD)) + print(_c(f"Target: {base_url}", DIM)) + print(_hr()) + + try: + r = requests.get(f"{base_url}/v1/models", timeout=5) + r.raise_for_status() + model_name = r.json()["data"][0]["id"] + print(f" Service Online model = {_c(model_name, BOLD)}") + except Exception as exc: + print(_c(f" ✗ Cannot connect to server: {exc}", RED)) + sys.exit(1) + + passed = 0 + failed = 0 + + for i, case in enumerate(CASES, 1): + max_tokens = args.max_tokens or case.get("max_tokens", 512) + _section(f"[{i}/{len(CASES)}] {case['name']}") + + user_msg = case["messages"][-1]["content"] + print(_c(f" Q: {textwrap.shorten(user_msg, 80)}", DIM)) + print() + + try: + reasoning, normal = chat_stream( + base_url, + case["messages"], + model=model_name, + max_tokens=max_tokens, + temperature=args.temperature, + ) + except requests.HTTPError as exc: + print(_c(f" HTTP Error: {exc}", RED)) + failed += 1 + continue + except Exception as exc: + print(_c(f" Request Failed: {exc}", RED)) + failed += 1 + continue + + _print_result(reasoning, normal) + print() + + _check("Received non-empty output", bool(reasoning or normal)) + _check("reasoning_content is not empty (think tags identified)", bool(reasoning)) + _check("content is not empty (normal response preserved)", bool(normal)) + _check("reasoning_content does not contain ", "" not in reasoning) + _check("content does not contain ", "" not in normal) + + case_ok = all([bool(reasoning or normal), bool(reasoning), bool(normal), + "" not in reasoning, "" not in normal]) + if case_ok: + passed += 1 + else: + failed += 1 + + print() + print(_hr("═")) + total = passed + failed + summary_color = GREEN if failed == 0 else RED + print(_c(f" Result: {passed}/{total} passed", BOLD + summary_color)) + if failed > 0: + print(_c( + "\n Tip: If reasoning_content is empty, ensure:\n" + " 1. The engine was started with --reasoning-parser\n" + " 2. You are using a reasoning model (e.g., Qwen-R1, DeepSeek-R1)\n" + " 3. Temperature > 0 (temperature=0 might cause the model to skip )", + YELLOW, + )) + print() + +if __name__ == "__main__": + main() \ No newline at end of file