diff --git a/python/freetoken/core.py b/python/freetoken/core.py index a7811ed712..bbba592378 100644 --- a/python/freetoken/core.py +++ b/python/freetoken/core.py @@ -54,6 +54,9 @@ class SamplingParams: # reports the chosen token's raw (pre-temperature) logprob and top-k alternatives. logprobs: bool = False top_logprobs: int = 0 + # Set by the tokenizer worker when the rendered prompt leaves a block open, so + # the detokenizer counts an unclosed generation as reasoning tokens. + starts_in_reasoning: bool = False @property def is_greedy(self) -> bool: diff --git a/python/freetoken/engine/sample.py b/python/freetoken/engine/sample.py index 9d12b5f571..62bdd29cc8 100644 --- a/python/freetoken/engine/sample.py +++ b/python/freetoken/engine/sample.py @@ -57,7 +57,10 @@ class BatchSamplingArgs: greedy_mask: torch.Tensor | None = None penalties: list[RowPenalty] = field(default_factory=list) plan: LogitsPlan | None = None - logprob_rows: torch.Tensor | None = None + # Rows that asked for logprobs, as an int64 device index built on the host (no + # nonzero sync). None with logprob_all_rows set when every row asks: no gather then. + logprob_row_idx: torch.Tensor | None = None + logprob_all_rows: bool = False max_top_logprobs: int = 0 @@ -244,9 +247,12 @@ def prepare(self, batch: Batch) -> BatchSamplingArgs: ) plan = self._plan(batch, params) want_logprobs = [p.logprobs for p in params] - logprob_rows = ( - make_device_tensor(want_logprobs, torch.bool, self.device) - if any(want_logprobs) + logprob_all_rows = bool(want_logprobs) and all(want_logprobs) + logprob_row_idx = ( + make_device_tensor( + [i for i, want in enumerate(want_logprobs) if want], torch.int64, self.device + ) + if any(want_logprobs) and not logprob_all_rows else None ) max_top_logprobs = min( @@ -257,7 +263,8 @@ def prepare(self, batch: Batch) -> BatchSamplingArgs: temperatures=None, penalties=penalties, plan=plan, - logprob_rows=logprob_rows, + logprob_row_idx=logprob_row_idx, + logprob_all_rows=logprob_all_rows, max_top_logprobs=max_top_logprobs, ) @@ -289,7 +296,8 @@ def prepare(self, batch: Batch) -> BatchSamplingArgs: greedy_mask=greedy_mask, penalties=penalties, plan=plan, - logprob_rows=logprob_rows, + logprob_row_idx=logprob_row_idx, + logprob_all_rows=logprob_all_rows, max_top_logprobs=max_top_logprobs, ) @@ -324,46 +332,50 @@ def compute_logprobs( sampled_tokens: torch.Tensor, args: BatchSamplingArgs, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None: - if args.logprob_rows is None: - return None - - requested_rows = torch.nonzero(args.logprob_rows, as_tuple=False).flatten() - if requested_rows.numel() == 0: + rows = args.logprob_row_idx + if not args.logprob_all_rows and (rows is None or rows.numel() == 0): return None - request_logits = logits.index_select(0, requested_rows).float() + request_logits = (logits if rows is None else logits.index_select(0, rows)).float() # Reported values are raw model logprobs (pre-temperature log_softmax over logits). request_logprobs = torch.log_softmax(request_logits, dim=-1) - request_tokens = sampled_tokens.to(dtype=torch.long, device=logits.device).index_select( - 0, requested_rows - ) - request_row_idx = torch.arange(requested_rows.numel(), device=logits.device) + request_tokens = sampled_tokens.to(dtype=torch.long, device=logits.device) + if rows is not None: + request_tokens = request_tokens.index_select(0, rows) + request_row_idx = torch.arange(request_logprobs.shape[0], device=logits.device) request_chosen_logprobs = request_logprobs[request_row_idx, request_tokens] - chosen_logprobs = torch.full( - (logits.shape[0],), float("nan"), dtype=torch.float32, device=logits.device - ) - chosen_logprobs.index_copy_(0, requested_rows, request_chosen_logprobs) + if rows is None: + chosen_logprobs = request_chosen_logprobs + else: + chosen_logprobs = torch.full( + (logits.shape[0],), float("nan"), dtype=torch.float32, device=logits.device + ) + chosen_logprobs.index_copy_(0, rows, request_chosen_logprobs) if args.max_top_logprobs > 0: request_top_logprobs, request_top_ids = torch.topk( request_logprobs, k=args.max_top_logprobs, dim=-1 ) - top_ids = torch.full( - (logits.shape[0], args.max_top_logprobs), - -1, - dtype=torch.int32, - device=logits.device, - ) - top_logprobs = torch.full( - (logits.shape[0], args.max_top_logprobs), - float("-inf"), - dtype=torch.float32, - device=logits.device, - ) - top_ids[requested_rows] = request_top_ids.to(torch.int32) - top_logprobs[requested_rows] = request_top_logprobs + if rows is None: + top_ids = request_top_ids.to(torch.int32) + top_logprobs = request_top_logprobs + else: + top_ids = torch.full( + (logits.shape[0], args.max_top_logprobs), + -1, + dtype=torch.int32, + device=logits.device, + ) + top_logprobs = torch.full( + (logits.shape[0], args.max_top_logprobs), + float("-inf"), + dtype=torch.float32, + device=logits.device, + ) + top_ids[rows] = request_top_ids.to(torch.int32) + top_logprobs[rows] = request_top_logprobs else: top_ids = torch.empty((logits.shape[0], 0), dtype=torch.int32, device=logits.device) top_logprobs = torch.empty((logits.shape[0], 0), dtype=torch.float32, device=logits.device) diff --git a/python/freetoken/message/frontend.py b/python/freetoken/message/frontend.py index d70453e910..35620c7e8e 100644 --- a/python/freetoken/message/frontend.py +++ b/python/freetoken/message/frontend.py @@ -62,9 +62,13 @@ class UserReply(BaseFrontendMsg): # On the finished reply: tokens generated up to and including the reasoning end tag # (0 when the model emitted none) -> usage.completion_tokens_details.reasoning_tokens. reasoning_tokens: int = 0 - # Neutral sampled-token logprobs entry for this token (see - # tokenizer.detokenize.build_logprobs_entry); None when the request did not ask. - logprobs: dict | None = None + # Neutral sampled-token logprobs entries released with this reply (see + # tokenizer.detokenize.build_logprobs_entry), normally this token's own; None when + # there are none. Each entry's "text_offset" is where its token starts in the generated + # text. With stop strings an entry waits for its token's text, and entries + # of tokens a matched stop string trimmed arrive marked "stop_trimmed"; the token it + # ends inside carries "stop_visible", the length of its text that was released. + logprobs: list[dict] | None = None @dataclass diff --git a/python/freetoken/message/tokenizer.py b/python/freetoken/message/tokenizer.py index 1b3b49eeeb..b5c24a6e10 100644 --- a/python/freetoken/message/tokenizer.py +++ b/python/freetoken/message/tokenizer.py @@ -40,6 +40,8 @@ class DetokenizeMsg(BaseTokenizerMsg): keep_stop_str: bool = False # Decode this request with skip_special_tokens (SamplingParams.skip_special_tokens). skip_special_tokens: bool = False + # The prompt left a reasoning block open (SamplingParams.starts_in_reasoning). + starts_in_reasoning: bool = False # Sampled-token logprobs (None unless the request asked): the chosen token's raw # logprob and the top alternatives, already cut to this request's top_logprobs. chosen_logprob: float | None = None diff --git a/python/freetoken/scheduler/scheduler.py b/python/freetoken/scheduler/scheduler.py index cb6085d344..358ce31355 100644 --- a/python/freetoken/scheduler/scheduler.py +++ b/python/freetoken/scheduler/scheduler.py @@ -414,6 +414,7 @@ def _process_last_data(self, last_data: ForwardData | None) -> None: stop_strs=req.sampling_params.stop_strs or None, keep_stop_str=req.sampling_params.include_stop_str_in_output, skip_special_tokens=req.sampling_params.skip_special_tokens, + starts_in_reasoning=req.sampling_params.starts_in_reasoning, prefill_ms=( 0.0 if started_at is None else (time.monotonic() - started_at) * 1000.0 diff --git a/python/freetoken/server/api_models.py b/python/freetoken/server/api_models.py index 21b1d1f860..0da3d09e77 100644 --- a/python/freetoken/server/api_models.py +++ b/python/freetoken/server/api_models.py @@ -77,9 +77,9 @@ class ChatCompletionRequest(BaseModel): presence_penalty: float = Field(default=0.0, ge=-2.0, le=2.0, allow_inf_nan=False) frequency_penalty: float = Field(default=0.0, ge=-2.0, le=2.0, allow_inf_nan=False) # Sampled-token logprobs (OpenAI chat semantics): top_logprobs (0..20) requires - # logprobs=true; entries cover generated tokens only (no prompt logprobs). - # Fail-closed (400) when a reasoning parser or tool parsing is active: entries - # must align 1:1 with visible content tokens (see _chat_logprobs_conflict). + # logprobs=true; entries cover message-content tokens only (no prompt, reasoning or + # tool-call tokens). 400 for parser families whose hidden tokens the segmenter + # cannot tell apart from content (see _chat_logprobs_conflict). logprobs: bool = False top_logprobs: int | None = None chat_template_kwargs: dict[str, Any] = Field(default_factory=dict) diff --git a/python/freetoken/server/function_call_parser.py b/python/freetoken/server/function_call_parser.py index 88cc65b367..e45acea77b 100644 --- a/python/freetoken/server/function_call_parser.py +++ b/python/freetoken/server/function_call_parser.py @@ -261,6 +261,11 @@ class BaseFormatDetector(ABC): # bot_token is a parse trigger, not a uniqueness claim. toolcall_opener: str | None = None + # True when detect_and_parse replays the streaming state machine: its normal_text + # is then already every piece of text the stream emits (before, between and after + # calls), so parse_non_stream must return it as is. + one_shot_replays_stream = False + def __init__(self): # Streaming state management # Buffer for accumulating incomplete patterns that arrive across multiple streaming chunks @@ -778,6 +783,12 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami +# A bare invoke opener's name: bounded so a literal "]{1,128})>\s*") +_PS_BARE_NAME_PARTIAL_RE = re.compile(r"[^\s<>]{0,128}") + + class InvokeParamStreamMixin: """Value-level streaming for invoke/parameter block formats (qwen3_coder, minimax — the same shape vLLM's ParserEngine covers): text outside blocks @@ -793,6 +804,13 @@ class InvokeParamStreamMixin: _ps_trim_single True: at most ONE leading/trailing trim char (qwen3_coder) _ps_missing_type schema type assumed for undeclared params ("string" streams, "loose" buffers and loose-parses — per-family legacy typing) + _ps_trim_after_block True: whitespace after a closed block is held, then + dropped if another block or the end follows, kept if + text does -- the same for any chunking (qwen3_coder) + _ps_bare_invoke True: an invoke without the wrapper block is a call when it + names a declared tool and its body starts (qwen3_coder) + _ps_literal_open True: a block opener whose next non-whitespace text cannot + start an invoke or the block close is text (qwen3_coder) """ _ps_outer_open: str = "" @@ -800,9 +818,15 @@ class InvokeParamStreamMixin: _ps_trim: str = "\n" _ps_trim_single: bool = False _ps_missing_type: str = "string" + _ps_trim_after_block: bool = False + _ps_bare_invoke: bool = False + _ps_literal_open: bool = False def _ps_reset(self) -> None: self._ps_mode = "idle" + self._ps_after_block = False + self._ps_bare = False + self._ps_after_bare = False self._ps_key = "" self._ps_lead = "{" self._ps_emitted_any = False @@ -829,6 +853,58 @@ def _ps_trim_trailing(self, text: str) -> str: return text[:-1] if text[-1:] and text[-1:] in self._ps_trim else text return text.rstrip(self._ps_trim) + def _ps_bare_status(self, text: str) -> str: + """Classify the bare invoke opener ``text`` starts with: "call", "partial" + (may still become one) or "literal". A declared name is required -- not + the unknown-tool forwarding a wrapped block gets -- and the body must + start, so the format quoted in prose or code stays text.""" + rest = text[len(self._ps_invoke_open_prefix):] + m = _PS_BARE_NAME_RE.match(rest) + if m is None: + return "partial" if _PS_BARE_NAME_PARTIAL_RE.fullmatch(rest) else "literal" + if m.group(1) not in self._tool_indices: + return "literal" + body = rest[m.end():] + for tok in (self._ps_param_open_prefix, self._ps_invoke_close): + if body.startswith(tok): + return "call" + if tok.startswith(body): + return "partial" + return "literal" + + def _ps_find_bare(self, buf: str, limit: int) -> Tuple[int, str]: + """First bare invoke opener before ``limit`` that is not literal.""" + i = buf.find(self._ps_invoke_open_prefix) + while i != -1 and i < limit: + status = self._ps_bare_status(buf[i:]) + if status != "literal": + return i, status + i = buf.find(self._ps_invoke_open_prefix, i + 1) + return -1, "" + + def _ps_opens_block(self, text: str) -> str: + """Whether ``text`` starts a block: "yes", "no" or "maybe" (too short to tell).""" + if text.startswith(self._ps_outer_open): + if not self._ps_literal_open: + return "yes" + body = text[len(self._ps_outer_open):].lstrip() + # A repeated opener is a stutter: the block ignores it, as before. + for tok in (self._ps_invoke_open_prefix, self._ps_outer_close, self._ps_outer_open): + if body.startswith(tok): + return "yes" + if tok.startswith(body): + return "maybe" + return "no" + if self._ps_outer_open.startswith(text): + return "maybe" + if self._ps_bare_invoke: + prefix = self._ps_invoke_open_prefix + if text.startswith(prefix): + return {"call": "yes", "partial": "maybe"}.get(self._ps_bare_status(text), "no") + if prefix.startswith(text): + return "maybe" + return "no" + def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult: self._buffer += new_text if not hasattr(self, "_tool_indices"): @@ -866,9 +942,42 @@ def _update_prev() -> None: if mode == "idle": if calls: break # text after a call defers to the next step (wire order) + if self._ps_after_block: + rest = buf.lstrip() + if self._ps_after_bare: + if rest.startswith(self._ps_outer_close): + # The model dropped the opener but kept the closer. + self._ps_after_bare = False + self._buffer = rest[len(self._ps_outer_close):] + continue + if self._ps_outer_close.startswith(rest): + break + self._ps_after_bare = False + opens = self._ps_opens_block(rest) + if opens == "maybe": + break # a separator or a text gap: undecided until more arrives + self._ps_after_block = False + if opens == "yes": + buf = self._buffer = rest pos = buf.find(self._ps_outer_open) + if self._ps_bare_invoke: + bare, status = self._ps_find_bare(buf, len(buf) if pos == -1 else pos) + if bare > 0: + normal_parts.append(buf[:bare]) + self._buffer = buf[bare:] + continue + if bare == 0: + if status == "partial": + break + self._ps_bare = True + self._ps_mode = "block" # parses the invoke at the buffer head + continue if pos == -1: hold = self._ends_with_partial_token(buf, self._ps_outer_open) + if self._ps_bare_invoke: + hold = max( + hold, self._ends_with_partial_token(buf, self._ps_invoke_open_prefix) + ) release = buf[: len(buf) - hold] if hold else buf if release: normal_parts.append(release) @@ -878,7 +987,13 @@ def _update_prev() -> None: normal_parts.append(buf[:pos]) self._buffer = buf[pos:] continue + opens = self._ps_opens_block(buf) + if opens == "maybe": + break self._buffer = buf[len(self._ps_outer_open):] + if opens == "no": + normal_parts.append(self._ps_outer_open) # the tag named in prose + continue self._ps_mode = "block" continue @@ -888,6 +1003,7 @@ def _update_prev() -> None: if close != -1 and (inv == -1 or close < inv): self._buffer = buf[close + len(self._ps_outer_close):] self._ps_mode = "idle" + self._ps_after_block = self._ps_trim_after_block continue if inv != -1: m = self._ps_invoke_open_re.search(buf, inv) @@ -939,7 +1055,14 @@ def _update_prev() -> None: while len(self.streamed_args_for_tool) <= self.current_tool_id: self.streamed_args_for_tool.append("") self._buffer = buf[e + len(self._ps_invoke_close):] - self._ps_mode = "block" + if self._ps_bare: + # A bare invoke is its own block: back to text after it. + self._ps_bare = False + self._ps_mode = "idle" + self._ps_after_block = self._ps_trim_after_block + self._ps_after_bare = True + else: + self._ps_mode = "block" continue if p != -1: m = self._ps_param_open_re.search(buf, p) @@ -1023,6 +1146,8 @@ def _update_prev() -> None: def finish_streaming(self) -> str: residual, self._buffer = self._buffer, "" mode = getattr(self, "_ps_mode", "idle") + if getattr(self, "_ps_after_block", False) and not residual.strip(): + residual = "" self._ps_reset() if mode != "idle" or (self._ps_outer_open and self._ps_outer_open in residual): return "" @@ -2016,9 +2141,13 @@ def finish_streaming(self) -> str: class Qwen3CoderDetector(InvokeParamStreamMixin, BaseFormatDetector): toolcall_opener = "" + one_shot_replays_stream = True _ps_trim = "\n" _ps_trim_single = True _ps_missing_type = "string" + _ps_trim_after_block = True + _ps_bare_invoke = True + _ps_literal_open = True """ Detector for Qwen3-Coder XML-style function call format. @@ -2052,8 +2181,6 @@ def __init__(self): self.tool_call_separator = "\n" # Regex patterns - self.tool_call_block_regex = re.compile(r"(.*?)", re.DOTALL) - self.function_regex = re.compile(r"||(?=)|$)", re.DOTALL ) @@ -2063,7 +2190,8 @@ def __init__(self): self._ps_outer_open = "" self._ps_outer_close = "" self._ps_invoke_open_prefix = "]*)>") + # No "<" in a name: an opener cut off before its ">" must not run into . + self._ps_invoke_open_re = re.compile(r"]*)>") self._ps_invoke_close = "" self._ps_param_open_prefix = "]*)>") @@ -2074,44 +2202,6 @@ def has_tool_call(self, text: str) -> bool: return " Optional[ToolCallItem]: - """Parse a single ... block into a ToolCallItem.""" - try: - end_index = function_str.index(">") - except ValueError: - return None - - func_name = function_str[:end_index].strip() - tool_indices = self._get_tool_indices(tools) - if func_name not in tool_indices and not _should_forward_unknown_tool(func_name): - logger.warning(f"Model attempted to call undefined function: {func_name}") - return None - - parameters_text = function_str[end_index + 1 :] - param_config = self._get_param_config(func_name, tools) - param_dict = {} - - for match in self.parameter_regex.findall(parameters_text): - try: - idx = match.index(">") - except ValueError: - continue - param_name = match[:idx].strip() - param_value = match[idx + 1 :] - # Strip leading/trailing newlines from value - if param_value.startswith("\n"): - param_value = param_value[1:] - if param_value.endswith("\n"): - param_value = param_value[:-1] - - param_dict[param_name] = self._convert_param_value(param_value, param_name, param_config, func_name) - - return ToolCallItem( - tool_index=tool_indices.get(func_name, 0), - name=func_name, - parameters=json.dumps(param_dict, ensure_ascii=False), - ) - def _build_partial_arguments_json(self, func_name: str, partial_body: str, tools: List[Tool]) -> Optional[str]: """Build the current argument JSON from a partial XML tool-call body.""" param_matches = self.parameter_regex.findall(partial_body) @@ -2155,28 +2245,38 @@ def _build_partial_arguments_json(self, func_name: str, partial_body: str, tools return json.dumps(param_dict, ensure_ascii=False) def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult: - idx = text.find(self.bot_token) - normal_text = text[:idx].strip() if idx != -1 else text - - if " StreamingParseResult: if not self.tools: return StreamingParseResult(normal_text=full_text, calls=[]) parsed_result = self.detector.detect_and_parse(full_text, self.tools) + if self.detector.one_shot_replays_stream: + return parsed_result tool_call_list = parsed_result.calls if tool_call_list: # Keep text after the LAST tool block: the streaming path emits it, so diff --git a/python/freetoken/server/generation.py b/python/freetoken/server/generation.py index 00d94abebf..e2d4d343a4 100644 --- a/python/freetoken/server/generation.py +++ b/python/freetoken/server/generation.py @@ -37,8 +37,16 @@ _TemplateError = () from .function_call_parser import FunctionCallParser, TOOLS_TAG_LIST, ToolCallItem +from .logprobs import ( + LogprobMarkers, + LogprobSegmenter, + resolve_logprob_markers, + untrimmed, +) from .reasoning_parser import ( DSV4_SPECIAL_TOKENS, + THINK_END_TOKEN, + THINK_START_TOKEN, ReasoningParser, build_reasoning_parser, strip_special_tokens, @@ -386,6 +394,9 @@ class GenDone: cached_tokens: int = 0 reasoning_tokens: int = 0 timings: GenTimings = field(default_factory=GenTimings) + # Content logprob entries no content delta carried (text held to the end, or + # dropped as a trailing separator); None when there are none. + logprobs: list[dict] | None = None GenEvent = ReasoningDelta | ContentDelta | ToolCallStart | ToolCallArgsDelta | ToolCallsDelta | GenDone @@ -403,8 +414,8 @@ class GenResult: cached_tokens: int = 0 reasoning_tokens: int = 0 timings: GenTimings = field(default_factory=GenTimings) - # Neutral sampled-token logprob entries, one per sampled token (empty when the - # request did not ask). + # Neutral logprob entries of the message-content tokens (empty when the request did + # not ask). logprobs: list[dict] = field(default_factory=list) @@ -740,8 +751,13 @@ async def prerender_error(spec: GenSpec, state: Any) -> GenerationError | None: def _make_reasoning_parser(spec: GenSpec, state: Any) -> ReasoningParser | None: """Build a reasoning parser for this generation, or None if the server has no - reasoning parser configured. ``force_reasoning`` matches the encode-side - thinking mode so chat-mode content is never mislabeled as reasoning.""" + reasoning parser configured.""" + return build_reasoning_parser(state.config, _force_reasoning(spec, state)) + + +def _force_reasoning(spec: GenSpec, state: Any) -> bool: + """Whether the generation starts inside reasoning: matches the encode-side thinking + mode so chat-mode content is never mislabeled as reasoning.""" parser_name = getattr(state.config, "reasoning_parser", None) if parser_name == "qwen3": # The qwen3 chat template opens an implicit (thinking on) unless @@ -776,7 +792,7 @@ def _make_reasoning_parser(spec: GenSpec, state: Any) -> ReasoningParser | None: force_reasoning = ( resolve_thinking_mode(spec.chat_template_kwargs, spec.template_tools) == "thinking" ) - return build_reasoning_parser(state.config, force_reasoning) + return force_reasoning def _split_reasoning(text: str, spec: GenSpec, state: Any) -> tuple[str, str]: @@ -817,11 +833,75 @@ def _parse_tool_response( return None parser = _make_tool_parser(spec, state) result = parser.parse_non_stream(text) - if not result.calls: + # A detector that replays its stream can drop markup without producing a call + # (an empty block): the one-shot content must drop it too. + if not result.calls and result.normal_text == text: return None return result.normal_text, result.calls +_UNRESOLVED = object() + + +async def chat_logprob_markers(state: Any) -> LogprobMarkers | None: + """The token ids chat logprobs are segmented on, resolved once per server from the + frontend tokenizer; None when this server has none.""" + cached = getattr(state, "_logprob_markers", _UNRESOLVED) + if cached is not _UNRESOLVED: + return cached + markers = None + build = getattr(state, "frontend_tokenizer", None) + if build is not None: + try: + manager = await asyncio.to_thread(build) + hidden = _QWEN_SEMANTIC_HIDDEN_TOKENS if _uses_qwen_semantic_protocol(state) else () + markers = await asyncio.to_thread( + resolve_logprob_markers, + manager.tokenizer, THINK_START_TOKEN, THINK_END_TOKEN, hidden, + ) + except Exception: # noqa: BLE001 -- server fault: retried on the next request + return None + state._logprob_markers = markers + return markers + + +def chat_logprobs_supported(state: Any, parse_tools: bool, markers: LogprobMarkers | None) -> bool: + """Whether chat logprobs can be confined to message content for this server's parsers: + the qwen3 reasoning split needs single-id think markers, and tool markup needs a + detector whose one-shot parse replays its stream (the segmenter shadows that stream).""" + config = state.config + reasoning = getattr(config, "reasoning_parser", None) + if reasoning not in (None, "qwen3"): + return False + if reasoning and (markers is None or markers.think_start is None or markers.think_end is None): + return False + if parse_tools: + name = getattr(config, "tool_call_parser", None) + detector = FunctionCallParser.ToolCallParserEnum.get(name) + if detector is None or not (detector.one_shot_replays_stream and detector.supports_streaming): + return False + return True + + +def _wants_logprobs(spec: GenSpec) -> bool: + return bool(getattr(spec.sampling_params, "logprobs", False)) + + +async def _make_logprob_segmenter(spec: GenSpec, state: Any) -> LogprobSegmenter | None: + """The segmenter for a request that asked for logprobs, or None when its entries + cannot be confined to message content (they are then all dropped).""" + markers = await chat_logprob_markers(state) + if not chat_logprobs_supported(state, spec.parse_tools, markers): + return None + reasoning = getattr(state.config, "reasoning_parser", None) is not None + return LogprobSegmenter( + markers or LogprobMarkers(None, None, frozenset()), + reasoning=reasoning, + force_reasoning=reasoning and _force_reasoning(spec, state), + tool_parser=_make_tool_parser(spec, state) if spec.parse_tools else None, + ) + + def _valid_json(text: str) -> bool: if not text: return False @@ -1065,6 +1145,10 @@ def _content_delta(text: str) -> ContentDelta: pending_logprobs = [] return ContentDelta(text, logprobs=logprobs) + # Content entries ride the next content delta; reasoning and tool-call entries are + # never collected, so a hidden token's entry cannot surface on a visible chunk. + segmenter = await _make_logprob_segmenter(spec, state) if _wants_logprobs(spec) else None + tool_parser: FunctionCallParser | None = None if parse_tools: try: @@ -1074,14 +1158,9 @@ def _content_delta(text: str) -> ContentDelta: if candidate is not None and candidate.supports_streaming(): tool_parser = candidate frag_stable = tool_parser.args_fragments_prefix_stable() if tool_parser else True - - # Logprob entries are collected only on the passthrough path: with a reasoning - # parser or tool parsing active, text can be hidden, held back, or reclassified, - # so an entry could come to describe a token that never reaches visible content. - # The API layer fail-closes those request combinations; this guard is the - # structural half of the contract — a hidden-token entry must never ride a - # later visible delta, whatever the caller. - collect_logprobs = reasoning_parser is None and tool_parser is None and not parse_tools + # A detector that replays its stream for the one-shot parse already decides which + # whitespace after a call is a separator; dropping more here would split the modes. + detector_owns_ws = tool_parser is not None and tool_parser.detector.one_shot_replays_stream # Streaming tool-call assembly: detectors emit fragments (name first, then # argument diffs); they accumulate here and the call is emitted complete when @@ -1105,7 +1184,7 @@ def _semantic_content_events(items: list[Any]) -> list[GenEvent]: else: out.append(item) if isinstance(item, ToolCallsDelta): - suppress_ws = True + suppress_ws = not detector_owns_ws return out def _filter_tool_text(piece: str) -> list[GenEvent]: @@ -1183,8 +1262,8 @@ def _route_tool_text(piece: str) -> list[GenEvent]: completion_tokens += ack.completion_tokens_delta cached_tokens += ack.cached_tokens reasoning_tokens = max(reasoning_tokens, getattr(ack, "reasoning_tokens", 0)) - if collect_logprobs and getattr(ack, "logprobs", None) is not None: - pending_logprobs.append(ack.logprobs) + if segmenter is not None: + pending_logprobs.extend(segmenter.feed(untrimmed(getattr(ack, "logprobs", None)))) content_delta = ack.incremental_output if reasoning_parser is not None and content_delta: reasoning_delta, content_delta = reasoning_parser.parse_stream_chunk(content_delta) @@ -1207,6 +1286,9 @@ def _route_tool_text(piece: str) -> list[GenEvent]: engine_matched_stop = getattr(ack, "matched_stop", None) break + if segmenter is not None: + pending_logprobs.extend(segmenter.finish()) + # Drain residue held in the reasoning parser (a deferred tool block, or a # trailing partial token) so it is not silently dropped. if reasoning_parser is not None: @@ -1274,9 +1356,10 @@ def _route_tool_text(piece: str) -> list[GenEvent]: normal_text = _filter_content(normal_text) if normal_text: yield _content_delta(normal_text) - yield ToolCallsDelta(tool_calls) - if finish_reason != "length": - finish_reason = "tool_calls" + if tool_calls: + yield ToolCallsDelta(tool_calls) + if finish_reason != "length": + finish_reason = "tool_calls" elif parse_tools and pending: stripped_pending = _filter_content(pending) if stripped_pending: @@ -1293,12 +1376,12 @@ def _route_tool_text(piece: str) -> list[GenEvent]: if trailing_content: yield _content_delta(trailing_content) - # Entries without a content delta are intentionally dropped in streaming mode. yield GenDone( finish_reason, prompt_tokens, completion_tokens, matched_stop=engine_matched_stop, cached_tokens=cached_tokens, reasoning_tokens=reasoning_tokens, timings=timer.finish(), + logprobs=pending_logprobs or None, ) @@ -1307,6 +1390,7 @@ async def _generate_full_impl(uid: int, spec: GenSpec, state: Any, timer: _Timer tool calls, strip special tokens. The adapters format the GenResult into their wire.""" full_content = "" logprob_entries: list[dict] = [] + wants_logprobs = _wants_logprobs(spec) prompt_tokens = 0 completion_tokens = 0 cached_tokens = 0 @@ -1322,13 +1406,18 @@ async def _generate_full_impl(uid: int, spec: GenSpec, state: Any, timer: _Timer cached_tokens += ack.cached_tokens reasoning_tokens = max(reasoning_tokens, getattr(ack, "reasoning_tokens", 0)) full_content += ack.incremental_output - if getattr(ack, "logprobs", None) is not None: - logprob_entries.append(ack.logprobs) + if wants_logprobs: + logprob_entries.extend(untrimmed(getattr(ack, "logprobs", None))) if ack.finished: engine_finish_reason = getattr(ack, "finish_reason", None) engine_matched_stop = getattr(ack, "matched_stop", None) break + if wants_logprobs: + segmenter = await _make_logprob_segmenter(spec, state) + logprob_entries = ( + segmenter.feed(logprob_entries) + segmenter.finish() if segmenter is not None else [] + ) reasoning_text, content_text = _split_reasoning(full_content, spec, state) # Engine reason ("stop"/"length"); a tool call overrides it, but a truncation (length) wins. finish_reason = engine_finish_reason or "stop" @@ -1336,7 +1425,7 @@ async def _generate_full_impl(uid: int, spec: GenSpec, state: Any, timer: _Timer parsed = _parse_tool_response(content_text, spec, state) if parsed is not None: content_text, tool_calls = parsed - if finish_reason != "length": + if tool_calls and finish_reason != "length": finish_reason = "tool_calls" specials = _leaked_special_tokens(state) diff --git a/python/freetoken/server/logprobs.py b/python/freetoken/server/logprobs.py index 8618569544..c3d22141a7 100644 --- a/python/freetoken/server/logprobs.py +++ b/python/freetoken/server/logprobs.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections import deque +from dataclasses import dataclass from typing import Any @@ -21,6 +23,11 @@ def completion_logprobs_error(req: Any) -> str | None: return None +def untrimmed(entries: list[dict] | None) -> list[dict]: + """A reply's entries minus those of tokens a matched stop string cut from the text.""" + return [entry for entry in entries or () if not entry.get("stop_trimmed")] + + def chat_content_entry(entry: dict) -> dict: return { "token": entry["token"], @@ -37,24 +44,210 @@ def chat_content_entry(entry: dict) -> dict: } -def completions_logprobs(entries: list[dict], start_offset: int = 0) -> dict: +def completions_logprobs(entries: list[dict], text_len: int | None = None) -> dict: + """Each entry's detokenizer-stamped ``text_offset`` is where its token starts in the + generated text (the pieces of a split multi-byte character share its start). Given the + finished text's ``text_len``, offsets are clipped to it, since a matched stop string + trims tokens off the end.""" tokens: list[str] = [] token_logprobs: list[float] = [] top_logprobs: list[dict[str, float]] = [] - text_offset: list[int] = [] - offset = start_offset for entry in entries: - token = entry["token"] - tokens.append(token) + tokens.append(entry["token"]) token_logprobs.append(entry["logprob"]) - top_logprobs.append({candidate["token"]: candidate["logprob"] for candidate in entry["top"]}) - text_offset.append(offset) - offset += len(token) + top: dict[str, float] = {} + for candidate in entry["top"]: + # The legacy shape keys alternatives by text, and partial-UTF-8 candidates + # all decode to U+FFFD: keep the most likely one per key. + key, logprob = candidate["token"], candidate["logprob"] + if key not in top or logprob > top[key]: + top[key] = logprob + top_logprobs.append(top) return { "tokens": tokens, "token_logprobs": token_logprobs, "top_logprobs": top_logprobs, - "text_offset": text_offset, + "text_offset": [ + entry["text_offset"] if text_len is None else min(entry["text_offset"], text_len) + for entry in entries + ], } + + +def chat_logprobs_object(entries: list[dict]) -> dict: + return {"content": [chat_content_entry(entry) for entry in entries], "refusal": None} + + +@dataclass(frozen=True) +class LogprobMarkers: + """Token ids the chat segmenter keys on, resolved once per server. A think marker is + None unless the tokenizer encodes it as exactly one id that decodes back to it.""" + + think_start: int | None + think_end: int | None + # Special and protocol-hidden tokens: never message content. + hidden: frozenset[int] + + +def resolve_logprob_markers( + tokenizer: Any, think_start: str, think_end: str, hidden_tokens: tuple[str, ...] +) -> LogprobMarkers: + def single_id(marker: str) -> int | None: + ids = tokenizer.encode(marker, add_special_tokens=False) + if len(ids) == 1 and tokenizer.decode(ids) == marker: + return int(ids[0]) + return None + + hidden = {int(tid) for tid in getattr(tokenizer, "all_special_ids", None) or ()} + hidden.update(tid for tid in map(single_id, hidden_tokens) if tid is not None) + return LogprobMarkers(single_id(think_start), single_id(think_end), frozenset(hidden)) + + +def _visible_text(entry: dict) -> str: + """The part of an entry's token that reached the output text.""" + return entry["token"][: entry.get("stop_visible")] + + +class LogprobSegmenter: + """Picks a chat generation's message-content logprob entries out of the sampled ones. + + A pure function of the entry sequence (token ids and single-token strings), so the + streaming and non-streaming paths select the same entries for any ack chunking. + The reasoning split is keyed on the think marker ids and mirrors the qwen3 parser: + a leading ```` (after whitespace) opens reasoning once, the first ```` + ends it for good, and content after it is whitespace-trimmed at both ends. Tool + markup is decided by a shadow tool-call parser fed one token string at a time: its + held buffer is always a suffix of what it was fed, so every resolved character is + either emitted as text or consumed as markup, and a token is content when any of + its characters is emitted. + """ + + def __init__( + self, + markers: LogprobMarkers, + *, + reasoning: bool, + force_reasoning: bool, + tool_parser: Any | None = None, + ) -> None: + self._markers = markers + self._state = "head" if reasoning else "content" + self._force = force_reasoning + self._trim = False # content follows a real : trim it at both ends + self._content_started = False + self._held: list[dict] = [] # whitespace-only tokens awaiting the next text + self._tools = tool_parser + self._queue: deque[list] = deque() # [entry, start, end, is_content] being resolved + self._fed = 0 + self._out: list[dict] = [] + + def feed(self, entries: list[dict]) -> list[dict]: + """Content entries that became final, in generation order.""" + for entry in entries: + # Hidden ids skip the parsers whole: the final EOS adds no text to the output. + if entry["token_id"] not in self._markers.hidden: + self._split_reasoning(entry) + out, self._out = self._out, [] + return out + + def finish(self) -> list[dict]: + held, self._held = self._held, [] + if self._state == "head" and not self._force: + for entry in held: + self._content(entry) + if self._tools is not None: + det = self._tools.detector + base = self._fed - len(det._buffer) + region = det._buffer + if self._tools.recover_truncated_call(): + self._resolve(base, region, "") + base, region = self._fed, "" + self._resolve(base, region, self._tools.finish_stream()) + out, self._out = self._out, [] + return out + + def _split_reasoning(self, entry: dict) -> None: + # A token a stop string cut inside is only its visible text, never a marker. + tid = -1 if "stop_visible" in entry else entry["token_id"] + text = _visible_text(entry) + blank = not text.strip() + if self._state == "head": + if tid == self._markers.think_start: + self._held = [] + self._state = "reasoning" + elif blank: + self._held.append(entry) + elif self._force: + self._held = [] + self._state = "reasoning" + self._split_reasoning(entry) + else: + self._state = "content" + held, self._held = self._held, [] + for e in held + [entry]: + self._content(e) + elif self._state == "reasoning": + if tid == self._markers.think_end: + self._state = "content" + self._trim = True + elif not self._trim: + self._content(entry) + elif blank: + if self._content_started: + self._held.append(entry) + elif not self._content_started: + # The reasoning parser strips this leading whitespace before the tool + # parser sees it: " <" opening a bare call is markup, not a space. + self._content_started = True + self._content(entry, text.lstrip()) + else: + held, self._held = self._held, [] + for e in held + [entry]: + self._content(e) + + def _content(self, entry: dict, text: str | None = None) -> None: + if self._tools is None: + self._out.append(entry) + return + text = _visible_text(entry) if text is None else text + held = self._tools.detector._buffer + base = self._fed - len(held) + self._queue.append([entry, self._fed, self._fed + len(text), False]) + self._fed += len(text) + normal = "".join( + payload for kind, payload in self._tools.parse_stream_events(text) if kind == "text" + ) + resolved = self._fed - len(self._tools.detector._buffer) - base + region = (held + text)[:resolved] if normal else "" + self._resolve(base, region, normal, resolved) + + def _resolve(self, base: int, region: str, normal: str, length: int | None = None) -> None: + """``region`` (``length`` chars from offset ``base``) left the parser's buffer, + emitting ``normal``: mark the tokens it emitted from, then release resolved ones. + ``region`` itself is only needed when something was emitted.""" + if normal: + if region.startswith(normal): + emitted = [(0, len(normal))] + elif region.endswith(normal): + # Text after markup in one token, as when a bare call's ">" merges with + # the next character (">n"): a leftmost match could land inside the markup. + emitted = [(len(region) - len(normal), len(region))] + else: # not seen in practice: fall back to the leftmost alignment + emitted, i = [], 0 + for ch in normal: + j = region.find(ch, i) + if j == -1: + break + emitted.append((j, j + 1)) + i = j + 1 + for item in self._queue: + start, end = item[1] - base, item[2] - base + if any(lo < end and start < hi for lo, hi in emitted): + item[3] = True + resolved = base + (len(region) if length is None else length) + while self._queue and self._queue[0][2] <= resolved: + entry, _, _, is_content = self._queue.popleft() + if is_content: + self._out.append(entry) diff --git a/python/freetoken/server/openai_api.py b/python/freetoken/server/openai_api.py index 0fc8113bc8..4b43074b6e 100644 --- a/python/freetoken/server/openai_api.py +++ b/python/freetoken/server/openai_api.py @@ -27,10 +27,11 @@ from .function_call_parser import ToolCallItem from .request_logger import log_request from .logprobs import ( - chat_content_entry, chat_logprobs_error, + chat_logprobs_object, completion_logprobs_error, completions_logprobs, + untrimmed, ) from .generation import ( DEFAULT_MAX_OUTPUT_TOKENS, @@ -44,6 +45,8 @@ ToolCallsDelta, ToolCallStart, build_metrics, + chat_logprob_markers, + chat_logprobs_supported, generate_events, generate_full, metrics_enabled, @@ -261,27 +264,25 @@ def _client_disconnected_response() -> JSONResponse: ) -def _chat_logprobs_conflict(req: ChatCompletionRequest, state: Any) -> str | None: - """Fail-close chat logprobs when a semantic parsing layer can hide or reclassify - generated tokens. A reasoning parser routes tokens out of visible content (and its - stream buffering re-chunks text), and tool parsing consumes tokens into tool_calls, - so logprob entries could not be aligned 1:1 with visible content tokens — and hidden - reasoning-token strings must never surface through logprobs entries. The raw - /v1/completions path has no semantic layer and keeps full logprobs support.""" +async def _chat_logprobs_conflict(req: ChatCompletionRequest, state: Any) -> str | None: + """Refuse chat logprobs when the server's parsers can hide or reclassify generated + tokens in a way the logprob segmenter cannot follow: entries must cover message + content only, never reasoning or tool-call tokens. The raw /v1/completions path has + no semantic layer and keeps full logprobs support.""" if not req.logprobs: return None - if getattr(state.config, "reasoning_parser", None): + markers = await chat_logprob_markers(state) + if not chat_logprobs_supported(state, False, markers): return ( - "logprobs on /v1/chat/completions are not supported when the server runs a " - "reasoning parser: reasoning tokens are hidden from message content, so " - "logprob entries cannot be aligned with it. Use /v1/completions for raw " - "token logprobs." + "logprobs on /v1/chat/completions are not supported with the " + f"{state.config.reasoning_parser!r} reasoning parser: reasoning tokens cannot " + "be told apart from message content. Use /v1/completions for raw token logprobs." ) - if _should_parse_tools(req): + if not chat_logprobs_supported(state, _should_parse_tools(req), markers): return ( - "logprobs with tool parsing on /v1/chat/completions are not supported: " - "tool-call tokens are consumed into tool_calls, so logprob entries cannot " - "be aligned with message content. Send tool_choice='none' or use " + "logprobs with tool parsing on /v1/chat/completions are not supported for the " + f"{state.config.tool_call_parser!r} tool-call parser: tool-call tokens cannot be " + "told apart from message content. Send tool_choice='none' or use " "/v1/completions for raw token logprobs." ) return None @@ -298,7 +299,7 @@ async def handle_chat_completion( logprobs_error = chat_logprobs_error(req) if logprobs_error is not None: return create_error_response(logprobs_error, param="top_logprobs") - logprobs_conflict = _chat_logprobs_conflict(req, state) + logprobs_conflict = await _chat_logprobs_conflict(req, state) if logprobs_conflict is not None: return create_error_response(logprobs_conflict, param="logprobs") if _response_format_unsupported(req.response_format): @@ -399,10 +400,13 @@ async def handle_chat_completion( message["reasoning_content"] = result.reasoning if result.tool_calls: message["tool_calls"] = _tool_calls_to_openai(result.tool_calls) - choice: dict[str, Any] = {"index": index, "message": message, "finish_reason": result.finish_reason} - if req.logprobs: - choice["logprobs"] = {"content": [chat_content_entry(e) for e in result.logprobs]} - choices.append(choice) + choices.append({ + "index": index, + "message": message, + # OpenAI always sends the key, null when logprobs were not requested + "logprobs": chat_logprobs_object(result.logprobs) if req.logprobs else None, + "finish_reason": result.finish_reason, + }) first = results[0] response: dict[str, Any] = { @@ -487,7 +491,7 @@ async def stream_chat_completion_chunks( elif isinstance(ev, ContentDelta): choice: dict[str, Any] = {"delta": {"content": ev.text}, "index": index, "finish_reason": None} if ev.logprobs: - choice["logprobs"] = {"content": [chat_content_entry(e) for e in ev.logprobs]} + choice["logprobs"] = chat_logprobs_object(ev.logprobs) yield _sse(_chat_chunk(req, uid, [choice])) elif isinstance(ev, ToolCallStart): open_tool = { @@ -568,7 +572,10 @@ async def stream_chat_completion_chunks( completion_tokens = ev.completion_tokens cached_tokens = ev.cached_tokens reasoning_tokens = ev.reasoning_tokens - yield _sse(_chat_chunk(req, uid, [{"delta": {}, "index": index, "finish_reason": ev.finish_reason}])) + done: dict[str, Any] = {"delta": {}, "index": index, "finish_reason": ev.finish_reason} + if ev.logprobs: + done["logprobs"] = chat_logprobs_object(ev.logprobs) + yield _sse(_chat_chunk(req, uid, [done])) timings = ev.timings usage = _usage( @@ -666,8 +673,8 @@ async def _drain_one(uid: int, index: int) -> dict[str, Any] | JSONResponse: completion_tokens += ack.completion_tokens_delta cached_tokens += ack.cached_tokens text += ack.incremental_output - if req.logprobs is not None and ack.logprobs is not None: - entries.append(ack.logprobs) + if req.logprobs is not None: + entries.extend(untrimmed(ack.logprobs)) if ack.finished: finish_reason = getattr(ack, "finish_reason", None) or "stop" break @@ -675,7 +682,9 @@ async def _drain_one(uid: int, index: int) -> dict[str, Any] | JSONResponse: "index": index, "text": echo_texts[index // req.n] + text, "finish_reason": finish_reason, - "logprobs": completions_logprobs(entries) if req.logprobs is not None else None, + "logprobs": ( + completions_logprobs(entries, len(text)) if req.logprobs is not None else None + ), "_usage": (prompt_tokens, completion_tokens, cached_tokens), } @@ -727,7 +736,10 @@ async def stream_completion_chunks( echo_text: str = "", ) -> AsyncIterator[bytes]: prompt_tokens = 0 - text_offset = 0 + sent_len = 0 + # Logprob entries of acks with no visible text yet (a held partial UTF-8 character, an + # EOS), carried to the next chunk that has text; a reply can bring several at once. + pending: list[dict] = [] completion_tokens = 0 cached_tokens = 0 finish_reason = "stop" @@ -743,7 +755,10 @@ async def stream_completion_chunks( prompt_tokens += ack.prompt_tokens_delta completion_tokens += ack.completion_tokens_delta cached_tokens += ack.cached_tokens + if req.logprobs is not None: + pending.extend(untrimmed(ack.logprobs)) if ack.incremental_output: + sent_len += len(ack.incremental_output) yield _sse( _completion_chunk( req, uid, @@ -751,20 +766,35 @@ async def stream_completion_chunks( "text": ack.incremental_output, "index": index, "finish_reason": None, + # Mid-stream, a stamp past sent_len is held-back text still to come. "logprobs": ( - completions_logprobs([ack.logprobs], text_offset) - if req.logprobs is not None and ack.logprobs is not None + completions_logprobs(pending, sent_len if ack.finished else None) + if pending else None ), }], ) ) - text_offset += len(ack.incremental_output) + pending = [] if ack.finished: finish_reason = getattr(ack, "finish_reason", None) or "stop" break - yield _sse(_completion_chunk(req, uid, [{"text": "", "index": index, "finish_reason": finish_reason, "logprobs": None}])) + # Entries whose text never showed (EOS, a stop string, a trailing partial character) + # ride the finish chunk, so the joined stream matches the non-stream list. + yield _sse( + _completion_chunk( + req, uid, + [{ + "text": "", + "index": index, + "finish_reason": finish_reason, + "logprobs": ( + completions_logprobs(pending, sent_len) if pending else None + ), + }], + ) + ) usage = _usage(prompt_tokens, completion_tokens, _reported_cached(state, cached_tokens)) if not terminal: yield _StreamUsage(usage) diff --git a/python/freetoken/server/reasoning_parser.py b/python/freetoken/server/reasoning_parser.py index 6788675ca9..c571b97a79 100644 --- a/python/freetoken/server/reasoning_parser.py +++ b/python/freetoken/server/reasoning_parser.py @@ -393,7 +393,7 @@ def _scan(self, text: str, *, hold_partial: bool) -> tuple[str, str]: class ThinkReasoningParser(BaseReasoningParser): """Generic ````/```` reasoning parser for models that wrap their - chain-of-thought in think tags (Qwen3/3.5, GLM-4.x, MiniMax-M2). No DSML tool + chain-of-thought in think tags (GLM-4.x, MiniMax-M2). No DSML tool marker — reasoning ends at ````. Assumes the model closes ```` before any tool call. These families do @@ -412,6 +412,113 @@ def __init__(self, force_reasoning: bool = False, stream_reasoning: bool = True) ) +class Qwen3ReasoningParser(BaseReasoningParser): + """Qwen3 ```` parser with one positional rule for both paths. + + Reasoning is open from the start when the template pre-opened it + (``force_reasoning``) or when the generation itself opens with ```` + (stripped once). It ends at the FIRST ```` and is never re-entered: + every later ````/```` -- quoted in prose, code or tool-call + arguments -- is literal content. Reasoning is whitespace-trimmed at both + ends, and so is content that follows a real ````; streaming holds + whitespace-only tails so its deltas join to exactly the one-shot result. + The one-shot parse replays the streaming machine, so the two cannot drift. + """ + + def __init__(self, force_reasoning: bool = False, stream_reasoning: bool = True) -> None: + super().__init__( + think_start_token=THINK_START_TOKEN, + think_end_token=THINK_END_TOKEN, + force_reasoning=force_reasoning, + stream_reasoning=stream_reasoning, + ) + # The generation start, held while it can still grow into ws* + "". + self._head: Optional[str] = "" + self._trim_content = False + self._reasoning_started = False + self._content_started = False + self._unstreamed_reasoning = "" + + def detect_and_parse(self, text: str) -> ReasoningParseResult: + clone = type(self)( + force_reasoning=self.force_reasoning, stream_reasoning=self.stream_reasoning + ) + first = clone.parse_streaming_increment(text) + rest = clone.flush() + return ReasoningParseResult( + reasoning_text=first.reasoning_text + rest.reasoning_text, + normal_text=first.normal_text + rest.normal_text, + ) + + def parse_streaming_increment(self, new_text: str) -> ReasoningParseResult: + if self._head is not None: + self._head += new_text + head = self._head.lstrip() + if head.startswith(self.think_start_token): + self._head = None + self._in_reasoning = True + return self._consume(head[len(self.think_start_token):], final=False) + if not head or self.think_start_token.startswith(head): + return ReasoningParseResult() + new_text, self._head = self._head, None + return self._consume(new_text, final=False) + + def flush(self) -> ReasoningParseResult: + text, self._head = self._head or "", None + return self._consume(text, final=True) + + def _reasoning_piece(self, piece: str, closing: bool) -> str: + if not self._reasoning_started: + piece = piece.lstrip() + self._reasoning_started = bool(piece) + if self.stream_reasoning: + return piece + self._unstreamed_reasoning += piece + if not closing: + return "" + piece, self._unstreamed_reasoning = self._unstreamed_reasoning, "" + return piece + + def _consume(self, text: str, final: bool) -> ReasoningParseResult: + buf = self._buffer + text + self._buffer = "" + reasoning = "" + if self._in_reasoning: + end = buf.find(self.think_end_token) + if end == -1: + if final: + return ReasoningParseResult( + reasoning_text=self._reasoning_piece(buf.rstrip(), closing=True) + ) + safe, _ = self._split_trailing_partial(buf) + body = safe.rstrip() + self._buffer = buf[len(body):] + return ReasoningParseResult( + reasoning_text=self._reasoning_piece(body, closing=False) + ) + reasoning = self._reasoning_piece(buf[:end].rstrip(), closing=True) + self._in_reasoning = False + self._trim_content = True + buf = buf[end + len(self.think_end_token):] + if not self._trim_content: + return ReasoningParseResult(reasoning_text=reasoning, normal_text=buf) + if not self._content_started: + buf = buf.lstrip() + self._content_started = bool(buf) + body = buf.rstrip() + if not final: + self._buffer = buf[len(body):] + return ReasoningParseResult(reasoning_text=reasoning, normal_text=body) + + def _split_trailing_partial(self, text: str) -> tuple[str, str]: + # Only is tracked: a later is literal, never a marker. + token = self.think_end_token + for k in range(min(len(token) - 1, len(text)), 0, -1): + if text.endswith(token[:k]): + return text[:-k], text[-k:] + return text, "" + + class MiniMaxM3ReasoningParser(BaseReasoningParser): """Reasoning parser for MiniMax-M3's ``...`` protocol. @@ -876,7 +983,7 @@ class ReasoningParser: ReasoningParserEnum: Dict[str, Type[BaseReasoningParser]] = { "deepseekv32": DeepSeekV32ReasoningParser, "gpt_oss": GptOssHarmonyReasoningParser, - "qwen3": ThinkReasoningParser, + "qwen3": Qwen3ReasoningParser, "glm": ThinkReasoningParser, "minimax": ThinkReasoningParser, "minimax_m3": MiniMaxM3ReasoningParser, diff --git a/python/freetoken/server/responses_api.py b/python/freetoken/server/responses_api.py index a84c68cb6d..4240080510 100644 --- a/python/freetoken/server/responses_api.py +++ b/python/freetoken/server/responses_api.py @@ -467,6 +467,7 @@ def build_responses_response( usage=_usage( result.prompt_tokens, result.completion_tokens, result.cached_tokens if cache_report else 0, + reasoning_tokens=result.reasoning_tokens, ), incomplete_reason="max_output_tokens" if truncated else None, metrics=build_metrics( @@ -504,7 +505,9 @@ def _response_obj( return response -def _usage(prompt_tokens: int, completion_tokens: int, cached_tokens: int = 0) -> ResponseUsage: +def _usage( + prompt_tokens: int, completion_tokens: int, cached_tokens: int = 0, reasoning_tokens: int = 0 +) -> ResponseUsage: # input_tokens stays inclusive of the cached prefix (OpenAI semantics); # cached_tokens is nonzero only under --enable-cache-report. return ResponseUsage( @@ -512,7 +515,7 @@ def _usage(prompt_tokens: int, completion_tokens: int, cached_tokens: int = 0) - output_tokens=completion_tokens, total_tokens=prompt_tokens + completion_tokens, input_tokens_details=InputTokensDetails(cached_tokens=cached_tokens, cache_write_tokens=0), - output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=reasoning_tokens), ) @@ -550,6 +553,7 @@ def snapshot(status, output, usage=None, incomplete_reason=None, request_metrics usage_pt = 0 usage_ct = 0 usage_cached = 0 + usage_reasoning = 0 def close_current() -> list[str]: nonlocal current, output_index @@ -735,6 +739,7 @@ def args_delta_frame(fragment: str) -> str: finish_reason = ev.finish_reason usage_pt, usage_ct = ev.prompt_tokens, ev.completion_tokens usage_cached = ev.cached_tokens if cache_report else 0 + usage_reasoning = ev.reasoning_tokens request_metrics = build_metrics( prompt_tokens=ev.prompt_tokens, completion_tokens=ev.completion_tokens, @@ -748,7 +753,7 @@ def args_delta_frame(fragment: str) -> str: type="response.incomplete", sequence_number=seq.next(), response=snapshot( "incomplete", output_items, - usage=_usage(usage_pt, usage_ct, usage_cached), + usage=_usage(usage_pt, usage_ct, usage_cached, usage_reasoning), incomplete_reason="max_output_tokens", request_metrics=request_metrics, ), @@ -758,7 +763,7 @@ def args_delta_frame(fragment: str) -> str: type="response.completed", sequence_number=seq.next(), response=snapshot( "completed", output_items, - usage=_usage(usage_pt, usage_ct, usage_cached), + usage=_usage(usage_pt, usage_ct, usage_cached, usage_reasoning), request_metrics=request_metrics, ), )) @@ -772,7 +777,7 @@ def args_delta_frame(fragment: str) -> str: type="response.failed", sequence_number=seq.next(), response=_response_obj( response_id, created, req.model, output_items, status="failed", - usage=_usage(usage_pt, usage_ct, usage_cached), + usage=_usage(usage_pt, usage_ct, usage_cached, usage_reasoning), # codex reads this code to tell a blown context window from a generic failure. # model_construct because ResponseError.code is a closed Literal in the SDK. error=ResponseError.model_construct( @@ -787,7 +792,7 @@ def args_delta_frame(fragment: str) -> str: type="response.failed", sequence_number=seq.next(), response=_response_obj( response_id, created, req.model, output_items, status="failed", - usage=_usage(usage_pt, usage_ct, usage_cached), + usage=_usage(usage_pt, usage_ct, usage_cached, usage_reasoning), error=ResponseError(code="server_error", message=str(exc)), ), )) diff --git a/python/freetoken/tokenizer/detokenize.py b/python/freetoken/tokenizer/detokenize.py index 2eb3029df0..44c3149948 100644 --- a/python/freetoken/tokenizer/detokenize.py +++ b/python/freetoken/tokenizer/detokenize.py @@ -1,5 +1,7 @@ -from dataclasses import dataclass -from typing import Dict, FrozenSet, List +import json +import weakref +from dataclasses import dataclass, field +from typing import Any, Dict, FrozenSet, List from freetoken.message import DetokenizeMsg from transformers import PreTrainedTokenizerBase @@ -73,6 +75,45 @@ class DecodeStatus: sent_offset: int # length of sent out string skip_special: bool = False # decode this request with skip_special_tokens think_end_at: int | None = None # generated tokens up to and incl. the reasoning end tag + in_reasoning: bool = False # the prompt or the output opened a reasoning block + generated: int = 0 # sampled tokens, EOS included + text_end: int = 0 # decoded text so far, before holdback: where the next token starts + # (text start, entry) of logprob entries whose token text is still held for a stop string + held_logprobs: List[tuple] = field(default_factory=list) + + def reasoning_tokens(self) -> int: + if self.think_end_at is not None: + return self.think_end_at + # stopped inside the block (max_tokens, or EOS before the end tag): all of it + return self.generated if self.in_reasoning else 0 + + def release_logprobs( + self, entry: dict | None, token_start: int, emit_end: int, text_len: int, flush: bool + ) -> List[dict]: + """Hold this token's logprob entry and release those whose text is now sent.""" + if entry is not None: + self.held_logprobs.append((token_start, {**entry, "text_offset": token_start})) + out: List[dict] = [] + # A held token's text ends where the next one's starts. + starts = [start for start, _ in self.held_logprobs] + ends = starts[1:] + [text_len] if starts else [] + if flush: + trimmed = emit_end < text_len + for (start, held), end in zip(self.held_logprobs, ends, strict=True): + if trimmed and start >= emit_end: + held = {**held, "stop_trimmed": True} + elif trimmed and emit_end < end: + held = {**held, "stop_visible": emit_end - start} + out.append(held) + self.held_logprobs = [] + elif self.held_logprobs: + # Wait for all of a token's text, so a stop match inside it can still mark it. + n = 0 + while n < len(ends) and starts[n] < emit_end and ends[n] <= emit_end: + n += 1 + out = [held for _, held in self.held_logprobs[:n]] + self.held_logprobs = self.held_logprobs[n:] + return out def update(self, read_str: str, surr_str: str) -> str: new_text = read_str[len(surr_str) :] @@ -99,6 +140,7 @@ def __init__( tokenizer: PreTrainedTokenizerBase, eos_token_ids: FrozenSet[int] | None = None, think_end_id: int | None = None, + think_start_id: int | None = None, ) -> None: # uid -> DecodeStatus self.decode_map: Dict[int, DecodeStatus] = {} @@ -109,6 +151,7 @@ def __init__( # The reasoning end tag's id (e.g. ````): tokens generated up to and # including it are reported as usage.completion_tokens_details.reasoning_tokens. self.think_end_id = think_end_id if think_end_id is None else int(think_end_id) + self.think_start_id = think_start_id if think_start_id is None else int(think_start_id) def discard(self, uid: int) -> None: """Drop a uid's decode state without a finished DetokenizeMsg. An aborted or errored @@ -131,13 +174,42 @@ def _batch_decode(self, ids: List[List[int]], skip: List[bool]) -> List[str]: out[i] = t return [t if t is not None else "" for t in out] - def detokenize_with_meta(self, msgs: List[DetokenizeMsg]) -> tuple[List[str], List[int]]: - """Incremental text per message plus, for a finished message, its reasoning token - count (0 otherwise or when the model emitted no reasoning end tag).""" + def detokenize_with_meta( + self, msgs: List[DetokenizeMsg] + ) -> tuple[List[str], List[int], List[int]]: + """Incremental text per message; for a finished message, its reasoning token count + (0 otherwise or when the generation never was in a reasoning block); and where each + message's token starts in the generated text. That offset ignores stop-string + holdback, which delays when text is sent but not where it lands.""" + texts, counts, _, offsets = self._detokenize(msgs, [None] * len(msgs)) + return texts, counts, offsets + + def detokenize_with_logprobs( + self, msgs: List[DetokenizeMsg], entries: List[dict | None] + ) -> tuple[List[str], List[int], List[List[dict]]]: + """``detokenize_with_meta`` plus the logprob entries each reply carries, each stamped + with its token's ``text_offset``. With stop strings, an entry waits for the reply that + releases all its token's text, so it never precedes text that may still be trimmed; + entries of tokens a matched stop string trimmed come on the finished reply marked + ``stop_trimmed``, and that of a token it cut inside with ``stop_visible``, the length + of the token's text left visible.""" + texts, counts, released, _ = self._detokenize(msgs, entries) + return texts, counts, released + + def _detokenize( + self, msgs: List[DetokenizeMsg], entries: List[dict | None] + ) -> tuple[List[str], List[int], List[List[dict]], List[int]]: # Each message must advance its request's decode state before the next one. if len({msg.uid for msg in msgs}) != len(msgs): - parts = [self.detokenize_with_meta([msg]) for msg in msgs] - return [p[0][0] for p in parts], [p[1][0] for p in parts] + parts = [ + self._detokenize([msg], [entry]) for msg, entry in zip(msgs, entries, strict=True) + ] + return ( + [p[0][0] for p in parts], + [p[1][0] for p in parts], + [p[2][0] for p in parts], + [p[3][0] for p in parts], + ) read_ids: List[List[int]] = [] surr_ids: List[List[int]] = [] skip: List[bool] = [] @@ -150,8 +222,10 @@ def detokenize_with_meta(self, msgs: List[DetokenizeMsg]) -> tuple[List[str], Li surr_offset=0, sent_offset=0, skip_special=bool(getattr(msg, "skip_special_tokens", False)), + in_reasoning=bool(getattr(msg, "starts_in_reasoning", False)), ) s = self.decode_map[msg.uid] + s.generated += 1 if not ( msg.finished and not msg.matched_stop and msg.next_token in self.eos_token_ids ): @@ -162,6 +236,8 @@ def detokenize_with_meta(self, msgs: List[DetokenizeMsg]) -> tuple[List[str], Li and msg.next_token == self.think_end_id ): s.think_end_at = len(s.decoded_ids) + if s.think_end_at is None and msg.next_token == self.think_start_id: + s.in_reasoning = True read_ids.append(s.decoded_ids[s.surr_offset :]) surr_ids.append(s.decoded_ids[s.surr_offset : s.read_offset]) skip.append(s.skip_special) @@ -171,9 +247,16 @@ def detokenize_with_meta(self, msgs: List[DetokenizeMsg]) -> tuple[List[str], Li incremental_strs: List[str] = [] reasoning_tokens: List[int] = [] - for msg, read_str, surr_str in zip(msgs, read_texts, surr_texts, strict=True): + released: List[List[dict]] = [] + text_offsets: List[int] = [] + for msg, entry, read_str, surr_str in zip( + msgs, entries, read_texts, surr_texts, strict=True + ): s = self.decode_map[msg.uid] + token_start = s.text_end + text_offsets.append(token_start) output_str = s.update(read_str, surr_str) + s.text_end = max(s.text_end, len(output_str)) prev_sent = s.sent_offset if msg.finished: @@ -197,11 +280,83 @@ def detokenize_with_meta(self, msgs: List[DetokenizeMsg]) -> tuple[List[str], Li incremental_output = output_str[prev_sent:emit_end] if emit_end > prev_sent else "" s.sent_offset = max(prev_sent, emit_end) incremental_strs.append(incremental_output) - reasoning_tokens.append((s.think_end_at or 0) if msg.finished else 0) + reasoning_tokens.append(s.reasoning_tokens() if msg.finished else 0) + if entry is not None or s.held_logprobs: + flush = msg.finished or not msg.stop_strs + out = s.release_logprobs(entry, token_start, emit_end, len(output_str), flush) + else: + out = [] # no logprobs asked for: skip the hold bookkeeping + released.append(out) if msg.finished: del self.decode_map[msg.uid] - return incremental_strs, reasoning_tokens + return incremental_strs, reasoning_tokens, released, text_offsets + + +def _gpt2_byte_decoder() -> dict[str, int]: + """Inverse of GPT-2's ``bytes_to_unicode``: the printable stand-in char -> its byte.""" + bs = [*range(ord("!"), ord("~") + 1), *range(ord("¡"), ord("¬") + 1), *range(ord("®"), ord("ÿ") + 1)] + cs = bs[:] + n = 0 + for b in range(256): + if b not in bs: + bs.append(b) + cs.append(256 + n) + n += 1 + return {chr(c): b for b, c in zip(bs, cs, strict=True)} + + +class _TokenBytes: + """A token id's own bytes. A single-id decode cannot give them for a piece of a + multi-byte character (it decodes to U+FFFD), so read the vocab piece instead.""" + + def __init__(self, tokenizer: Any) -> None: + decoder: dict = {} + backend = getattr(tokenizer, "backend_tokenizer", None) + try: + decoder = json.loads(backend.decoder.__getstate__()) + except Exception: # noqa: BLE001 -- no fast backend: fall back to the decoded text + pass + kinds = {d.get("type") for d in decoder.get("decoders", [decoder])} + self.byte_level = _gpt2_byte_decoder() if "ByteLevel" in kinds else None + self.byte_fallback = "ByteFallback" in kinds + added = getattr(tokenizer, "added_tokens_decoder", None) or {} + self.added = {int(tid): str(getattr(tok, "content", tok)) for tid, tok in added.items()} + self.convert = getattr(tokenizer, "convert_ids_to_tokens", None) + + def __call__(self, tid: int, text: str) -> bytes: + if tid in self.added: + # the GPT-2 table cannot invert an added token's raw (e.g. full-width) chars + return self.added[tid].encode("utf-8") + try: + piece = self.convert(tid) if self.convert is not None else None + except Exception: # noqa: BLE001 -- out-of-vocab id + piece = None + if isinstance(piece, str): + if self.byte_level is not None: + try: + return bytes(self.byte_level[c] for c in piece) + except KeyError: + pass + elif self.byte_fallback and len(piece) == 6 and piece.startswith("<0x") and piece.endswith(">"): + try: + return bytes([int(piece[3:5], 16)]) + except ValueError: + pass + return text.encode("utf-8") + + +_TOKEN_BYTES: "weakref.WeakKeyDictionary[Any, _TokenBytes]" = weakref.WeakKeyDictionary() + + +def _token_bytes(tokenizer: Any) -> _TokenBytes: + try: + cached = _TOKEN_BYTES.get(tokenizer) + if cached is None: + cached = _TOKEN_BYTES[tokenizer] = _TokenBytes(tokenizer) + except TypeError: # not weak-referenceable or unhashable + return _TokenBytes(tokenizer) + return cached def build_logprobs_entry( @@ -213,21 +368,27 @@ def build_logprobs_entry( ) -> dict: """Neutral sampled-token logprobs entry (UserReply.logprobs). Values are the raw pre-temperature logprobs the sampler computed; token text comes from a single-id - decode, with UTF-8 bytes alongside so clients can reassemble partial-UTF-8 pieces.""" + decode, and ``bytes`` are the token's own bytes, so clients can reassemble a + character split across tokens. Ids past the real vocab (the padded tail of the + logits) report an empty token.""" + token_bytes = _token_bytes(tokenizer) def _fields(tid: int) -> tuple[str, list[int]]: - text = tokenizer.decode([tid]) - return text, list(text.encode("utf-8")) + try: + text = tokenizer.decode([tid]) + except Exception: # noqa: BLE001 -- a padded id some tokenizers refuse to decode + text = "" + return text, list(token_bytes(tid, text)) - token, token_bytes = _fields(token_id) + token, data = _fields(token_id) top = [] for tid, logprob in zip(top_ids or [], top_logprobs or []): - text, data = _fields(int(tid)) - top.append({"token_id": int(tid), "token": text, "bytes": data, "logprob": float(logprob)}) + text, cand = _fields(int(tid)) + top.append({"token_id": int(tid), "token": text, "bytes": cand, "logprob": float(logprob)}) return { "token_id": token_id, "token": token, - "bytes": token_bytes, + "bytes": data, "logprob": float(chosen_logprob), "top": top, } diff --git a/python/freetoken/tokenizer/server.py b/python/freetoken/tokenizer/server.py index 4c650042b4..43c5515761 100644 --- a/python/freetoken/tokenizer/server.py +++ b/python/freetoken/tokenizer/server.py @@ -84,7 +84,11 @@ def _send_generation_replies( def _reasoning_end_id(tokenizer) -> int | None: """The id of the reasoning end tag when the vocabulary has one (```` for the Qwen / DeepSeek / GLM families), else None. Only used to count reasoning tokens.""" - for tag in ("", "<|end_of_thinking|>"): + return _vocab_tag_id(tokenizer, ("", "<|end_of_thinking|>")) + + +def _vocab_tag_id(tokenizer, tags: tuple[str, ...]) -> int | None: + for tag in tags: try: tid = tokenizer.convert_tokens_to_ids(tag) except Exception: # noqa: BLE001 -- a tokenizer without the tag answers unk or raises @@ -94,10 +98,20 @@ def _reasoning_end_id(tokenizer) -> int | None: return None +def _prompt_opens_reasoning(input_ids: torch.Tensor, start_id: int, end_id: int) -> bool: + """The prompt's last reasoning tag is an opening one: the template pre-opened the + block (qwen3 with thinking on), so generation starts inside it. A raw prompt that just + mentions a literal trips this too; harmless, since only the chat and responses + endpoints, whose prompts are always templated, report reasoning_tokens.""" + tags = torch.nonzero((input_ids == start_id) | (input_ids == end_id)).flatten() + return tags.numel() > 0 and int(input_ids[tags[-1]]) == start_id + + def _tokenize_requests( tokenize_manager: Any, messages: List[TokenizeMsg], logger: Any, + reasoning_tags: tuple[int, int] | None = None, ) -> tuple[List[UserMsg], List[UserReply]]: """Tokenize independently, returning backend work plus terminal frontend errors. @@ -132,6 +146,10 @@ def _tokenize_requests( ) ) continue + if reasoning_tags is not None: + user_msg.sampling_params.starts_in_reasoning = _prompt_opens_reasoning( + user_msg.input_ids, *reasoning_tags + ) backend.append(user_msg) return backend, errors @@ -163,10 +181,18 @@ def tokenize_worker( from .tokenize import TokenizeManager tokenize_manager = TokenizeManager(tokenizer, get_mm_processor(tokenizer_path, mm)) + think_start_id = _vocab_tag_id(tokenizer, ("",)) + think_end_id = _reasoning_end_id(tokenizer) + reasoning_tags = ( + (think_start_id, think_end_id) + if think_start_id is not None and think_end_id is not None + else None + ) detokenize_manager = DetokenizeManager( tokenizer, load_eos_token_ids(tokenizer_path, tokenizer), - think_end_id=_reasoning_end_id(tokenizer), + think_end_id=think_end_id, + think_start_id=think_start_id, ) if ack_queue is not None: @@ -224,7 +250,21 @@ def tokenize_worker( ) sampled_replies: List[UserReply] = [] if len(detokenize_msg) > 0: - replies, reasoning_counts = detokenize_manager.detokenize_with_meta(detokenize_msg) + entries = [ + build_logprobs_entry( + detokenize_manager.tokenizer, + msg.next_token, + msg.chosen_logprob, + msg.top_ids, + msg.top_logprobs, + ) + if msg.chosen_logprob is not None + else None + for msg in detokenize_msg + ] + replies, reasoning_counts, released = detokenize_manager.detokenize_with_logprobs( + detokenize_msg, entries + ) sampled_replies = [ UserReply( uid=msg.uid, @@ -242,22 +282,10 @@ def tokenize_worker( swa_total_tokens=msg.swa_total_tokens, gpu_mem_bytes=msg.gpu_mem_bytes, prefill_ms=msg.prefill_ms, - # Stop-string trimming can hide final visible text; keep one logprob - # entry per sampled token. - logprobs=( - build_logprobs_entry( - detokenize_manager.tokenizer, - msg.next_token, - msg.chosen_logprob, - msg.top_ids, - msg.top_logprobs, - ) - if msg.chosen_logprob is not None - else None - ), + logprobs=reply_logprobs or None, ) - for msg, reply, reasoning_tokens in zip( - detokenize_msg, replies, reasoning_counts, strict=True + for msg, reply, reasoning_tokens, reply_logprobs in zip( + detokenize_msg, replies, reasoning_counts, released, strict=True ) ] @@ -279,7 +307,9 @@ def tokenize_worker( # Tokenize per-message so a single un-renderable request (e.g. a chat template # that rejects the message layout) becomes a terminal error reply for THAT uid # instead of an uncaught exception that kills the worker and bricks the server. - backend, errors = _tokenize_requests(tokenize_manager, tokenize_msg, logger) + backend, errors = _tokenize_requests( + tokenize_manager, tokenize_msg, logger, reasoning_tags + ) if errors: send_frontend.put( errors[0] if len(errors) == 1 else BatchFrontendMsg(data=errors) diff --git a/tests/engine/test_sample_logprobs.py b/tests/engine/test_sample_logprobs.py index 88fffcc2ed..02dff731ef 100644 --- a/tests/engine/test_sample_logprobs.py +++ b/tests/engine/test_sample_logprobs.py @@ -20,7 +20,7 @@ def test_compute_logprobs_matches_raw_log_softmax_and_sorted_top() -> None: sampled_tokens = torch.tensor([2, 3, 0], dtype=torch.long) args = BatchSamplingArgs( temperatures=torch.tensor([1.0, 1.0, 1.0], dtype=torch.float32), - logprob_rows=torch.tensor([True, False, True], dtype=torch.bool), + logprob_row_idx=torch.tensor([0, 2]), max_top_logprobs=3, ) @@ -58,16 +58,15 @@ def test_compute_logprobs_ignores_temperatures() -> None: dtype=torch.float32, ) sampled_tokens = torch.tensor([1, 2], dtype=torch.long) - logprob_rows = torch.tensor([True, True], dtype=torch.bool) cold = BatchSamplingArgs( temperatures=torch.full((2,), 0.3, dtype=torch.float32), - logprob_rows=logprob_rows, + logprob_all_rows=True, max_top_logprobs=2, ) hot = BatchSamplingArgs( temperatures=torch.full((2,), 2.5, dtype=torch.float32), - logprob_rows=logprob_rows, + logprob_all_rows=True, max_top_logprobs=2, ) @@ -89,7 +88,7 @@ def test_compute_logprobs_returns_none_without_requested_rows() -> None: sampled_tokens = torch.tensor([0, 1], dtype=torch.long) args = BatchSamplingArgs( temperatures=torch.ones(2), - logprob_rows=torch.zeros(2, dtype=torch.bool), + logprob_row_idx=torch.empty(0, dtype=torch.int64), max_top_logprobs=2, ) @@ -109,7 +108,7 @@ def test_compute_logprobs_handles_zero_top_logprobs() -> None: sampled_tokens = torch.tensor([3, 0, 1], dtype=torch.long) args = BatchSamplingArgs( temperatures=torch.full((3,), 1.0), - logprob_rows=torch.tensor([True, False, True], dtype=torch.bool), + logprob_row_idx=torch.tensor([0, 2]), max_top_logprobs=0, ) @@ -166,3 +165,57 @@ def test_compute_logprobs_with_prepare_keeps_max_top_logprobs_clamped(monkeypatc assert top_logprobs.shape == (2, sampler.vocab_size) assert torch.equal(top_ids[1], torch.full((sampler.vocab_size,), -1, dtype=torch.int32)) assert torch.isneginf(top_logprobs[1]).all() + + +def _reference_logprobs(logits, sampled_tokens, want, k): + """The pre-index implementation (bool mask + torch.nonzero), kept as the oracle.""" + mask = torch.tensor(want, dtype=torch.bool) + rows = torch.nonzero(mask, as_tuple=False).flatten() + if rows.numel() == 0: + return None + logprobs = torch.log_softmax(logits.index_select(0, rows).float(), dim=-1) + chosen = torch.full((logits.shape[0],), float("nan")) + chosen.index_copy_( + 0, rows, logprobs[torch.arange(rows.numel()), sampled_tokens.long().index_select(0, rows)] + ) + top_ids = torch.full((logits.shape[0], k), -1, dtype=torch.int32) + top_logprobs = torch.full((logits.shape[0], k), float("-inf")) + if k > 0: + values, ids = torch.topk(logprobs, k=k, dim=-1) + top_ids[rows] = ids.to(torch.int32) + top_logprobs[rows] = values + return chosen, top_ids, top_logprobs + + +def test_compute_logprobs_builds_row_index_on_host(monkeypatch) -> None: + # A nonzero over a CUDA mask is a host-device sync on every logprobs step; the row + # index must come from prepare's host list instead, with values unchanged. + import freetoken.engine.sample as sample_mod + + monkeypatch.setattr( + sample_mod, "make_device_tensor", + lambda data, dtype, device: torch.tensor(data, dtype=dtype), + ) + sampler = Sampler(torch.device("cpu"), vocab_size=6) + logits = torch.randn(4, 6, generator=torch.Generator().manual_seed(0)) + sampled_tokens = torch.tensor([5, 0, 3, 1], dtype=torch.int32) + + for want in ([True, False, True, False], [True] * 4, [False] * 4): + batch = SimpleNamespace( + reqs=[ + SimpleNamespace(sampling_params=SamplingParams(logprobs=w, top_logprobs=3 if w else 0)) + for w in want + ] + ) + args = sampler.prepare(batch) + expected = _reference_logprobs(logits, sampled_tokens, want, 3) + with monkeypatch.context() as m: + m.setattr(torch, "nonzero", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("sync"))) + result = sampler.compute_logprobs(logits, sampled_tokens, args) + if expected is None: + assert result is None + continue + assert result is not None + for got, ref in zip(result, expected, strict=True): + assert got.shape == ref.shape and got.dtype == ref.dtype + assert torch.equal(got.nan_to_num(-7.0), ref.nan_to_num(-7.0)) diff --git a/tests/scheduler/test_cost_accounting_core.py b/tests/scheduler/test_cost_accounting_core.py index 512bfa4049..045b9924be 100644 --- a/tests/scheduler/test_cost_accounting_core.py +++ b/tests/scheduler/test_cost_accounting_core.py @@ -60,6 +60,28 @@ def tokenize(self, messages): assert errors == [] # in particular, no early prompt_tokens_delta UserReply +def test_tokenization_flags_a_prompt_that_leaves_reasoning_open(): + # The detokenizer cannot see the prompt; the tokenizer worker marks a template that + # pre-opened (5) so an unclosed generation counts as reasoning (6 = ). + prompts = { + 1: [10, 5, 11], # thinking on: the generation prompt ends inside + 2: [10, 5, 6, 11], # thinking off: the template pre-closed the block + 3: [5, 7, 6, 10, 5, 12], # an earlier turn's block is closed, the last one open + 4: [10, 11], # no reasoning tags + } + + class Tokenizer: + def tokenize(self, messages): + (msg,) = messages + ids = torch.tensor(prompts[msg.uid], dtype=torch.int32) + return [UserMsg(uid=msg.uid, input_ids=ids, sampling_params=msg.sampling_params)] + + backend, _ = _tokenize_requests( + Tokenizer(), [_tokenize_msg(uid) for uid in prompts], _Logger(), (5, 6) + ) + assert [m.sampling_params.starts_in_reasoning for m in backend] == [True, False, True, False] + + def test_tokenization_failure_and_empty_prompt_are_terminal_without_usage(): class Tokenizer: def tokenize(self, messages): diff --git a/tests/server/test_function_call_parser.py b/tests/server/test_function_call_parser.py index 8af5bc88b6..3ba0fb0e9e 100644 --- a/tests/server/test_function_call_parser.py +++ b/tests/server/test_function_call_parser.py @@ -365,6 +365,35 @@ def test_dsv41_dsml_tags_lead_with_a_space(): assert json.loads("".join(c.parameters for c in calls if c.name is None)) == {"pattern": "*.py", "path": "/src"} +def _qwen3_coder_read(path): + return ( + "\n\n\n" + f"{path}\n\n\n" + ) + + +def test_qwen3_coder_one_shot_keeps_text_between_calls(): + text = "Hi" + _qwen3_coder_read("/a") + "\nThen more.\n" + _qwen3_coder_read("/b") + "\n" + result = FunctionCallParser(OPENCODE_TOOLS, tool_call_parser="qwen3_coder").parse_non_stream(text) + assert result.normal_text == "Hi\nThen more.\n" + assert [json.loads(c.parameters) for c in result.calls] == [{"filePath": "/a"}, {"filePath": "/b"}] + + parser = FunctionCallParser(OPENCODE_TOOLS, tool_call_parser="qwen3_coder") + texts, calls = _feed(parser, text) + assert "".join(texts) + parser.finish_stream() == result.normal_text + assert [c.name for c in calls if c.name] == ["read", "read"] + + +def test_qwen3_coder_unclosed_function_tag_is_not_a_call(): + # The name stops at the next tag: never "x\n\n")]) + assert calls == [] and parser.recover_truncated_call() == [] + + def test_streaming_support_flags(): # Every registered detector is incremental-safe (buffered fallback remains as # the escape hatch for future formats, exercised via monkeypatch in diff --git a/tests/server/test_logprobs_api.py b/tests/server/test_logprobs_api.py index 8cf0387c60..f983e6d9b5 100644 --- a/tests/server/test_logprobs_api.py +++ b/tests/server/test_logprobs_api.py @@ -2,9 +2,14 @@ import asyncio import json +import os +import random from types import SimpleNamespace +import pytest + from freetoken.message import TokenizeMsg, UserReply +from freetoken.server.generation import generate_full from freetoken.server.openai_api import ( ChatCompletionRequest, CompletionRequest, @@ -20,16 +25,79 @@ def run(coro): return asyncio.run(coro) +THINK, END_THINK, TOOL_CALL, END_TOOL_CALL = 248068, 248069, 248058, 248059 +IM_END, ENDOFTEXT = 248046, 248044 +_MARKERS = { + "": THINK, + "": END_THINK, + "": TOOL_CALL, + "": END_TOOL_CALL, + "<|im_end|>": IM_END, + "<|endoftext|>": ENDOFTEXT, +} + + +class QwenTokenizer: + """Qwen3.8's marker ids; any other text encodes one id per character.""" + + all_special_ids = [ENDOFTEXT, IM_END] + + def __init__(self, single_id_markers: bool = True) -> None: + self.single = single_id_markers + + def encode(self, text: str, add_special_tokens: bool = False) -> list[int]: + if self.single and text in _MARKERS: + return [_MARKERS[text]] + return [ord(ch) for ch in text] + + def decode(self, ids: list[int]) -> str: + names = {v: k for k, v in _MARKERS.items()} + return "".join(names.get(i, chr(i) if i < 0x110000 else "") for i in ids) + + +def token_replies( + tokens: list[str], *, finish_reason: str = "stop", texts: list[str] | None = None +) -> list[UserReply]: + """One reply per sampled token, each carrying that token's entry (EOS appended for a + "stop" finish). ``texts`` overrides the text each reply releases, the way the + detokenizer holds back partial characters and stop prefixes.""" + tokens = tokens + (["<|im_end|>"] if finish_reason == "stop" else []) + if texts is None: + texts = ["" if t in ("<|im_end|>", "<|endoftext|>") else t for t in tokens] + replies = [UserReply(uid=42, incremental_output="", finished=False, prompt_tokens_delta=3)] + for i, (token, text) in enumerate(zip(tokens, texts, strict=True)): + last = i == len(tokens) - 1 + replies.append( + UserReply( + uid=42, + incremental_output=text, + finished=last, + finish_reason=finish_reason if last else None, + completion_tokens_delta=1, + logprobs=[entry(_MARKERS.get(token, 1000 + i), token, -0.01 * (i + 1))], + ) + ) + return replies + + class FakeState: - def __init__(self, replies: list[UserReply], reasoning_parser: str | None = None) -> None: + def __init__( + self, + replies: list[UserReply], + reasoning_parser: str | None = None, + tool_call_parser: str = "llama3", + tokenizer: QwenTokenizer | None = None, + ) -> None: self.config = SimpleNamespace( model_path="/models/unit-model", served_model_name="unit-model", - tool_call_parser="llama3", + tool_call_parser=tool_call_parser, reasoning_parser=reasoning_parser, ) self.replies = replies self.sent: TokenizeMsg | None = None + if tokenizer is not None: + self.frontend_tokenizer = lambda: SimpleNamespace(tokenizer=tokenizer) def new_user(self) -> int: return 42 @@ -43,7 +111,7 @@ async def wait_for_ack(self, uid: int): yield reply -def reply(text: str, *, finished: bool = False, logprobs: dict | None = None) -> UserReply: +def reply(text: str, *, finished: bool = False, logprobs: list[dict] | None = None) -> UserReply: return UserReply( uid=42, incremental_output=text, @@ -55,10 +123,11 @@ def reply(text: str, *, finished: bool = False, logprobs: dict | None = None) -> ) -def entry(token_id: int, token: str, logprob: float) -> dict: +def entry(token_id: int, token: str, logprob: float, *, text_offset: int = 0) -> dict: return { "token_id": token_id, "token": token, + "text_offset": text_offset, "bytes": list(token.encode("utf-8")), "logprob": logprob, "top": [ @@ -105,7 +174,7 @@ def test_chat_non_stream_logprobs() -> None: handle_chat_completion( chat_request(logprobs=True, top_logprobs=2), None, - FakeState([reply("Hello", logprobs=first), reply("!", finished=True, logprobs=second)]), + FakeState([reply("Hello", logprobs=[first]), reply("!", finished=True, logprobs=[second])]), {}, ) ) @@ -136,18 +205,20 @@ def test_chat_non_stream_logprobs() -> None: handle_chat_completion( chat_request(), None, - FakeState([reply("Hello", finished=True, logprobs=first)]), + FakeState([reply("Hello", finished=True, logprobs=[first])]), {}, ) ) - assert without_logprobs["choices"][0].get("logprobs") is None + # OpenAI sends the key with null rather than leaving it out + assert "logprobs" in without_logprobs["choices"][0] + assert without_logprobs["choices"][0]["logprobs"] is None def test_chat_stream_logprobs_follow_content_deltas() -> None: first = entry(1, "Hello", -0.1) state = FakeState( [ - reply("Hello", logprobs=first), + reply("Hello", logprobs=[first]), reply(" world", finished=True), ] ) @@ -168,14 +239,14 @@ def test_chat_stream_logprobs_follow_content_deltas() -> None: def test_completion_logprobs_non_stream_and_stream() -> None: first = entry(1, "Hi", -0.1) - second = entry(2, "!", -0.2) + second = entry(2, "!", -0.2, text_offset=2) req = CompletionRequest(model="client-model", prompt="hello", logprobs=2, max_tokens=8) result = run( handle_completion( req, None, - FakeState([reply("Hi", logprobs=first), reply("!", finished=True, logprobs=second)]), + FakeState([reply("Hi", logprobs=[first]), reply("!", finished=True, logprobs=[second])]), {}, ) ) @@ -201,7 +272,7 @@ def test_completion_logprobs_non_stream_and_stream() -> None: max_tokens=8, stream=True, ), - FakeState([reply("Hi", finished=True, logprobs=first)]), + FakeState([reply("Hi", finished=True, logprobs=[first])]), ) ) ) @@ -215,6 +286,255 @@ def test_completion_logprobs_non_stream_and_stream() -> None: } +def _crab_replies() -> list[UserReply]: + # 🦀 is three byte-level tokens: the first two acks carry no visible text yet. + pieces = [ + reply("", logprobs=[entry(9008, "�", -0.1)]), + reply("", logprobs=[entry(99, "�", -0.2)]), + reply("🦀", logprobs=[entry(222, "�", -0.3)]), + reply(" x", logprobs=[entry(1, " x", -0.4, text_offset=1)]), + reply("", finished=True, logprobs=[entry(2, "<|im_end|>", -0.5, text_offset=3)]), + ] + return pieces + + +def test_completion_text_offset_counts_visible_text() -> None: + req = CompletionRequest(model="client-model", prompt="hello", logprobs=1, max_tokens=8) + result = run(handle_completion(req, None, FakeState(_crab_replies()), {})) + logprobs = result["choices"][0]["logprobs"] + assert result["choices"][0]["text"] == "🦀 x" + # The split character's pieces all point at where it starts, not at + # len("�") steps that drift past the real text. + assert logprobs["text_offset"] == [0, 0, 0, 1, 3] + + +def _offset_replies(acks: list[tuple[str, list[tuple[str, int]]]]) -> list[UserReply]: + """One reply per (text, [(token, text_offset), ...]) ack, entries in generation order.""" + replies, n = [], 0 + for i, (text, tokens) in enumerate(acks): + entries = [] + for token, offset in tokens: + entries.append(entry(n, token, -0.1, text_offset=offset)) + n += 1 + replies.append(reply(text, finished=i == len(acks) - 1, logprobs=entries or None)) + return replies + + +# "Answer: 4\nQuestion 2" under stop=["\n\n"]: the "\n" ack sends nothing (it could start +# the stop), and "Question" releases it along with its own text. "own" gives every ack +# its own entry; "held" is what the detokenizer sends, holding the "\n" entry until the +# reply that releases its text, which then carries two entries. +_HOLDBACK_ACKS = { + "own": [("Answer", [("Answer", 0)]), (":", [(":", 6)]), (" ", [(" ", 7)]), ("4", [("4", 8)]), + ("", [("\n", 9)]), ("\nQuestion", [("Question", 10)]), (" ", [(" ", 18)]), + ("2", [("2", 19)])], + "held": [("Answer", [("Answer", 0)]), (":", [(":", 6)]), (" ", [(" ", 7)]), ("4", [("4", 8)]), + ("", []), ("\nQuestion", [("\n", 9), ("Question", 10)]), (" ", [(" ", 18)]), + ("2", [("2", 19)])], +} + + +def _joined_stream_logprobs(replies: list[UserReply], **kwargs) -> dict[str, list]: + req = CompletionRequest(model="client-model", prompt="hello", max_tokens=8, stream=True, **kwargs) + events = parse_sse(run(_collect(stream_completion_chunks(42, req, FakeState(replies))))) + joined: dict[str, list] = {"tokens": [], "token_logprobs": [], "top_logprobs": [], "text_offset": []} + for event in events: + if isinstance(event, dict) and event["choices"] and event["choices"][0]["logprobs"]: + for key in joined: + joined[key] += event["choices"][0]["logprobs"][key] + return joined + + +@pytest.mark.parametrize("shape", ["own", "held"]) +def test_completion_text_offset_survives_stop_prefix_holdback(shape: str) -> None: + req = CompletionRequest(model="client-model", prompt="hello", logprobs=0, max_tokens=8, stop=["\n\n"]) + replies = _offset_replies(_HOLDBACK_ACKS[shape]) + choice = run(handle_completion(req, None, FakeState(replies), {}))["choices"][0] + logprobs = choice["logprobs"] + assert choice["text"] == "Answer: 4\nQuestion 2" + assert logprobs["tokens"] == ["Answer", ":", " ", "4", "\n", "Question", " ", "2"] + assert logprobs["text_offset"] == [0, 6, 7, 8, 9, 10, 18, 19] + for token, offset in zip(logprobs["tokens"], logprobs["text_offset"], strict=True): + assert choice["text"][offset:].startswith(token) + assert _joined_stream_logprobs(replies, logprobs=0, stop=["\n\n"]) == logprobs + + +def test_completion_text_offset_clipped_to_stop_trimmed_text() -> None: + # "a\n\n" under stop=["\n\n"]: the second "\n" completes the stop and both are trimmed. + # Unmarked, their offsets are clipped so they cannot point past the returned text. + replies = [ + reply("a", logprobs=[entry(1, "a", -0.1)]), + reply("", logprobs=[entry(2, "\n", -0.2, text_offset=1)]), + reply("", finished=True, logprobs=[entry(2, "\n", -0.3, text_offset=2)]), + ] + req = CompletionRequest(model="client-model", prompt="hello", logprobs=0, max_tokens=8, stop=["\n\n"]) + choice = run(handle_completion(req, None, FakeState(replies), {}))["choices"][0] + assert choice["text"] == "a" + assert choice["logprobs"]["text_offset"] == [0, 1, 1] + assert _joined_stream_logprobs(replies, logprobs=0, stop=["\n\n"]) == choice["logprobs"] + + # As the detokenizer sends them: held with their text, then marked and dropped. + trimmed = [ + {**entry(2, "\n", -0.2, text_offset=1), "stop_trimmed": True}, + {**entry(2, "\n", -0.3, text_offset=2), "stop_trimmed": True}, + ] + marked = [replies[0], reply("", logprobs=None), reply("", finished=True, logprobs=trimmed)] + choice = run(handle_completion(req, None, FakeState(marked), {}))["choices"][0] + assert choice["logprobs"]["tokens"] == ["a"] + assert choice["logprobs"]["text_offset"] == [0] + assert _joined_stream_logprobs(marked, logprobs=0, stop=["\n\n"]) == choice["logprobs"] + + +# stop=["aab"] over "aaax": two "a"s stay held while the first is sent. "own": the held +# tokens' entries ride their own acks, so their offsets run past the text sent so far; +# that is where they land, not an overrun. "held": the detokenizer's shape, each entry +# waiting for its text, so the finished reply carries three. +_AHEAD_ACKS = { + "own": [("", [("a", 0)]), ("", [("a", 1)]), ("a", [("a", 2)]), ("aax", [("x", 3)])], + "held": [("", []), ("", []), ("a", [("a", 0)]), ("aax", [("a", 1), ("a", 2), ("x", 3)])], +} + + +@pytest.mark.parametrize("shape", ["own", "held"]) +def test_completion_stream_text_offset_ahead_of_sent_text(shape: str) -> None: + req = CompletionRequest(model="client-model", prompt="hello", logprobs=0, max_tokens=8, stop=["aab"]) + replies = _offset_replies(_AHEAD_ACKS[shape]) + choice = run(handle_completion(req, None, FakeState(replies), {}))["choices"][0] + assert choice["text"] == "aaax" + assert choice["logprobs"]["tokens"] == ["a", "a", "a", "x"] + assert choice["logprobs"]["text_offset"] == [0, 1, 2, 3] + assert _joined_stream_logprobs(replies, logprobs=0, stop=["aab"]) == choice["logprobs"] + + +def test_completion_text_offset_real_tokenizer_with_stops() -> None: + # The real Qwen3.8 tokenizer through the real detokenizer: every token's offset must + # point at its own text, stream and non-stream alike, with entries held for their text + # and those of stop-trimmed tokens dropped. + path = os.path.expanduser("~/models/Qwen3.8-Flash-Next-NVFP4-FTW-fp8") + if not os.path.exists(os.path.join(path, "tokenizer.json")): + pytest.skip("local Qwen3.8 tokenizer not present") + from transformers import AutoTokenizer + + from freetoken.message import DetokenizeMsg + from freetoken.tokenizer.detokenize import DetokenizeManager, build_logprobs_entry + + tokenizer = AutoTokenizer.from_pretrained(path) + cases = [ + ("Answer: 4\nQuestion 2", "\n\n", None), + ("Q: hi\nA: Question time 🦀 ok", "Question:", None), + # the stop matches on the last token: the "\n\n" token it trimmed loses its entry + ("Answer: 4\n\n", "\n\n", "\n\n"), + # the stop cuts inside " hello": that entry stays, " world" (trimmed) does not + ("Say hello world", "lo w", "lo w"), + ] + held = False + for text, stop, matched in cases: + ids = tokenizer.encode(text, add_special_tokens=False) + manager = DetokenizeManager(tokenizer, frozenset({248046})) + replies = [] + for i, tid in enumerate(ids): + last = i == len(ids) - 1 + msg = DetokenizeMsg( + uid=42, next_token=tid, finished=last, stop_strs=[stop], + matched_stop=matched if last else None, + ) + (out,), _, (released,) = manager.detokenize_with_logprobs( + [msg], [build_logprobs_entry(tokenizer, tid, -0.1, [], [])] + ) + held |= len(released) > 1 + replies.append(reply(out, finished=last, logprobs=released or None)) + visible = text[: text.find(matched)] if matched else text + req = CompletionRequest(model="client-model", prompt="p", logprobs=0, max_tokens=64, stop=[stop]) + choice = run(handle_completion(req, None, FakeState(replies), {}))["choices"][0] + assert choice["text"] == visible + tokens, offsets = choice["logprobs"]["tokens"], choice["logprobs"]["text_offset"] + assert len(tokens) == len(ids) - (1 if matched else 0) + for token, offset in zip(tokens, offsets, strict=True): + if "\ufffd" not in token: + # a token's text sits at its offset; only one the stop cut ends the text early + seg = visible[offset : offset + len(token)] + assert token.startswith(seg), (text, token, offset) + assert seg == token or offset + len(seg) == len(visible), (text, token, offset) + assert offsets == sorted(offsets) + assert _joined_stream_logprobs(replies, logprobs=0, stop=[stop]) == choice["logprobs"] + assert held, "no reply carried more than one entry" + + +def test_completion_top_logprobs_key_collision_keeps_most_likely() -> None: + # Partial-UTF-8 candidates all decode to U+FFFD and collide on the dict key. + colliding = entry(9008, "�", -1.0) + colliding["top"] = [ + {"token_id": 9008, "token": "�", "bytes": [240, 159], "logprob": -1.0}, + {"token_id": 9009, "token": "�", "bytes": [240, 160], "logprob": -2.0}, + {"token_id": 5, "token": "a", "bytes": [97], "logprob": -3.0}, + ] + req = CompletionRequest(model="client-model", prompt="hello", logprobs=3, max_tokens=8) + result = run(handle_completion(req, None, FakeState([reply("x", finished=True, logprobs=[colliding])]), {})) + assert result["choices"][0]["logprobs"]["top_logprobs"] == [{"�": -1.0, "a": -3.0}] + + +def test_completion_stream_logprobs_match_non_stream() -> None: + # Acks with no visible text must not lose their entries: they ride the next chunk + # with text (or the finish chunk), so the joined stream equals the non-stream list. + req = CompletionRequest(model="client-model", prompt="hello", logprobs=1, max_tokens=8) + expected = run(handle_completion(req, None, FakeState(_crab_replies()), {})) + stream_req = CompletionRequest(model="client-model", prompt="hello", logprobs=1, max_tokens=8, stream=True) + events = parse_sse(run(_collect(stream_completion_chunks(42, stream_req, FakeState(_crab_replies()))))) + joined: dict[str, list] = {"tokens": [], "token_logprobs": [], "top_logprobs": [], "text_offset": []} + for event in events: + if isinstance(event, dict) and event["choices"] and event["choices"][0]["logprobs"]: + for key in joined: + joined[key] += event["choices"][0]["logprobs"][key] + assert joined == expected["choices"][0]["logprobs"] + assert len(joined["tokens"]) == 5 + + +def _stop_trimmed_replies() -> list[UserReply]: + # "Hi", then a stop string "##" sampled as "#" "#": the detokenizer withheld both + # entries with the text and releases them, marked, on the finished reply. + trimmed = [{**entry(3, "#", -0.3), "stop_trimmed": True}, {**entry(3, "#", -0.4), "stop_trimmed": True}] + return [ + reply("Hi", logprobs=[entry(1, "Hi", -0.1)]), + reply("", logprobs=None), + reply("", finished=True, logprobs=trimmed), + ] + + +def test_completion_logprobs_drop_stop_trimmed_entries() -> None: + req = CompletionRequest(model="client-model", prompt="hello", logprobs=1, max_tokens=8, stop=["##"]) + result = run(handle_completion(req, None, FakeState(_stop_trimmed_replies()), {})) + assert result["choices"][0]["logprobs"]["tokens"] == ["Hi"] + + stream_req = req.model_copy(update={"stream": True}) + events = parse_sse(run(_collect(stream_completion_chunks(42, stream_req, FakeState(_stop_trimmed_replies()))))) + tokens = [ + token + for event in events + if isinstance(event, dict) and event["choices"] and event["choices"][0]["logprobs"] + for token in event["choices"][0]["logprobs"]["tokens"] + ] + assert tokens == ["Hi"] + + +def test_chat_logprobs_drop_stop_trimmed_entries() -> None: + req = chat_request(logprobs=True, stop=["##"]) + result = run(handle_chat_completion(req, None, FakeState(_stop_trimmed_replies()), {})) + assert [e["token"] for e in result["choices"][0]["logprobs"]["content"]] == ["Hi"] + + stream_req = chat_request(logprobs=True, stop=["##"], stream=True) + spec = chat_request_to_genspec(stream_req, {}) + events = parse_sse( + run(_collect(stream_chat_completion_chunks(42, stream_req, FakeState(_stop_trimmed_replies()), spec))) + ) + tokens = [ + e["token"] + for event in events + if isinstance(event, dict) and event["choices"] and event["choices"][0].get("logprobs") + for e in event["choices"][0]["logprobs"]["content"] + ] + assert tokens == ["Hi"] + + def test_logprobs_validation_errors() -> None: chat_top_out_of_range = run( handle_chat_completion(chat_request(logprobs=True, top_logprobs=25), None, FakeState([]), {}) @@ -247,61 +567,292 @@ def test_logprobs_validation_errors() -> None: assert completion_echo.status_code == 400 -def test_chat_logprobs_fail_closed_under_semantic_parsing() -> None: - # A server-side reasoning parser hides tokens from message content, so - # logprob entries cannot be aligned with it: reject up front, stream and - # non-stream alike, before any engine work is submitted. - with_parser = FakeState([], reasoning_parser="qwen3") - for stream in (False, True): - resp = run( - handle_chat_completion(chat_request(logprobs=True, stream=stream), None, with_parser, {}) - ) - assert resp.status_code == 400 - assert json.loads(resp.body)["error"]["param"] == "logprobs" - - # Tool parsing consumes tokens into tool_calls -- same conflict. - tool = {"type": "function", "function": {"name": "f", "parameters": {"type": "object"}}} - tools_resp = run( - handle_chat_completion(chat_request(logprobs=True, tools=[tool]), None, FakeState([]), {}) +TOOL = { + "type": "function", + "function": { + "name": "read", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}, + }, +} +CALL = [ + "", "\n", "<", "function", "=read", ">", "\n", "<", "parameter", "=path", ">", + "\n", "a", ".py", "\n", "", "\n", "", "\n", + "", +] + + +def qwen_state(replies: list[UserReply], **kwargs) -> FakeState: + """The production stack: qwen3 reasoning parser and qwen3_coder tool parser.""" + return FakeState( + replies, + reasoning_parser="qwen3", + tool_call_parser="qwen3_coder", + tokenizer=kwargs.pop("tokenizer", QwenTokenizer()), + **kwargs, ) - assert tools_resp.status_code == 400 - assert json.loads(tools_resp.body)["error"]["param"] == "logprobs" - # tool_choice="none" disables parsing, so logprobs stay available. + +def _chat_both(replies: list[UserReply], **req_kwargs) -> tuple[dict, list[dict]]: + """The non-streaming response and the streamed chunk choices for the same replies.""" + req = chat_request(logprobs=True, **req_kwargs) + result = run(handle_chat_completion(req, None, qwen_state(replies), {})) + req = chat_request(logprobs=True, stream=True, **req_kwargs) + spec = chat_request_to_genspec(req, {}) + chunks = stream_chat_completion_chunks(42, req, qwen_state(replies), spec) + events = parse_sse(run(_collect(chunks))) + choices = [e["choices"][0] for e in events if isinstance(e, dict) and e.get("choices")] + return result, choices + + +def _streamed_tokens(choices: list[dict]) -> list[str]: + return [e["token"] for c in choices if c.get("logprobs") for e in c["logprobs"]["content"]] + + +def _content_tokens(result: dict) -> list[str]: + return [e["token"] for e in result["choices"][0]["logprobs"]["content"]] + + +def test_chat_logprobs_exclude_reasoning() -> None: + replies = token_replies(["thought", " more", "", "\n\n", "Hello", " world"]) + result, choices = _chat_both(replies) + + assert result["choices"][0]["message"]["reasoning_content"] == "thought more" + assert result["choices"][0]["logprobs"] == { + "content": result["choices"][0]["logprobs"]["content"], + "refusal": None, + } + assert _content_tokens(result) == ["Hello", " world"] + assert _streamed_tokens(choices) == ["Hello", " world"] + for choice in choices: + if choice["delta"].get("reasoning_content"): + assert "logprobs" not in choice + if choice.get("logprobs"): + assert choice["logprobs"]["refusal"] is None + + +def test_chat_logprobs_exclude_tool_call_markup() -> None: + replies = token_replies(["plan", "", "\n\n", "Sure", "."] + CALL, finish_reason="stop") + result, choices = _chat_both(replies, tools=[TOOL]) + + assert result["choices"][0]["message"]["tool_calls"][0]["function"]["name"] == "read" + assert _content_tokens(result) == ["Sure", "."] + assert _streamed_tokens(choices) == ["Sure", "."] + for choice in choices: + if choice["delta"].get("tool_calls"): + assert "logprobs" not in choice + + +def test_chat_logprobs_bare_function_call_is_not_content() -> None: + # The qwen3_coder parser takes a declared without its + # wrapper as a call; its markup tokens are not content either. + call = CALL[2:-2] + replies = token_replies(["plan", "", "Doing", " it", ".\n"] + call + ["\n", "ok"]) + result, choices = _chat_both(replies, tools=[TOOL]) + + assert result["choices"][0]["message"]["tool_calls"] + assert result["choices"][0]["message"]["content"] == "Doing it.\n\nok" + assert _content_tokens(result) == ["Doing", " it", ".\n", "\n", "ok"] + assert _streamed_tokens(choices) == _content_tokens(result) + + +def test_chat_logprobs_text_merged_into_a_bare_call_closer_is_content() -> None: + # Qwen3.8 merges the closing ">" with the next character (">n"): the emitted "n" + # sits at the end of "n", not on the "n" inside "function". + call = CALL[2:-3] + [">n"] + replies = token_replies(["plan", ""] + call) + result, choices = _chat_both(replies, tools=[TOOL]) + + assert result["choices"][0]["message"]["tool_calls"] + assert result["choices"][0]["message"]["content"] == "n" + assert _content_tokens(result) == [">n"] + assert _streamed_tokens(choices) == _content_tokens(result) + + +def test_chat_logprobs_leading_space_merged_into_a_bare_call_opener_is_not_content() -> None: + # The reasoning parser strips content's leading whitespace before the tool parser + # sees it, so the space of " <" is never message text. + call = [" <"] + CALL[3:-2] + replies = token_replies(["x", ""] + call) + result, choices = _chat_both(replies, tools=[TOOL]) + + assert result["choices"][0]["message"]["tool_calls"] + assert not result["choices"][0]["message"]["content"] + assert _content_tokens(result) == [] + assert _streamed_tokens(choices) == [] + + +def _stop_cut_replies(tokens: list[str], visible: int) -> list[UserReply]: + """A stop string matched inside the last token: only its first ``visible`` chars + were released, and the detokenizer says so on its entry.""" + texts = tokens[:-1] + [tokens[-1][:visible]] + replies = token_replies(tokens, finish_reason="length", texts=texts) + replies[-1].finish_reason = "stop" + replies[-1].logprobs = [{**replies[-1].logprobs[0], "stop_visible": visible}] + return replies + + +def test_chat_logprobs_follow_the_visible_part_of_a_token_a_stop_string_cut() -> None: + # Only the trailing space of " !" survives the stop "!", and content trimming drops it. + result, choices = _chat_both(_stop_cut_replies(["x", "", "Hi", " !"], 1), stop=["!"]) + assert result["choices"][0]["message"]["content"] == "Hi" + assert _content_tokens(result) == ["Hi"] + assert _streamed_tokens(choices) == ["Hi"] + + # A stop "_c" leaves " as text, not a call opener. + replies = _stop_cut_replies(["x", "", "Use", " ", ""], 5) + result, choices = _chat_both(replies, stop=["_c"], tools=[TOOL]) + assert result["choices"][0]["message"]["content"] == "Use "] + assert _streamed_tokens(choices) == _content_tokens(result) + + +def test_chat_logprobs_literal_markers_in_content_stay_content() -> None: + # Only a marker that switches state is markup: a later or a + # named in prose is message text, and so is its entry. + replies = token_replies( + ["plan", "", "Use", " ", " and", " ", " tags", "."] + ) + result, choices = _chat_both(replies, tools=[TOOL]) + + content = result["choices"][0]["message"]["content"] + assert content == "Use and tags." + assert "".join(_content_tokens(result)) == content + assert _streamed_tokens(choices) == _content_tokens(result) + + +def test_chat_logprobs_ride_the_delta_that_releases_a_held_marker_prefix() -> None: + # The tool parser holds the "<" of " <" as a possible : the token's + # entry waits until all its text is out, then rides that delta, in order. + replies = token_replies(["", "a", " <", " b"]) + _, choices = _chat_both(replies, tools=[TOOL]) + + content_chunks = [c for c in choices if c["delta"].get("content")] + assert [c["delta"]["content"] for c in content_chunks] == ["a", " ", "< b"] + assert [ + [e["token"] for e in c["logprobs"]["content"]] if c.get("logprobs") else None + for c in content_chunks + ] == [["a"], None, [" <", " b"]] + + +def test_chat_logprobs_trailing_whitespace_is_dropped() -> None: + # Content after is trimmed at both ends, and so are its whitespace entries; + # whitespace between words is kept. + replies = token_replies(["", "\n\n", "Hi", "\n", "\n", "there", "\n\n"]) + result, choices = _chat_both(replies) + + assert result["choices"][0]["message"]["content"] == "Hi\n\nthere" + assert _content_tokens(result) == ["Hi", "\n", "\n", "there"] + assert _streamed_tokens(choices) == _content_tokens(result) + + +@pytest.mark.parametrize("seed", range(12)) +def test_chat_logprobs_stream_matches_non_stream_for_any_chunking(seed: int) -> None: + # The detokenizer can release a token's text on a later reply (a partial character, + # a stop prefix), leaving replies with no text at all: the streamed entries, joined + # across content chunks and the finish chunk, must still equal the one-shot list. + tokens = ( + [" ", "", "plan", " it", "", "\n\n", "Hi", " ", "<", "b", ">", "\n\n"] + + CALL + + ["\n", "Then", " more", "\n"] + ) + full = "".join(tokens) + rng = random.Random(seed) + cuts, end, prev = [], 0, 0 + for token in tokens: + end += len(token) + prev = rng.randint(prev, end) + cuts.append(prev) + cuts[-1] = len(full) + texts = [full[a:b] for a, b in zip([0] + cuts[:-1], cuts, strict=True)] + [""] + replies = token_replies(tokens, texts=texts) + + result, choices = _chat_both(replies, tools=[TOOL]) + assert result["choices"][0]["message"]["content"] == "Hi \n\n\nThen more" + assert _content_tokens(result) == ["Hi", " ", "<", "b", ">", "\n\n", "\n", "Then", " more"] + assert _streamed_tokens(choices) == _content_tokens(result) + + +def test_chat_logprobs_start_in_content_when_thinking_is_off() -> None: + replies = token_replies([" ", "Hi", " ", " there"]) + result, choices = _chat_both(replies, chat_template_kwargs={"enable_thinking": False}) + + assert result["choices"][0]["message"]["content"] == " Hi there" + assert _content_tokens(result) == [" ", "Hi", " ", " there"] + assert _streamed_tokens(choices) == _content_tokens(result) + + +def test_chat_logprobs_truncated_reasoning_has_no_content_entries() -> None: + replies = token_replies(["still", " thinking"], finish_reason="length") + result, choices = _chat_both(replies) + + assert result["choices"][0]["finish_reason"] == "length" + assert result["choices"][0]["logprobs"] == {"content": [], "refusal": None} + assert _streamed_tokens(choices) == [] + + +def test_chat_logprobs_drop_the_eos_entry() -> None: + # The end-of-turn token is not message text; non-streaming used to list it. + replies = token_replies(["Hi", "!"]) + state = FakeState(replies, tokenizer=QwenTokenizer()) + result = run(handle_chat_completion(chat_request(logprobs=True), None, state, {})) + assert _content_tokens(result) == ["Hi", "!"] + + +def test_chat_logprobs_unsupported_parser_families_still_400() -> None: + def status(state: FakeState, **kwargs) -> int | None: + resp = run(handle_chat_completion(chat_request(logprobs=True, **kwargs), None, state, {})) + if isinstance(resp, dict): + return None + assert json.loads(resp.body)["error"]["param"] == "logprobs" + return resp.status_code + + # A reasoning parser whose split the segmenter does not mirror. + assert status(FakeState([], reasoning_parser="glm", tokenizer=QwenTokenizer())) == 400 + # qwen3 whose think markers are not single tokens, or with no tokenizer to tell. + multi = QwenTokenizer(single_id_markers=False) + assert status(FakeState([], reasoning_parser="qwen3", tokenizer=multi)) == 400 + assert status(FakeState([], reasoning_parser="qwen3")) == 400 + for stream in (False, True): + assert status(FakeState([], reasoning_parser="qwen3"), stream=stream) == 400 + # A tool-call parser the segmenter cannot shadow... + assert status(FakeState([], tokenizer=QwenTokenizer()), tools=[TOOL]) == 400 + # ...unless tool parsing is off for the request. ok = run( handle_chat_completion( - chat_request(logprobs=True, tools=[tool], tool_choice="none"), + chat_request(logprobs=True, tools=[TOOL], tool_choice="none"), None, - FakeState([reply("Hi", finished=True, logprobs=entry(1, "Hi", -0.1))]), + FakeState(token_replies(["Hi"]), tokenizer=QwenTokenizer()), {}, ) ) - assert ok["choices"][0]["logprobs"]["content"][0]["token"] == "Hi" + assert _content_tokens(ok) == ["Hi"] -def test_reasoning_logprob_is_not_carried_to_content_delta() -> None: - # The generation-layer guard behind the API fail-close: even when a caller - # bypasses request validation, a hidden reasoning token's entry is dropped, - # never attached to a later visible delta (where its token string would leak). +def test_chat_logprobs_are_dropped_when_the_segmenter_cannot_build() -> None: + # The generation-layer guard behind the API check: a caller that bypasses it still + # never gets a reasoning token's entry on a visible delta. state = FakeState( - [ - reply("thought", logprobs=entry(1, "thought", -0.1)), - reply("answer", finished=True), - ], - reasoning_parser="qwen3", + token_replies(["thought", "", "answer"]), reasoning_parser="qwen3" ) req = chat_request(stream=True, logprobs=True, top_logprobs=2) spec = chat_request_to_genspec(req, {}) events = parse_sse(run(_collect(stream_chat_completion_chunks(42, req, state, spec)))) - content_choice = next( - event["choices"][0] - for event in events - if isinstance(event, dict) - and event["choices"] - and event["choices"][0]["delta"].get("content") == "answer" + choices = [e["choices"][0] for e in events if isinstance(e, dict) and e.get("choices")] + assert any(c["delta"].get("content") == "answer" for c in choices) + assert _streamed_tokens(choices) == [] + + +def test_generate_full_ignores_entries_when_logprobs_were_not_requested() -> None: + # The protocol-neutral result carries entries only for a request that asked for them, + # so an adapter reading it never sees unsegmented (reasoning) entries. + state = FakeState( + token_replies(["thought", "", "answer"]), reasoning_parser="qwen3" ) - assert "logprobs" not in content_choice + spec = chat_request_to_genspec(chat_request(), {}) + result = run(generate_full(42, spec, state)) + assert result.content == "answer" + assert result.logprobs == [] async def _collect(iterator): diff --git a/tests/server/test_qwen_semantic_special_tokens.py b/tests/server/test_qwen_semantic_special_tokens.py index 11e35ccb3e..da20fdde7a 100644 --- a/tests/server/test_qwen_semantic_special_tokens.py +++ b/tests/server/test_qwen_semantic_special_tokens.py @@ -118,8 +118,10 @@ def test_stream_and_nonstream_match_while_preserving_semantic_markers_and_litera "<|box_start|>(1,2),(3,4)<|box_end|>" "<|quad_start|>(1,2),(3,4),(5,6),(7,8)<|quad_end|>" ) + # The leaked marker sits after the think block: qwen3 opens on only as the + # first non-whitespace text, so "<|im_start|>" keeps "" as reasoning. text = ( - "<|im_start|>plananswer" + "plan<|im_start|>answer" "<|vision_start|><|image_pad|><|vision_end|>" f"{grounding} literal \"<|im_start|>\" and `<|image_pad|>`<|im_end|>" ) diff --git a/tests/server/test_reasoning_parser.py b/tests/server/test_reasoning_parser.py index 05c207d0e1..2580af83b9 100644 --- a/tests/server/test_reasoning_parser.py +++ b/tests/server/test_reasoning_parser.py @@ -44,10 +44,25 @@ class TestDeepSeekV32ReasoningParser(_ReasoningContract): class TestThinkReasoningParser(_ReasoningContract): + name = "glm" + opener = "" + closer = "" + + +class TestQwen3ReasoningParser(_ReasoningContract): name = "qwen3" opener = "" closer = "" + def test_markers_after_the_first_close_are_literal(self): + text = "plan\n\nSTART = \"\"\nEND = \"\"\n" + expected = ("plan", 'START = ""\nEND = ""') + parser = ReasoningParser(self.name, force_reasoning=True) + assert parser.parse_non_stream(text) == expected + parser = ReasoningParser(self.name, force_reasoning=True) + parts = [parser.parse_stream_chunk(char) for char in text] + [parser.flush()] + assert tuple("".join(pair[i] for pair in parts) for i in (0, 1)) == expected + class TestMiniMaxM3ReasoningParser(_ReasoningContract): name = "minimax_m3" diff --git a/tests/server/test_responses_api.py b/tests/server/test_responses_api.py index 6e7721b891..95fbba3ef1 100644 --- a/tests/server/test_responses_api.py +++ b/tests/server/test_responses_api.py @@ -338,8 +338,9 @@ def test_stream_tool_call_events(): # Route smoke tests # --------------------------------------------------------------------------- # class FakeState: - def __init__(self, outputs, finish_reason=None, cached_tokens=0, prefill_ms=0.0): + def __init__(self, outputs, finish_reason=None, cached_tokens=0, prefill_ms=0.0, reasoning_tokens=0): self._outputs = outputs + self._reasoning_tokens = reasoning_tokens # stamped on the terminal ack self._finish_reason = finish_reason # stamped on the terminal ack self._cached_tokens = cached_tokens # stamped on the first ack (admission reply) self._prefill_ms = prefill_ms # the scheduler stamps it on the first generated token @@ -366,6 +367,7 @@ async def wait_for_ack(self, uid): finish_reason=self._finish_reason if finished else None, prompt_tokens_delta=pt, completion_tokens_delta=ct, cached_tokens=self._cached_tokens if i == 0 else 0, + reasoning_tokens=self._reasoning_tokens if finished else 0, prefill_ms=self._prefill_ms if i == 0 else 0.0) async def stream_with_cancellation(self, gen, request, uid): @@ -780,6 +782,27 @@ def test_stream_length_truncation_emits_incomplete_event(): assert final["incomplete_details"]["reason"] == "max_output_tokens" +def test_route_reports_reasoning_tokens(): + # Codex shows usage.output_tokens_details.reasoning_tokens; it was hardcoded to 0. + def usage(stream): + client = _client(FakeState([("Hello", True, 5, 9)], reasoning_tokens=7)) + r = client.post("/v1/responses", json={"model": "gpt-x", "input": "hi", "stream": stream}) + assert r.status_code == 200, r.text + if not stream: + return r.json()["usage"] + final = [ + json.loads(line[len("data:"):]) + for line in r.text.splitlines() + if line.startswith("data:") and "response.completed" in line + ] + return final[-1]["response"]["usage"] + + for stream in (False, True): + u = usage(stream) + assert u["output_tokens"] == 9 + assert u["output_tokens_details"]["reasoning_tokens"] == 7 + + def test_route_length_truncation_reports_incomplete(): client = _client(FakeState([("partial", True, 5, 8192)], finish_reason="length")) r = client.post("/v1/responses", json={"model": "gpt-x", "input": "hi"}) diff --git a/tests/server/test_streaming_model_matrix.py b/tests/server/test_streaming_model_matrix.py index eb6e3a2ed2..2226fe7fe5 100644 --- a/tests/server/test_streaming_model_matrix.py +++ b/tests/server/test_streaming_model_matrix.py @@ -708,3 +708,212 @@ def norm(s): assert norm(_content(events)) == norm(full.content) assert [c.name for c in _calls(events)] == [c.name for c in full.tool_calls] == ["read"] + + +# --------------------------------------------------------------------------- # +# Axis 3: production Qwen3.8 parsers (qwen3 + qwen3_coder), stream == one-shot +# --------------------------------------------------------------------------- # +QWEN_TOOLS = TOOLS + [ + { + "type": "function", + "function": { + "name": "edit", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}, "new": {"type": "string"}}, + }, + }, + }, +] + + +def _qwen_parity(pieces, *, thinking=True, finish_reason=None): + """Feed token texts one per ack to the streaming path and joined to the + non-streaming path; assert both expose the same content, reasoning and calls + (exactly, no strip) and return them.""" + spec = GenSpec( + messages=[], sampling_params=None, parser_tools=QWEN_TOOLS, template_tools=QWEN_TOOLS, + chat_template_kwargs={} if thinking else {"enable_thinking": False}, + ) + + def state(chunks): + return FakeState(chunks, tool_call_parser="qwen3_coder", reasoning_parser="qwen3", + finish_reason=finish_reason) + + async def stream(chunks): + return [ev async for ev in generate_events(42, spec, state(chunks))] + + def calls(items): + return [(c.name, json.loads(c.parameters)) for c in items] + + full = asyncio.run(generate_full(42, spec, state(["".join(pieces)]))) + one_shot = (full.content, full.reasoning, calls(full.tool_calls), full.finish_reason) + # One token per delta (what the detokenizer sends), then the worst case: one char. + for chunks in (list(pieces), list("".join(pieces))): + events = asyncio.run(stream(chunks)) + streamed = (_content(events), _reasoning(events), calls(_calls(events)), + events[-1].finish_reason) + assert streamed == one_shot + return one_shot + + +EDIT_WITH_THINK_LITERALS = [ + "plan", "", "\n\n", "Edit:", "", "\n\n\n", + 'START = "', "", '"\nEND = "', "", '"\n', "\n\n", + "", +] + + +def test_qwen_think_literals_in_tool_arguments_stay_in_the_call(): + # Editing a parser's own source: the markers are argument data, and reasoning + # ended at the first for good. + content, reasoning, calls, finish = _qwen_parity(EDIT_WITH_THINK_LITERALS) + assert reasoning == "plan" + assert content == "Edit:" + assert calls == [("edit", {"new": 'START = ""\nEND = ""'})] + assert finish == "tool_calls" + + +@pytest.mark.parametrize("pieces, expected", [ + (["r", "", "\n\n", "Use ", "", " tags", " and ", "", "."], + ("Use tags and .", "r")), + (["r", "", "\n\n", "if a ", "<", " b:", " pass"], ("if a < b: pass", "r")), + (["r", "\n", "", "\n\nAnswer", " here", "\n"], ("Answer here", "r")), + (["\n", "r", "\n\n", "s", "", "\n\n", "A", "\n\n", "B"], ("A\n\nB", "r\n\ns")), + (["", "\n", "r", "", "\n\n", "a"], ("a", "r")), +], ids=["think-literals-in-content", "code-lt", "ws-glued", "ws-separate", "explicit-think"]) +def test_qwen_reasoning_split_parity(pieces, expected): + content, reasoning, calls, _ = _qwen_parity(pieces) + assert (content, reasoning) == expected + assert calls == [] + + +@pytest.mark.parametrize("pieces, expected", [ + (["Use", " ", " here", " ", " too"], "Use here too"), + (["\n", "Plain", " answer", "\n"], "\nPlain answer\n"), +], ids=["think-literals", "literal-ws"]) +def test_qwen_start_in_content_is_literal(pieces, expected): + # enable_thinking=false: the template closed the think block, so nothing the + # model writes (except an explicit leading ) reopens it. + content, reasoning, calls, _ = _qwen_parity(pieces, thinking=False) + assert (content, reasoning, calls) == (expected, "", []) + + +def test_qwen_start_in_content_explicit_leading_think(): + content, reasoning, _, _ = _qwen_parity(["", "r", "", "\n\n", "a"], thinking=False) + assert (content, reasoning) == ("a", "r") + + +def test_qwen_truncated_in_reasoning(): + content, reasoning, calls, finish = _qwen_parity( + ["thinking", " more", "\n", "", "\n\n", "", "\n"] + _qwen_call("/a")) + assert (content, calls) == ("", [("read", {"filePath": "/a"})]) + + +def test_qwen_tool_call_only_turn(): + content, reasoning, calls, finish = _qwen_parity([ + "r", "\n", "", "\n\n", "", "\n", "", "\n", + "", "\n/tmp/test_calc.py\n", "", "\n", "", + "\n", "", + ]) + assert (content, reasoning, finish) == ("", "r", "tool_calls") + assert calls == [("read", READ_ARGS)] + + +def _qwen_call(path): + return ["", "\n", "", "\n", "", f"\n{path}\n", + "", "\n", "", "\n", ""] + + +@pytest.mark.parametrize("between, after, expected", [ + (["\nThen more.\n"], [], "\nThen more.\n"), + (["\n", "Then", " more.", "\n"], ["\n"], "\nThen more.\n"), + (["\n\n"], [" Done", "."], " Done."), + (["\n", "\n"], [" ", "Done", "."], " Done."), + (["\n"], ["\n\n", "
", "x"], "\n\n
x"), + (["\n"], [" ", "", " generic"], " generic"), + (["\n"], ["\n", ""], "\n"), + (["\n"], ["\n", "", " tags"], "\n tags"), +], ids=["glued", "separate", "ws-only-glued-tail", "ws-only-separate-tail", "ws-then-tag", + "space-then-tag", "stray-close", "literal-open"]) +def test_qwen_text_around_calls_parity(between, after, expected): + # Text before, between and after calls is content in both modes, verbatim. + # A whitespace-only gap between calls or after the last one is markup + # separation and is dropped, however it is chunked. + pieces = (["r", "", "\n\n", "Let me look.", "\n\n"] + _qwen_call("/a") + between + + _qwen_call("/b") + after) + content, reasoning, calls, finish = _qwen_parity(pieces) + assert content == "Let me look.\n\n" + expected + assert calls == [("read", {"filePath": "/a"}), ("read", {"filePath": "/b"})] + assert (reasoning, finish) == ("r", "tool_calls") + + +def test_qwen_empty_tool_block_is_dropped_in_both_modes(): + content, _, calls, finish = _qwen_parity( + ["r", "", "\n\n", "A", "", "\n", "", "\n", "B"]) + assert (content, calls, finish) == ("A\nB", [], "stop") + + +def test_qwen_truncated_inside_function_tag_is_not_recovered(): + # Cut off by max_tokens inside "\n", ">x<", "div>", " tags", " and splits", " them."], + ["Wrap calls in ", "", "...", "", " tags, then answer."], + ["Calls:\n\n", "", "\n", '{"name": "read"}', "\n", ""], +], ids=["opener", "pair", "not-an-invoke"]) +def test_qwen_tool_call_tag_in_prose_stays_content(pieces): + # followed by anything but an invoke or its close is the tag + # named in text, like a bare ", "\n\n"] + pieces) + assert (content, calls, finish) == ("".join(pieces), [], "stop") + + +BARE_EDIT = ["<", "function", "=", "edit", ">\n", "\n", "a.py\n", "\n", + ""] + + +def test_qwen_bare_function_is_a_call_in_both_modes(): + # The model skipped : an agent still needs the call executed, and + # neither mode may leave the markup in content. + content, _, calls, finish = _qwen_parity( + ["r", "", "\n\n", "Doing it.\n"] + BARE_EDIT + ["\n", "Done", "."]) + assert (content, finish) == ("Doing it.\n\nDone.", "tool_calls") + assert calls == [("edit", {"path": "a.py"})] + + +def test_qwen_bare_function_after_a_wrapped_call(): + content, _, calls, _ = _qwen_parity( + ["r", "", "\n\n"] + _qwen_call("/a") + ["\n"] + BARE_EDIT) + assert content == "" + assert calls == [("read", {"filePath": "/a"}), ("edit", {"path": "a.py"})] + + +def test_qwen_bare_function_swallows_a_stray_close(): + content, _, calls, _ = _qwen_parity( + ["r", "", "\n\n", "Doing it.\n"] + BARE_EDIT + ["\n", "", "\n", "Done"]) + assert content == "Doing it.\n\nDone" + assert calls == [("edit", {"path": "a.py"})] + + +@pytest.mark.parametrize("pieces", [ + ["Write ", "<", "function", "=", "name", ">", " tags."], + ["<", "function", "=", "nope", ">\n", "\n", "1\n", "\n", ""], + ["x ", "<", "function", "=", "read", ">", " y"], + ['p = "', "<", "function", "=", '"', " + name"], + ["if a ", "<", "f", "(b):", " pass"], + ["tail ", "<", "function"], +], ids=["undeclared-name", "unknown-tool", "no-body", "code-literal", "lt-call", "truncated-prefix"]) +def test_qwen_bare_function_lookalikes_stay_content(pieces): + content, _, calls, finish = _qwen_parity(["r", "", "\n\n"] + pieces) + assert (content, calls, finish) == ("".join(pieces), [], "stop") diff --git a/tests/tokenizer/test_detokenize_extras.py b/tests/tokenizer/test_detokenize_extras.py index 4717617b9a..01e02e63ee 100644 --- a/tests/tokenizer/test_detokenize_extras.py +++ b/tests/tokenizer/test_detokenize_extras.py @@ -6,11 +6,12 @@ from freetoken.message import DetokenizeMsg from freetoken.tokenizer.detokenize import DetokenizeManager -EOS, SPECIAL, THINK_END = 0, 99, 7 +EOS, SPECIAL, THINK_END, THINK_START = 0, 99, 7, 8 class _Tok: - """Token ids decode to letters; 99 is a special marker, 7 the reasoning end tag.""" + """Token ids decode to letters; 99 is a special marker, 7 / 8 the reasoning end / + start tags.""" eos_token_id = EOS unk_token_id = -1 @@ -23,6 +24,8 @@ def decode(self, ids, skip_special_tokens=False): out.append("") elif t == THINK_END: out.append("") + elif t == THINK_START: + out.append("") else: out.append(chr(ord("a") + t)) return "".join(out) @@ -39,7 +42,7 @@ def _run(manager, uid, tokens, *, finished_kwargs=None, **msg_kwargs): kwargs = dict(msg_kwargs) if last and finished_kwargs: kwargs.update(finished_kwargs) - strs, counts = manager.detokenize_with_meta( + strs, counts, _ = manager.detokenize_with_meta( [DetokenizeMsg(uid=uid, next_token=t, finished=last, **kwargs)] ) text += strs[0] @@ -81,6 +84,27 @@ def test_reasoning_tokens_count_up_to_the_end_tag(): assert none == 0 +def test_reasoning_tokens_when_generation_stops_inside_the_block(): + # The prompt pre-opened (qwen3, thinking on) and the output never closed it: + # every completion token was reasoning, whether max_tokens or EOS ended it. + manager = DetokenizeManager( + _Tok(), frozenset({EOS}), think_end_id=THINK_END, think_start_id=THINK_START + ) + _, by_length = _run(manager, 1, [1, 2, 3], starts_in_reasoning=True) + assert by_length == 3 + _, by_eos = _run(manager, 2, [1, 2, EOS], starts_in_reasoning=True) + assert by_eos == 3 + # the model opened the block itself + _, self_opened = _run(manager, 3, [THINK_START, 1, 2]) + assert self_opened == 3 + # thinking off: no block, no reasoning + _, off = _run(manager, 4, [1, 2, 3]) + assert off == 0 + # a closed block still counts up to the end tag + _, closed = _run(manager, 5, [1, THINK_END, 2, 3], starts_in_reasoning=True) + assert closed == 2 + + def test_a_batch_with_one_uid_twice_advances_its_state_in_order(): # each message must see the decode state the previous one left, and the reasoning # counts must stay aligned with the texts @@ -91,7 +115,156 @@ def test_a_batch_with_one_uid_twice_advances_its_state_in_order(): DetokenizeMsg(uid=2, next_token=3, finished=False), DetokenizeMsg(uid=1, next_token=2, finished=True), ] - texts, counts = manager.detokenize_with_meta(msgs) + texts, counts, offsets = manager.detokenize_with_meta(msgs) assert texts == ["b", "", "d", "c"] assert counts == [0, 0, 0, 2] + assert offsets == [0, 1, 0, 9] assert manager.detokenize([DetokenizeMsg(uid=2, next_token=4, finished=True)]) == ["e"] + + +def test_text_offset_is_where_the_token_starts_despite_holdback(): + # "c" is held back as a possible start of the stop "cx", then sent with "d": its + # offset is still where it starts, not how much text had been sent at its ack. + manager = DetokenizeManager(_Tok(), frozenset({EOS})) + sent, offsets = [], [] + for i, t in enumerate([1, 2, 3, EOS]): + strs, _, offs = manager.detokenize_with_meta( + [DetokenizeMsg(uid=1, next_token=t, finished=i == 3, stop_strs=["cx"])] + ) + sent.append(strs[0]) + offsets.append(offs[0]) + assert sent == ["b", "", "cd", ""] + assert offsets == [0, 1, 2, 3] + + +PARTIAL, COMPLETES = 50, 51 + + +class _ByteTok(_Tok): + """50 is the first half of a two-token character: alone it decodes to U+FFFD.""" + + def decode(self, ids, skip_special_tokens=False): + out = [] + for i, t in enumerate(ids): + if t == PARTIAL: + out.append("" if i + 1 < len(ids) and ids[i + 1] == COMPLETES else "�") + elif t == COMPLETES: + out.append("é" if i and ids[i - 1] == PARTIAL else "�") + else: + out.append(super().decode([t], skip_special_tokens)) + return "".join(out) + + +def _run_logprobs(manager, tokens, *, finished_kwargs=None, **msg_kwargs): + """(text, entry token ids) per reply, each token's entry carrying its own id.""" + replies = [] + for i, t in enumerate(tokens): + last = i == len(tokens) - 1 + kwargs = dict(msg_kwargs) + if last and finished_kwargs: + kwargs.update(finished_kwargs) + texts, _, released = manager.detokenize_with_logprobs( + [DetokenizeMsg(uid=1, next_token=t, finished=last, **kwargs)], [{"token_id": t}] + ) + replies.append( + (texts[0], [(e["token_id"], bool(e.get("stop_trimmed"))) for e in released[0]]) + ) + return replies + + +def test_logprob_entries_of_stop_trimmed_tokens_are_marked(): + manager = DetokenizeManager(_Tok(), frozenset({EOS})) + assert _run_logprobs( + manager, [1, 2, 3], stop_strs=["cd"], finished_kwargs={"matched_stop": "cd"} + ) == [("b", [(1, False)]), ("", []), ("", [(2, True), (3, True)])] + + +def test_logprob_entry_of_a_token_a_stop_string_cuts_inside_carries_its_visible_length(): + # "bc" with stop "k>c": " token stays visible. + manager = DetokenizeManager(_Tok(), frozenset({EOS})) + final = None + for i, t in enumerate([1, THINK_END, 2]): + last = i == 2 + _, _, released = manager.detokenize_with_logprobs( + [DetokenizeMsg(uid=1, next_token=t, finished=last, stop_strs=["k>c"], + matched_stop="k>c" if last else None)], + [{"token_id": t}], + ) + final = released[0] + # each entry also carries where its token starts in the text ("b" is 1 char, "" 8) + assert final == [ + {"token_id": THINK_END, "text_offset": 1, "stop_visible": 6}, + {"token_id": 2, "text_offset": 9, "stop_trimmed": True}, + ] + + +def test_logprob_entry_waits_for_its_text_when_a_stop_prefix_is_released(): + # "b" is held as a prefix of "bc"; the second "b" releases the first, but its own + # text is held, so its entry must not ride that reply -- the stop then trims it. + manager = DetokenizeManager(_Tok(), frozenset({EOS})) + assert _run_logprobs( + manager, [1, 1, 2], stop_strs=["bc"], finished_kwargs={"matched_stop": "bc"} + ) == [("", []), ("b", [(1, False)]), ("", [(1, True), (2, True)])] + + +def test_logprob_entries_kept_with_the_stop_string_or_without_a_match(): + manager = DetokenizeManager(_Tok(), frozenset({EOS})) + kept = _run_logprobs( + manager, [1, 2, 3], stop_strs=["cd"], keep_stop_str=True, + finished_kwargs={"matched_stop": "cd"}, + ) + assert kept[-1] == ("cd", [(2, False), (3, False)]) + # finished by length while "c" was held as a stop prefix: nothing was trimmed + by_length = _run_logprobs(manager, [1, 2], stop_strs=["cd"]) + assert by_length == [("b", [(1, False)]), ("c", [(2, False)])] + # no stop strings: every entry rides its own reply, as before + plain = _run_logprobs(manager, [1, 2, EOS], finished_kwargs={"finish_reason": "stop"}) + assert plain == [("b", [(1, False)]), ("c", [(2, False)]), ("", [(EOS, False)])] + + +def test_logprob_entry_of_a_partial_character_rides_the_reply_that_completes_it(): + manager = DetokenizeManager(_ByteTok(), frozenset({EOS})) + assert _run_logprobs(manager, [1, PARTIAL, COMPLETES, 2], stop_strs=["zz"]) == [ + ("b", [(1, False)]), + ("", []), + ("é", [(PARTIAL, False), (COMPLETES, False)]), + ("c", [(2, False)]), + ] + + +def test_logprob_entries_released_together_keep_their_own_text_offsets(): + # "c" is held as a prefix of "cx" and released with "d": that reply carries both + # entries, each stamped with where its own token starts, not one per-reply offset. + manager = DetokenizeManager(_Tok(), frozenset({EOS})) + replies = [] + for i, t in enumerate([1, 2, 3, EOS]): + texts, _, released = manager.detokenize_with_logprobs( + [DetokenizeMsg(uid=1, next_token=t, finished=i == 3, stop_strs=["cx"])], + [{"token_id": t}], + ) + replies.append((texts[0], [(e["token_id"], e["text_offset"]) for e in released[0]])) + assert replies == [("b", [(1, 0)]), ("", []), ("cd", [(2, 1), (3, 2)]), ("", [(EOS, 3)])] + + +def test_held_logprob_entries_release_on_a_message_without_its_own_entry(): + # "c" is held as a prefix of "cx"; the messages that release it carry no entry + # (and skip the hold bookkeeping when nothing is held), yet "c"'s entry still rides them. + def run(tokens, entries, last_finishes): + manager = DetokenizeManager(_Tok(), frozenset({EOS})) + replies = [] + for i, (t, entry) in enumerate(zip(tokens, entries, strict=True)): + finished = last_finishes and i == len(tokens) - 1 + texts, _, released = manager.detokenize_with_logprobs( + [DetokenizeMsg(uid=1, next_token=t, finished=finished, stop_strs=["cx"])], [entry] + ) + replies.append((texts[0], [e["token_id"] for e in released[0]])) + assert manager.decode_map.get(1) is None or not manager.decode_map[1].held_logprobs + return replies + + e = [{"token_id": t} for t in (1, 2)] + # released mid-stream by "d", whose message has no entry + assert run([1, 2, 3], [*e, None], False) == [("b", [1]), ("", []), ("cd", [2])] + # flushed by the finishing message, which has no entry + assert run([1, 2, EOS], [*e, None], True) == [("b", [1]), ("", []), ("c", [2])] + # a request without logprobs never carries any + assert run([1, 2, 3], [None] * 3, True) == [("b", []), ("", []), ("cd", [])] diff --git a/tests/tokenizer/test_logprobs_entry.py b/tests/tokenizer/test_logprobs_entry.py index 89cf35cb5e..978824a34d 100644 --- a/tests/tokenizer/test_logprobs_entry.py +++ b/tests/tokenizer/test_logprobs_entry.py @@ -1,3 +1,7 @@ +import os + +import pytest + from freetoken.tokenizer.detokenize import build_logprobs_entry @@ -57,3 +61,53 @@ def test_build_logprobs_entry_empty_top() -> None: ) assert entry["top"] == [] + + +def _byte_level_tokenizer(): + """A tiny GPT-2-style ByteLevel BPE tokenizer built in memory (no download): the 256 + byte stand-ins, one merge ("ðŁ", the first two bytes of most emoji) and one added + token whose content the byte table cannot invert.""" + from tokenizers import AddedToken, Tokenizer, decoders, models, pre_tokenizers + from transformers import PreTrainedTokenizerFast + + vocab = {c: i for i, c in enumerate(sorted(pre_tokenizers.ByteLevel.alphabet()))} + vocab["ðŁ"] = len(vocab) + tk = Tokenizer(models.BPE(vocab=vocab, merges=[("ð", "Ł")])) + tk.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False, use_regex=False) + tk.decoder = decoders.ByteLevel() + tk.add_tokens([AddedToken("<|end|>", special=False)]) + return PreTrainedTokenizerFast(tokenizer_object=tk) + + +def test_logprobs_bytes_rebuild_a_split_character() -> None: + # 🦀 is three byte-level tokens that each decode to U+FFFD; their bytes must still + # concatenate back to the character. + tokenizer = _byte_level_tokenizer() + ids = tokenizer.encode("🦀", add_special_tokens=False) + assert len(ids) == 3 + entries = [build_logprobs_entry(tokenizer, i, -0.5, [i], [-0.5]) for i in ids] + assert all(e["token"] == "�" for e in entries) + assert bytes(b for e in entries for b in e["bytes"]) == "🦀".encode("utf-8") + assert [e["top"][0]["bytes"] for e in entries] == [e["bytes"] for e in entries] + + +def test_logprobs_bytes_added_and_padded_ids() -> None: + tokenizer = _byte_level_tokenizer() + added = tokenizer.convert_tokens_to_ids("<|end|>") + padded = len(tokenizer) + 10 # the logits' padded tail: no vocab entry at all + entry = build_logprobs_entry(tokenizer, added, -0.1, [added, padded], [-0.1, -9.0]) + assert entry["token"] == "<|end|>" + assert entry["bytes"] == list("<|end|>".encode("utf-8")) + assert entry["top"][1] == {"token_id": padded, "token": "", "bytes": [], "logprob": -9.0} + + +def test_logprobs_bytes_real_qwen_tokenizer() -> None: + path = os.path.expanduser("~/models/Qwen3.8-Flash-Next-NVFP4-FTW-fp8") + if not os.path.exists(os.path.join(path, "tokenizer.json")): + pytest.skip("local Qwen3.8 tokenizer not present") + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(path) + entries = [build_logprobs_entry(tokenizer, i, -0.1, [248300], [-9.0]) for i in (9008, 99, 222)] + assert bytes(b for e in entries for b in e["bytes"]) == "🦀".encode("utf-8") + assert entries[0]["top"][0]["bytes"] == []