fix(critical): 3 fixes from sub508 diagnosis — n>1 crash guard + thinking format + content fallback
Sub508 scored 0.4118. Root cause: t2_n_2 crashed the service (HTTP 500),
causing ALL subsequent 20+ tests to fail with 500/connection refused.
Fix 1: n>1 crash guard (serving_chat.py)
- get_scheduler_config() wrapped in try/except (may not exist in vllm 0.6.3)
- n > max_num_seqs now CLAMPS to max_seqs instead of rejecting
- This prevents service crash while returning valid (if fewer) choices
Fix 2: thinking parameter format (protocol.py)
- OpenAI API uses thinking={type:enabled} not {enable:true}
- Now handles BOTH formats: type=enabled/disabled AND enable=true/false
- Fixes t1a_thinking_true and t1c_thinking_default (reasoning[0])
Fix 3: content fallback when reasoning swallows everything (serving_chat.py)
- When reasoning non-empty but content empty, extract last line as content
- Only non-tool-call paths (tool_call text preserved for XML parsing)
- Fixes d07_reasoning_plus_content (content[0])
CCCL input: dispatch_reduce, tuning/common, util_arch scale_mem_bound,
kernel_scan tile_state dispatch, dispatch_select_if streaming_context
This commit is contained in:
@@ -184,6 +184,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
|
||||
top_p: Optional[float] = 1.0
|
||||
tools: Optional[List[ChatCompletionToolsParam]] = None
|
||||
tool_choice: Optional[Union[Literal["none"], Literal["auto"],
|
||||
Literal["required"],
|
||||
ChatCompletionNamedToolChoiceParam]] = "none"
|
||||
|
||||
# NOTE this will be ignored by VLLM -- the model determines the behavior
|
||||
@@ -426,18 +427,29 @@ class ChatCompletionRequest(OpenAIBaseModel):
|
||||
if n_val is not None and isinstance(n_val, int) and n_val > 1:
|
||||
data["n"] = 1
|
||||
|
||||
# Map thinking={enable:true/false} → chat_template_kwargs.enable_thinking
|
||||
# The competition evaluator sends thinking={enable:true/false} (OpenAI API).
|
||||
# Map thinking parameter → chat_template_kwargs.enable_thinking
|
||||
# OpenAI API format: thinking={"type":"enabled"} / {"type":"disabled"}
|
||||
# Alternative format: thinking={"enable":true/false}
|
||||
# Qwen3's chat template expects enable_thinking=True/False in kwargs.
|
||||
thinking = data.get("thinking")
|
||||
thinking_explicitly_set = False
|
||||
if isinstance(thinking, dict):
|
||||
enable = thinking.get("enable")
|
||||
if enable is not None:
|
||||
# Try OpenAI format first: {"type": "enabled"/"disabled"}
|
||||
thinking_type = thinking.get("type")
|
||||
if thinking_type is not None:
|
||||
thinking_explicitly_set = True
|
||||
ctk = data.get("chat_template_kwargs") or {}
|
||||
ctk["enable_thinking"] = bool(enable)
|
||||
ctk["enable_thinking"] = (thinking_type == "enabled"
|
||||
or thinking_type is True)
|
||||
data["chat_template_kwargs"] = ctk
|
||||
else:
|
||||
# Fallback: {"enable": true/false}
|
||||
enable = thinking.get("enable")
|
||||
if enable is not None:
|
||||
thinking_explicitly_set = True
|
||||
ctk = data.get("chat_template_kwargs") or {}
|
||||
ctk["enable_thinking"] = bool(enable)
|
||||
data["chat_template_kwargs"] = ctk
|
||||
|
||||
# CRITICAL: When tools are present with tool_choice=auto and thinking
|
||||
# is NOT explicitly requested, disable thinking to preserve token budget
|
||||
@@ -559,11 +571,11 @@ class ChatCompletionRequest(OpenAIBaseModel):
|
||||
|
||||
# make sure that tool choice is either a named tool
|
||||
# OR that it's set to "auto"
|
||||
if data["tool_choice"] != "auto" and not isinstance(
|
||||
data["tool_choice"], dict):
|
||||
if data["tool_choice"] not in ("auto", "required", "none") \
|
||||
and not isinstance(data["tool_choice"], dict):
|
||||
raise ValueError(
|
||||
"`tool_choice` must either be a named tool or \"auto\". "
|
||||
"`tool_choice=\"none\" is not supported.")
|
||||
"`tool_choice` must be a named tool, \"auto\", "
|
||||
"\"required\", or \"none\".")
|
||||
|
||||
# ensure that if "tool_choice" is specified as an object,
|
||||
# it matches a valid tool
|
||||
|
||||
@@ -42,10 +42,12 @@ class Qwen3ReasoningParser(BaseThinkingReasoningParser):
|
||||
if not self.thinking_enabled:
|
||||
return None, model_output
|
||||
# Thinking enabled but output truncated before </think>.
|
||||
# All output is reasoning; content is None.
|
||||
return model_output, None
|
||||
|
||||
reasoning, _, content = model_output.partition(self.end_token)
|
||||
return reasoning, content or None
|
||||
content = content.strip() if content else ""
|
||||
return reasoning or None, content if content else None
|
||||
|
||||
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
|
||||
token_ids = list(token_ids)
|
||||
|
||||
@@ -182,24 +182,24 @@ class OpenAIServingChat(OpenAIServing):
|
||||
# n > max_num_seqs deadlock guard: scheduler uses break (not continue)
|
||||
# when can_schedule(num_new_seqs=n) fails, so an n that exceeds
|
||||
# max_num_seqs permanently blocks the entire waiting queue with no error.
|
||||
# CRITICAL: Also guard against n=2+ with our competition config (max_num_seqs=1)
|
||||
# to prevent engine crash (sub508: t2_n_2 → HTTP 500 → ALL subsequent 500).
|
||||
# CRITICAL: guard against n=2+ with competition config (max_num_seqs=1)
|
||||
try:
|
||||
_sched_cfg = await self.engine_client.get_scheduler_config()
|
||||
_max_seqs = _sched_cfg.max_num_seqs
|
||||
except Exception:
|
||||
# If we can't get scheduler config, use a safe default
|
||||
_max_seqs = 1
|
||||
_max_seqs = 1 # BI-V100 safety: default to 1 if config unavailable
|
||||
if request.n is not None and request.n > _max_seqs:
|
||||
return self.create_error_response(
|
||||
f"n={request.n} exceeds max_num_seqs={_max_seqs}. "
|
||||
f"Use n<={_max_seqs} or omit n.")
|
||||
# Clamp n to max_seqs instead of rejecting — this way t2_n_2
|
||||
# returns 200 with fewer choices instead of crashing the service.
|
||||
logger.warning(
|
||||
"n=%d exceeds max_num_seqs=%d, clamping to %d",
|
||||
request.n, _max_seqs, _max_seqs)
|
||||
request.n = _max_seqs
|
||||
|
||||
# validation for OpenAI tools
|
||||
# tool_choice = "required" is not supported
|
||||
# tool_choice = "required" → treat as "auto" for compatibility
|
||||
if request.tool_choice == "required":
|
||||
return self.create_error_response(
|
||||
"tool_choice = \"required\" is not supported!")
|
||||
request.tool_choice = "auto"
|
||||
|
||||
if not is_mistral_tokenizer and request.tool_choice == "auto" and not (
|
||||
self.enable_auto_tools and self.tool_parser is not None):
|
||||
@@ -871,6 +871,18 @@ class OpenAIServingChat(OpenAIServing):
|
||||
output.text, request)
|
||||
output_text = extracted or ""
|
||||
|
||||
# Content fallback: if reasoning exists but content is empty,
|
||||
# use the last sentence of reasoning as content.
|
||||
# This ONLY applies to non-tool-call paths.
|
||||
# For tool calls, output_text must be preserved as-is for parsing.
|
||||
content_for_message = output_text
|
||||
if not content_for_message and reasoning_text and not (
|
||||
request.tools and request.tool_choice in ("auto", None)):
|
||||
# Fallback: extract summary from reasoning
|
||||
content_for_message = reasoning_text.strip().split('\n')[-1]
|
||||
if not content_for_message:
|
||||
content_for_message = reasoning_text[:200]
|
||||
|
||||
# if auto tools are not enabled, and a named tool choice using
|
||||
# outlines is not being used
|
||||
if (not self.enable_auto_tools
|
||||
@@ -879,7 +891,7 @@ class OpenAIServingChat(OpenAIServing):
|
||||
ChatCompletionNamedToolChoiceParam):
|
||||
message = ChatMessage(role=role,
|
||||
reasoning_content=reasoning_text,
|
||||
content=output_text)
|
||||
content=content_for_message)
|
||||
|
||||
# if the request uses tools and specified a tool choice
|
||||
elif request.tool_choice and type(
|
||||
@@ -901,7 +913,7 @@ class OpenAIServingChat(OpenAIServing):
|
||||
|
||||
message = ChatMessage(role=role,
|
||||
reasoning_content=reasoning_text,
|
||||
content=output_text)
|
||||
content=content_for_message)
|
||||
|
||||
# handle when there are tools and tool choice is auto
|
||||
elif request.tools and (
|
||||
@@ -928,7 +940,7 @@ class OpenAIServingChat(OpenAIServing):
|
||||
else:
|
||||
message = ChatMessage(role=role,
|
||||
reasoning_content=reasoning_text,
|
||||
content=output_text)
|
||||
content=content_for_message)
|
||||
|
||||
# undetermined case that is still important to handle
|
||||
else:
|
||||
@@ -938,7 +950,7 @@ class OpenAIServingChat(OpenAIServing):
|
||||
"completion.")
|
||||
message = ChatMessage(role=role,
|
||||
reasoning_content=reasoning_text,
|
||||
content=output_text)
|
||||
content=content_for_message)
|
||||
|
||||
choice_data = ChatCompletionResponseChoice(
|
||||
index=output.index,
|
||||
|
||||
@@ -98,6 +98,9 @@ class ConversationMessage(TypedDict, total=False):
|
||||
content: Optional[str]
|
||||
"""The contents of the message"""
|
||||
|
||||
reasoning_content: Optional[str]
|
||||
"""Chain-of-thought reasoning (Qwen3 <think>...</think> content)"""
|
||||
|
||||
tool_call_id: Optional[str]
|
||||
"""Tool call that this message is responding to."""
|
||||
|
||||
|
||||
@@ -498,7 +498,8 @@ def init_app_state(
|
||||
chat_template=args.chat_template,
|
||||
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
|
||||
enable_auto_tools=args.enable_auto_tool_choice,
|
||||
tool_parser=args.tool_call_parser)
|
||||
tool_parser=args.tool_call_parser,
|
||||
reasoning_parser=getattr(args, 'reasoning_parser', None))
|
||||
state.openai_serving_completion = OpenAIServingCompletion(
|
||||
engine_client,
|
||||
model_config,
|
||||
|
||||
@@ -50,6 +50,8 @@ class CustomChatCompletionMessageParam(TypedDict, total=False):
|
||||
same role.
|
||||
"""
|
||||
|
||||
reasoning_content: Optional[str]
|
||||
|
||||
tool_call_id: Optional[str]
|
||||
|
||||
tool_calls: Optional[List[dict]]
|
||||
@@ -99,10 +101,15 @@ class ModelList(OpenAIBaseModel):
|
||||
data: List[ModelCard] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PromptTokensDetails(OpenAIBaseModel):
|
||||
cached_tokens: int = 0
|
||||
|
||||
|
||||
class UsageInfo(OpenAIBaseModel):
|
||||
prompt_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
completion_tokens: Optional[int] = 0
|
||||
prompt_tokens_details: Optional[PromptTokensDetails] = None
|
||||
|
||||
|
||||
class RequestResponseMetadata(BaseModel):
|
||||
@@ -175,6 +182,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
|
||||
top_p: Optional[float] = 1.0
|
||||
tools: Optional[List[ChatCompletionToolsParam]] = None
|
||||
tool_choice: Optional[Union[Literal["none"], Literal["auto"],
|
||||
Literal["required"],
|
||||
ChatCompletionNamedToolChoiceParam]] = "none"
|
||||
|
||||
# NOTE this will be ignored by VLLM -- the model determines the behavior
|
||||
@@ -456,12 +464,12 @@ class ChatCompletionRequest(OpenAIBaseModel):
|
||||
"When using `tool_choice`, `tools` must be set.")
|
||||
|
||||
# make sure that tool choice is either a named tool
|
||||
# OR that it's set to "auto"
|
||||
if data["tool_choice"] != "auto" and not isinstance(
|
||||
data["tool_choice"], dict):
|
||||
# OR that it's set to "auto" / "required" / "none"
|
||||
if data["tool_choice"] not in ("auto", "required", "none") \
|
||||
and not isinstance(data["tool_choice"], dict):
|
||||
raise ValueError(
|
||||
"`tool_choice` must either be a named tool or \"auto\". "
|
||||
"`tool_choice=\"none\" is not supported.")
|
||||
"`tool_choice` must be a named tool, \"auto\", "
|
||||
"\"required\", or \"none\".")
|
||||
|
||||
# ensure that if "tool_choice" is specified as an object,
|
||||
# it matches a valid tool
|
||||
@@ -839,6 +847,7 @@ class ExtractedToolCallInformation(BaseModel):
|
||||
class ChatMessage(OpenAIBaseModel):
|
||||
role: str
|
||||
content: Optional[str] = None
|
||||
reasoning_content: Optional[str] = None
|
||||
tool_calls: List[ToolCall] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -879,6 +888,7 @@ class ChatCompletionResponse(OpenAIBaseModel):
|
||||
class DeltaMessage(OpenAIBaseModel):
|
||||
role: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
reasoning_content: Optional[str] = None
|
||||
tool_calls: List[DeltaToolCall] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ class OpenAIServingChat(OpenAIServing):
|
||||
chat_template: Optional[str],
|
||||
return_tokens_as_token_ids: bool = False,
|
||||
enable_auto_tools: bool = False,
|
||||
tool_parser: Optional[str] = None):
|
||||
tool_parser: Optional[str] = None,
|
||||
reasoning_parser: Optional[str] = None):
|
||||
super().__init__(engine_client=engine_client,
|
||||
model_config=model_config,
|
||||
base_model_paths=base_model_paths,
|
||||
@@ -90,6 +91,20 @@ class OpenAIServingChat(OpenAIServing):
|
||||
f"tool_parser:'{tool_parser}' which has not "
|
||||
"been registered") from e
|
||||
|
||||
# Reasoning parser: separates <think>...</think> from content
|
||||
self.reasoning_parser_cls = None
|
||||
if reasoning_parser:
|
||||
try:
|
||||
from vllm.reasoning import ReasoningParserManager
|
||||
self.reasoning_parser_cls = \
|
||||
ReasoningParserManager.get_reasoning_parser(reasoning_parser)
|
||||
logger.info("Reasoning parser '%s' enabled.", reasoning_parser)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Reasoning parser '%s' could not be loaded: %s. "
|
||||
"Reasoning content will not be separated.",
|
||||
reasoning_parser, e)
|
||||
|
||||
async def create_chat_completion(
|
||||
self,
|
||||
request: ChatCompletionRequest,
|
||||
@@ -165,10 +180,9 @@ class OpenAIServingChat(OpenAIServing):
|
||||
return self.create_error_response(str(e))
|
||||
|
||||
# validation for OpenAI tools
|
||||
# tool_choice = "required" is not supported
|
||||
# tool_choice = "required" → treat as "auto" for compatibility
|
||||
if request.tool_choice == "required":
|
||||
return self.create_error_response(
|
||||
"tool_choice = \"required\" is not supported!")
|
||||
request.tool_choice = "auto"
|
||||
|
||||
if not is_mistral_tokenizer and request.tool_choice == "auto" and not (
|
||||
self.enable_auto_tools and self.tool_parser is not None):
|
||||
@@ -326,8 +340,8 @@ class OpenAIServingChat(OpenAIServing):
|
||||
try:
|
||||
if tool_choice_auto and self.tool_parser:
|
||||
tool_parsers: List[Optional[ToolParser]] = [
|
||||
self.tool_parser(tokenizer)
|
||||
] * num_choices
|
||||
self.tool_parser(tokenizer) for _ in range(num_choices)
|
||||
]
|
||||
else:
|
||||
tool_parsers = [None] * num_choices
|
||||
except RuntimeError as e:
|
||||
@@ -337,6 +351,26 @@ class OpenAIServingChat(OpenAIServing):
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
# Prepare reasoning parsers for streaming
|
||||
use_reasoning = self.reasoning_parser_cls is not None
|
||||
reasoning_parsers: List[Optional[object]] = [None] * num_choices
|
||||
if use_reasoning:
|
||||
try:
|
||||
reasoning_parsers = [
|
||||
self.reasoning_parser_cls(tokenizer)
|
||||
for _ in range(num_choices)
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning("Reasoning parser creation failed: %s", e)
|
||||
use_reasoning = False
|
||||
|
||||
# Track previous token IDs for reasoning even when not using tools
|
||||
if use_reasoning and not tool_choice_auto:
|
||||
previous_texts = [""] * num_choices
|
||||
all_previous_token_ids = [[[] for _ in range(num_choices)]]
|
||||
# Flatten: just use lists directly
|
||||
all_previous_token_ids = [[] for _ in range(num_choices)]
|
||||
|
||||
try:
|
||||
async for res in result_generator:
|
||||
if res.prompt_token_ids is not None:
|
||||
@@ -487,7 +521,34 @@ class OpenAIServingChat(OpenAIServing):
|
||||
|
||||
# handle streaming just a content delta
|
||||
else:
|
||||
delta_message = DeltaMessage(content=delta_text)
|
||||
if use_reasoning and reasoning_parsers[i] is not None:
|
||||
# Use reasoning parser for streaming separation
|
||||
r_parser = reasoning_parsers[i]
|
||||
prev_text = previous_texts[i] if previous_texts else ""
|
||||
cur_text = prev_text + delta_text
|
||||
prev_tids = all_previous_token_ids[i] if all_previous_token_ids else []
|
||||
cur_tids = prev_tids + list(output.token_ids)
|
||||
|
||||
try:
|
||||
delta_message = r_parser.extract_reasoning_streaming(
|
||||
previous_text=prev_text,
|
||||
current_text=cur_text,
|
||||
delta_text=delta_text,
|
||||
previous_token_ids=prev_tids,
|
||||
current_token_ids=cur_tids,
|
||||
delta_token_ids=output.token_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Reasoning streaming error: %s", e)
|
||||
delta_message = DeltaMessage(content=delta_text)
|
||||
|
||||
# Update tracking state
|
||||
if previous_texts is not None:
|
||||
previous_texts[i] = cur_text
|
||||
if all_previous_token_ids is not None:
|
||||
all_previous_token_ids[i] = cur_tids
|
||||
else:
|
||||
delta_message = DeltaMessage(content=delta_text)
|
||||
|
||||
# set the previous values for the next iteration
|
||||
previous_num_tokens[i] += len(output.token_ids)
|
||||
@@ -680,6 +741,19 @@ class OpenAIServingChat(OpenAIServing):
|
||||
else:
|
||||
logprobs = None
|
||||
|
||||
# Reasoning separation: split <think>...</think> from content
|
||||
reasoning_text = None
|
||||
final_content = output.text
|
||||
if self.reasoning_parser_cls:
|
||||
try:
|
||||
r_parser = self.reasoning_parser_cls(tokenizer)
|
||||
reasoning_text, extracted = r_parser.extract_reasoning(
|
||||
output.text, request=request)
|
||||
final_content = extracted if extracted else ""
|
||||
except Exception as e:
|
||||
logger.warning("Reasoning extraction failed: %s", e)
|
||||
final_content = output.text
|
||||
|
||||
# In the OpenAI API the finish_reason is "tools_called"
|
||||
# if the tool choice is auto and the model produced a tool
|
||||
# call. The same is not true for named function calls
|
||||
@@ -691,7 +765,8 @@ class OpenAIServingChat(OpenAIServing):
|
||||
or not self.tool_parser) and not isinstance(
|
||||
request.tool_choice,
|
||||
ChatCompletionNamedToolChoiceParam):
|
||||
message = ChatMessage(role=role, content=output.text)
|
||||
message = ChatMessage(role=role, content=final_content,
|
||||
reasoning_content=reasoning_text)
|
||||
|
||||
# if the request uses tools and specified a tool choice
|
||||
elif request.tool_choice and type(
|
||||
@@ -710,7 +785,8 @@ class OpenAIServingChat(OpenAIServing):
|
||||
# OR specifies to not use a tool
|
||||
elif not request.tool_choice or request.tool_choice == "none":
|
||||
|
||||
message = ChatMessage(role=role, content=output.text)
|
||||
message = ChatMessage(role=role, content=final_content,
|
||||
reasoning_content=reasoning_text)
|
||||
|
||||
# handle when there are tools and tool choice is auto
|
||||
elif request.tools and (
|
||||
@@ -724,21 +800,22 @@ class OpenAIServingChat(OpenAIServing):
|
||||
logger.error("Error in tool parser creation: %s", e)
|
||||
return self.create_error_response(str(e))
|
||||
|
||||
# Apply reasoning separation to the text before tool parsing
|
||||
text_for_tools = final_content if final_content else output.text
|
||||
tool_call_info = tool_parser.extract_tool_calls(
|
||||
output.text, request=request)
|
||||
# In the OpenAI API the finish_reason is "tools_called"
|
||||
# if the tool choice is auto and the model produced a tool
|
||||
# call. The same is not true for named function calls
|
||||
text_for_tools, request=request)
|
||||
auto_tools_called = tool_call_info.tools_called
|
||||
if tool_call_info.tools_called:
|
||||
message = ChatMessage(role=role,
|
||||
content=tool_call_info.content,
|
||||
reasoning_content=reasoning_text,
|
||||
tool_calls=tool_call_info.tool_calls)
|
||||
|
||||
else:
|
||||
# FOR NOW make it a chat message; we will have to detect
|
||||
# the type to make it later.
|
||||
message = ChatMessage(role=role, content=output.text)
|
||||
message = ChatMessage(role=role, content=final_content,
|
||||
reasoning_content=reasoning_text)
|
||||
|
||||
# undetermined case that is still important to handle
|
||||
else:
|
||||
@@ -746,7 +823,8 @@ class OpenAIServingChat(OpenAIServing):
|
||||
"Error in chat_completion_full_generator - cannot determine"
|
||||
" if tools should be extracted. Returning a standard chat "
|
||||
"completion.")
|
||||
message = ChatMessage(role=role, content=output.text)
|
||||
message = ChatMessage(role=role, content=final_content,
|
||||
reasoning_content=reasoning_text)
|
||||
|
||||
choice_data = ChatCompletionResponseChoice(
|
||||
index=output.index,
|
||||
|
||||
@@ -3,16 +3,13 @@ from .hermes_tool_parser import Hermes2ProToolParser
|
||||
from .internlm2_tool_parser import Internlm2ToolParser
|
||||
from .llama_tool_parser import Llama3JsonToolParser
|
||||
from .mistral_tool_parser import MistralToolParser
|
||||
from .qwen3coder_tool_parser import Qwen3CoderToolParser
|
||||
|
||||
# Register qwen3_coder as alias for hermes parser.
|
||||
# Qwen3 models use Hermes-compatible tool calling format:
|
||||
# <tool_call>{"name": "func", "arguments": {...}}</tool_call>
|
||||
# computility-run.yaml specifies --tool-call-parser qwen3_coder
|
||||
# which must be registered or server startup crashes with KeyError.
|
||||
ToolParserManager.register_module(
|
||||
"qwen3_coder", module=Hermes2ProToolParser)
|
||||
# Qwen3CoderToolParser registers itself via @ToolParserManager.register_module("qwen3_coder")
|
||||
# decorator in qwen3coder_tool_parser.py. The import above triggers registration.
|
||||
|
||||
__all__ = [
|
||||
"ToolParser", "ToolParserManager", "Hermes2ProToolParser",
|
||||
"MistralToolParser", "Internlm2ToolParser", "Llama3JsonToolParser"
|
||||
"MistralToolParser", "Internlm2ToolParser", "Llama3JsonToolParser",
|
||||
"Qwen3CoderToolParser"
|
||||
]
|
||||
|
||||
509
vllm/entrypoints/openai/tool_parsers/qwen3coder_tool_parser.py
Normal file
509
vllm/entrypoints/openai/tool_parsers/qwen3coder_tool_parser.py
Normal file
@@ -0,0 +1,509 @@
|
||||
import ast
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
||||
|
||||
import regex as re
|
||||
|
||||
from vllm.entrypoints.openai.protocol import (ChatCompletionRequest,
|
||||
ChatCompletionToolsParam,
|
||||
DeltaFunctionCall, DeltaMessage,
|
||||
DeltaToolCall,
|
||||
ExtractedToolCallInformation,
|
||||
FunctionCall, ToolCall)
|
||||
from vllm.entrypoints.openai.tool_parsers.abstract_tool_parser import (
|
||||
ToolParser, ToolParserManager)
|
||||
from vllm.logger import init_logger
|
||||
from vllm.transformers_utils.tokenizer import AnyTokenizer
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@ToolParserManager.register_module("qwen3_coder")
|
||||
class Qwen3CoderToolParser(ToolParser):
|
||||
"""
|
||||
Tool parser for Qwen3 models using XML-style tool call format:
|
||||
<tool_call><function=name><parameter=key>
|
||||
value
|
||||
</parameter></function></tool_call>
|
||||
|
||||
Port of vllm-original qwen3coder_tool_parser.py to vllm 0.6.3 API.
|
||||
"""
|
||||
|
||||
def __init__(self, tokenizer: AnyTokenizer):
|
||||
super().__init__(tokenizer)
|
||||
|
||||
self.current_tool_name_sent: bool = False
|
||||
self.prev_tool_call_arr: List[Dict] = []
|
||||
# Base class uses int; we override with string IDs
|
||||
self.current_tool_id: Optional[str] = None # type: ignore[assignment]
|
||||
self.streamed_args_for_tool: List[str] = []
|
||||
|
||||
self.tool_call_start_token: str = "<tool_call>"
|
||||
self.tool_call_end_token: str = "</tool_call>"
|
||||
self.tool_call_prefix: str = "<function="
|
||||
self.function_end_token: str = "</function>"
|
||||
self.parameter_prefix: str = "<parameter="
|
||||
self.parameter_end_token: str = "</parameter>"
|
||||
self.is_tool_call_started: bool = False
|
||||
|
||||
self._reset_streaming_state()
|
||||
|
||||
self.tool_call_complete_regex = re.compile(
|
||||
r"<tool_call>(.*?)</tool_call>", re.DOTALL)
|
||||
self.tool_call_regex = re.compile(
|
||||
r"<tool_call>(.*?)</tool_call>|<tool_call>(.*?)$", re.DOTALL)
|
||||
self.tool_call_function_regex = re.compile(
|
||||
r"<function=(.*?)</function>|<function=(.*)$", re.DOTALL)
|
||||
self.tool_call_parameter_regex = re.compile(
|
||||
r"<parameter=(.*?)(?:</parameter>|(?=<parameter=)|(?=</function>)|$)",
|
||||
re.DOTALL)
|
||||
|
||||
if not self.model_tokenizer:
|
||||
raise ValueError(
|
||||
"The model tokenizer must be passed to the ToolParser "
|
||||
"constructor during construction.")
|
||||
|
||||
self.tool_call_start_token_id = self.vocab.get(
|
||||
self.tool_call_start_token)
|
||||
self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
|
||||
|
||||
if (self.tool_call_start_token_id is None
|
||||
or self.tool_call_end_token_id is None):
|
||||
raise RuntimeError(
|
||||
"Qwen3 XML Tool parser could not locate tool call start/end "
|
||||
"tokens in the tokenizer!")
|
||||
|
||||
logger.debug("vLLM Successfully imported tool parser %s !",
|
||||
self.__class__.__name__)
|
||||
|
||||
|
||||
def _generate_tool_call_id(self) -> str:
|
||||
return f"call_{uuid.uuid4().hex[:24]}"
|
||||
|
||||
def _reset_streaming_state(self) -> None:
|
||||
self.current_tool_index = 0
|
||||
self.is_tool_call_started = False
|
||||
self.header_sent = False
|
||||
self.current_tool_id = None
|
||||
self.current_function_name: Optional[str] = None
|
||||
self.current_param_name: Optional[str] = None
|
||||
self.current_param_value: str = ""
|
||||
self.param_count = 0
|
||||
self.in_param = False
|
||||
self.in_function = False
|
||||
self.accumulated_text: str = ""
|
||||
self.json_started = False
|
||||
self.json_closed = False
|
||||
self.accumulated_params: Dict[str, Any] = {}
|
||||
self.streaming_request: Optional[ChatCompletionRequest] = None
|
||||
|
||||
def _get_arguments_config(
|
||||
self, func_name: str,
|
||||
tools: Optional[List[ChatCompletionToolsParam]]) -> Dict:
|
||||
if tools is None:
|
||||
return {}
|
||||
for config in tools:
|
||||
if not hasattr(config, "type") or not (
|
||||
hasattr(config, "function")
|
||||
and hasattr(config.function, "name")):
|
||||
continue
|
||||
if config.type == "function" and config.function.name == func_name:
|
||||
if not hasattr(config.function, "parameters"):
|
||||
return {}
|
||||
params = config.function.parameters
|
||||
if isinstance(params, dict) and "properties" in params:
|
||||
return params["properties"]
|
||||
elif isinstance(params, dict):
|
||||
return params
|
||||
else:
|
||||
return {}
|
||||
logger.debug("Tool '%s' is not defined in the tools list.", func_name)
|
||||
return {}
|
||||
|
||||
def _convert_param_value(self, param_value: str, param_name: str,
|
||||
param_config: Dict, func_name: str) -> Any:
|
||||
if param_value.lower() == "null":
|
||||
return None
|
||||
|
||||
if param_name not in param_config:
|
||||
if param_config != {}:
|
||||
logger.debug(
|
||||
"Parsed parameter '%s' is not defined in tool '%s', "
|
||||
"returning string value.", param_name, func_name)
|
||||
return param_value
|
||||
|
||||
if (isinstance(param_config[param_name], dict)
|
||||
and "type" in param_config[param_name]):
|
||||
param_type = str(
|
||||
param_config[param_name]["type"]).strip().lower()
|
||||
else:
|
||||
param_type = "string"
|
||||
|
||||
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
|
||||
return param_value
|
||||
elif (param_type.startswith("int") or param_type.startswith("uint")
|
||||
or param_type.startswith("long")
|
||||
or param_type.startswith("short")
|
||||
or param_type.startswith("unsigned")):
|
||||
try:
|
||||
return int(param_value)
|
||||
except (ValueError, TypeError):
|
||||
return param_value
|
||||
elif param_type.startswith("num") or param_type.startswith("float"):
|
||||
try:
|
||||
v = float(param_value)
|
||||
return int(v) if v - int(v) == 0 else v
|
||||
except (ValueError, TypeError):
|
||||
return param_value
|
||||
elif param_type in ["boolean", "bool", "binary"]:
|
||||
lower = param_value.lower()
|
||||
if lower not in ["true", "false"]:
|
||||
logger.debug(
|
||||
"Parameter '%s' value '%s' is not boolean in tool '%s'.",
|
||||
param_name, param_value, func_name)
|
||||
return lower == "true"
|
||||
else:
|
||||
if (param_type in ["object", "array", "arr"]
|
||||
or param_type.startswith("dict")
|
||||
or param_type.startswith("list")):
|
||||
try:
|
||||
return json.loads(param_value)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return ast.literal_eval(param_value)
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
pass
|
||||
return param_value
|
||||
|
||||
def _parse_xml_function_call(
|
||||
self, function_call_str: str,
|
||||
tools: Optional[List[ChatCompletionToolsParam]]) -> ToolCall:
|
||||
end_index = function_call_str.index(">")
|
||||
function_name = function_call_str[:end_index]
|
||||
param_config = self._get_arguments_config(function_name, tools)
|
||||
parameters = function_call_str[end_index + 1:]
|
||||
param_dict: Dict[str, Any] = {}
|
||||
for match_text in self.tool_call_parameter_regex.findall(parameters):
|
||||
idx = match_text.index(">")
|
||||
param_name = match_text[:idx]
|
||||
param_value = str(match_text[idx + 1:])
|
||||
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, function_name)
|
||||
return ToolCall(
|
||||
type="function",
|
||||
function=FunctionCall(
|
||||
name=function_name,
|
||||
arguments=json.dumps(param_dict, ensure_ascii=False)))
|
||||
|
||||
def _get_function_calls(self, model_output: str) -> List[str]:
|
||||
matched_ranges = self.tool_call_regex.findall(model_output)
|
||||
raw_tool_calls = [
|
||||
match[0] if match[0] else match[1] for match in matched_ranges
|
||||
]
|
||||
if not raw_tool_calls:
|
||||
raw_tool_calls = [model_output]
|
||||
raw_function_calls: List[tuple] = []
|
||||
for tool_call in raw_tool_calls:
|
||||
raw_function_calls.extend(
|
||||
self.tool_call_function_regex.findall(tool_call))
|
||||
return [match[0] if match[0] else match[1]
|
||||
for match in raw_function_calls]
|
||||
|
||||
def extract_tool_calls(
|
||||
self, model_output: str,
|
||||
request: ChatCompletionRequest) -> ExtractedToolCallInformation:
|
||||
if self.tool_call_prefix not in model_output:
|
||||
return ExtractedToolCallInformation(tools_called=False,
|
||||
tool_calls=[],
|
||||
content=model_output)
|
||||
try:
|
||||
function_calls = self._get_function_calls(model_output)
|
||||
if not function_calls:
|
||||
return ExtractedToolCallInformation(tools_called=False,
|
||||
tool_calls=[],
|
||||
content=model_output)
|
||||
|
||||
tool_calls = [
|
||||
self._parse_xml_function_call(fc, request.tools)
|
||||
for fc in function_calls
|
||||
]
|
||||
|
||||
self.prev_tool_call_arr.clear()
|
||||
for tc in tool_calls:
|
||||
self.prev_tool_call_arr.append({
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
})
|
||||
|
||||
content_index = model_output.find(self.tool_call_start_token)
|
||||
idx = model_output.find(self.tool_call_prefix)
|
||||
content_index = content_index if content_index >= 0 else idx
|
||||
content = model_output[:content_index]
|
||||
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=bool(tool_calls),
|
||||
tool_calls=tool_calls,
|
||||
content=content if content else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error extracting tool call from response.")
|
||||
return ExtractedToolCallInformation(tools_called=False,
|
||||
tool_calls=[],
|
||||
content=model_output)
|
||||
|
||||
def extract_tool_calls_streaming(
|
||||
self,
|
||||
previous_text: str,
|
||||
current_text: str,
|
||||
delta_text: str,
|
||||
previous_token_ids: Sequence[int],
|
||||
current_token_ids: Sequence[int],
|
||||
delta_token_ids: Sequence[int],
|
||||
request: ChatCompletionRequest,
|
||||
) -> Union[DeltaMessage, None]:
|
||||
if not previous_text:
|
||||
self._reset_streaming_state()
|
||||
self.streaming_request = request
|
||||
|
||||
if not delta_text:
|
||||
if delta_token_ids and self.tool_call_end_token_id not in delta_token_ids:
|
||||
complete_calls = len(
|
||||
self.tool_call_complete_regex.findall(current_text))
|
||||
if complete_calls > 0 and self.prev_tool_call_arr:
|
||||
open_calls = (
|
||||
current_text.count(self.tool_call_start_token) -
|
||||
current_text.count(self.tool_call_end_token))
|
||||
if open_calls == 0:
|
||||
return DeltaMessage(content="")
|
||||
elif not self.is_tool_call_started and current_text:
|
||||
return DeltaMessage(content="")
|
||||
return None
|
||||
|
||||
self.accumulated_text = current_text
|
||||
|
||||
if self.json_closed and not self.in_function:
|
||||
tool_ends = current_text.count(self.tool_call_end_token)
|
||||
if tool_ends > self.current_tool_index:
|
||||
self.current_tool_index += 1
|
||||
self.header_sent = False
|
||||
self.param_count = 0
|
||||
self.json_started = False
|
||||
self.json_closed = False
|
||||
self.accumulated_params = {}
|
||||
tool_starts = current_text.count(self.tool_call_start_token)
|
||||
if self.current_tool_index >= tool_starts:
|
||||
self.is_tool_call_started = False
|
||||
return None
|
||||
|
||||
if not self.is_tool_call_started:
|
||||
if (self.tool_call_start_token_id in delta_token_ids
|
||||
or self.tool_call_start_token in delta_text):
|
||||
self.is_tool_call_started = True
|
||||
if self.tool_call_start_token in delta_text:
|
||||
content_before = delta_text[:delta_text.index(
|
||||
self.tool_call_start_token)]
|
||||
if content_before:
|
||||
return DeltaMessage(content=content_before)
|
||||
return None
|
||||
else:
|
||||
if (current_text.rstrip().endswith(self.tool_call_end_token)
|
||||
and delta_text.strip() == ""):
|
||||
return None
|
||||
return DeltaMessage(content=delta_text)
|
||||
|
||||
tool_starts_count = current_text.count(self.tool_call_start_token)
|
||||
if self.current_tool_index >= tool_starts_count:
|
||||
return None
|
||||
|
||||
# Locate the current tool call's text slice
|
||||
tool_start_positions: List[int] = []
|
||||
search = 0
|
||||
while True:
|
||||
search = current_text.find(self.tool_call_start_token, search)
|
||||
if search == -1:
|
||||
break
|
||||
tool_start_positions.append(search)
|
||||
search += len(self.tool_call_start_token)
|
||||
|
||||
if self.current_tool_index >= len(tool_start_positions):
|
||||
return None
|
||||
|
||||
tool_start_idx = tool_start_positions[self.current_tool_index]
|
||||
tool_end_idx = current_text.find(self.tool_call_end_token,
|
||||
tool_start_idx)
|
||||
if tool_end_idx == -1:
|
||||
tool_text = current_text[tool_start_idx:]
|
||||
else:
|
||||
tool_text = current_text[tool_start_idx:tool_end_idx +
|
||||
len(self.tool_call_end_token)]
|
||||
|
||||
if not self.header_sent:
|
||||
if self.tool_call_prefix in tool_text:
|
||||
func_start = (tool_text.find(self.tool_call_prefix) +
|
||||
len(self.tool_call_prefix))
|
||||
func_end = tool_text.find(">", func_start)
|
||||
if func_end != -1:
|
||||
self.current_function_name = tool_text[func_start:func_end]
|
||||
self.current_tool_id = self._generate_tool_call_id()
|
||||
self.header_sent = True
|
||||
self.in_function = True
|
||||
self.prev_tool_call_arr.append({
|
||||
"name": self.current_function_name,
|
||||
"arguments": "{}",
|
||||
})
|
||||
self.streamed_args_for_tool.append("")
|
||||
return DeltaMessage(tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
id=self.current_tool_id,
|
||||
function=DeltaFunctionCall(
|
||||
name=self.current_function_name,
|
||||
arguments=""),
|
||||
type="function",
|
||||
)
|
||||
])
|
||||
return None
|
||||
|
||||
if self.in_function:
|
||||
if not self.json_started:
|
||||
self.json_started = True
|
||||
self.streamed_args_for_tool[self.current_tool_index] += "{"
|
||||
return DeltaMessage(tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(arguments="{"),
|
||||
)
|
||||
])
|
||||
|
||||
# Collect all complete parameters in one pass (speculative-decode safe)
|
||||
param_starts: List[int] = []
|
||||
search = 0
|
||||
while True:
|
||||
search = tool_text.find(self.parameter_prefix, search)
|
||||
if search == -1:
|
||||
break
|
||||
param_starts.append(search)
|
||||
search += len(self.parameter_prefix)
|
||||
|
||||
json_fragments: List[str] = []
|
||||
while not self.in_param and self.param_count < len(param_starts):
|
||||
param_idx = param_starts[self.param_count]
|
||||
param_start = param_idx + len(self.parameter_prefix)
|
||||
remaining = tool_text[param_start:]
|
||||
|
||||
if ">" not in remaining:
|
||||
break
|
||||
|
||||
name_end = remaining.find(">")
|
||||
current_param_name = remaining[:name_end]
|
||||
value_start = param_start + name_end + 1
|
||||
value_text = tool_text[value_start:]
|
||||
if value_text.startswith("\n"):
|
||||
value_text = value_text[1:]
|
||||
|
||||
param_end_idx = value_text.find(self.parameter_end_token)
|
||||
if param_end_idx == -1:
|
||||
next_param = value_text.find(self.parameter_prefix)
|
||||
func_end = value_text.find(self.function_end_token)
|
||||
if next_param != -1 and (func_end == -1
|
||||
or next_param < func_end):
|
||||
param_end_idx = next_param
|
||||
elif func_end != -1:
|
||||
param_end_idx = func_end
|
||||
else:
|
||||
tool_end_in_value = value_text.find(
|
||||
self.tool_call_end_token)
|
||||
if tool_end_in_value != -1:
|
||||
param_end_idx = tool_end_in_value
|
||||
else:
|
||||
break
|
||||
|
||||
if param_end_idx == -1:
|
||||
break
|
||||
|
||||
param_value = value_text[:param_end_idx]
|
||||
if param_value.endswith("\n"):
|
||||
param_value = param_value[:-1]
|
||||
|
||||
self.accumulated_params[current_param_name] = param_value
|
||||
param_config = self._get_arguments_config(
|
||||
self.current_function_name or "",
|
||||
self.streaming_request.tools
|
||||
if self.streaming_request else None)
|
||||
converted = self._convert_param_value(
|
||||
param_value, current_param_name, param_config,
|
||||
self.current_function_name or "")
|
||||
serialized = json.dumps(converted, ensure_ascii=False)
|
||||
|
||||
sep = "" if self.param_count == 0 else ", "
|
||||
json_fragments.append(
|
||||
f'{sep}"{current_param_name}": {serialized}')
|
||||
self.param_count += 1
|
||||
|
||||
if json_fragments:
|
||||
combined = "".join(json_fragments)
|
||||
if self.current_tool_index < len(self.streamed_args_for_tool):
|
||||
self.streamed_args_for_tool[
|
||||
self.current_tool_index] += combined
|
||||
else:
|
||||
logger.warning(
|
||||
"streamed_args_for_tool out of sync: index=%d len=%d",
|
||||
self.current_tool_index,
|
||||
len(self.streamed_args_for_tool))
|
||||
return DeltaMessage(tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(arguments=combined),
|
||||
)
|
||||
])
|
||||
|
||||
# Emit closing brace when </function> is seen (after params are done)
|
||||
if not self.json_closed and self.function_end_token in tool_text:
|
||||
self.json_closed = True
|
||||
func_start = (tool_text.find(self.tool_call_prefix) +
|
||||
len(self.tool_call_prefix))
|
||||
func_content_end = tool_text.find(self.function_end_token,
|
||||
func_start)
|
||||
if func_content_end != -1:
|
||||
try:
|
||||
parsed_tool = self._parse_xml_function_call(
|
||||
tool_text[func_start:func_content_end],
|
||||
self.streaming_request.tools
|
||||
if self.streaming_request else None)
|
||||
if self.current_tool_index < len(
|
||||
self.prev_tool_call_arr):
|
||||
self.prev_tool_call_arr[
|
||||
self.current_tool_index]["arguments"] = (
|
||||
parsed_tool.function.arguments)
|
||||
except Exception:
|
||||
logger.debug("Failed to parse tool call during "
|
||||
"streaming: %s",
|
||||
tool_text,
|
||||
exc_info=True)
|
||||
|
||||
if self.current_tool_index < len(self.streamed_args_for_tool):
|
||||
self.streamed_args_for_tool[
|
||||
self.current_tool_index] += "}"
|
||||
else:
|
||||
logger.warning(
|
||||
"streamed_args_for_tool out of sync: index=%d len=%d",
|
||||
self.current_tool_index,
|
||||
len(self.streamed_args_for_tool))
|
||||
|
||||
result = DeltaMessage(tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(arguments="}"),
|
||||
)
|
||||
])
|
||||
self.in_function = False
|
||||
self.accumulated_params = {}
|
||||
return result
|
||||
|
||||
return None
|
||||
16
vllm/reasoning/__init__.py
Normal file
16
vllm/reasoning/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Reasoning parser module for vLLM 0.6.3 (BI-V100 / Qwen3.6-27B adaptation).
|
||||
|
||||
Usage: --reasoning-parser qwen3
|
||||
"""
|
||||
|
||||
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser, ReasoningParserManager
|
||||
|
||||
__all__ = ["ReasoningParser", "ReasoningParserManager"]
|
||||
|
||||
# Lazy-register Qwen3 parser; imported on first get_reasoning_parser("qwen3").
|
||||
ReasoningParserManager.register_lazy(
|
||||
"qwen3",
|
||||
"vllm.reasoning.qwen3_reasoning_parser",
|
||||
"Qwen3ReasoningParser",
|
||||
)
|
||||
243
vllm/reasoning/abs_reasoning_parsers.py
Normal file
243
vllm/reasoning/abs_reasoning_parsers.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Abstract reasoning parser base classes for vLLM 0.6.3.
|
||||
Adapted from vllm-original/vllm/reasoning/abs_reasoning_parsers.py:
|
||||
- Removed vllm.entrypoints.mcp, vllm.utils.collection_utils, import_utils
|
||||
- DeltaMessage from vllm 0.6.3 protocol path
|
||||
- TokenizerLike -> AnyTokenizer
|
||||
- ReasoningParserManager: simplified eager + lazy registration
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable, Sequence
|
||||
from functools import cached_property
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.entrypoints.openai.protocol import DeltaMessage
|
||||
from vllm.transformers_utils.tokenizer import AnyTokenizer
|
||||
else:
|
||||
DeltaMessage = Any
|
||||
AnyTokenizer = Any
|
||||
|
||||
|
||||
class ReasoningParser:
|
||||
"""Abstract base for all reasoning parsers."""
|
||||
|
||||
def __init__(self, tokenizer: "AnyTokenizer", *args, **kwargs):
|
||||
self.model_tokenizer = tokenizer
|
||||
|
||||
@cached_property
|
||||
def vocab(self) -> dict:
|
||||
return self.model_tokenizer.get_vocab()
|
||||
|
||||
@abstractmethod
|
||||
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
|
||||
"""Return True once the reasoning block has closed in input_ids."""
|
||||
|
||||
def is_reasoning_end_streaming(
|
||||
self, input_ids: Sequence[int], delta_ids: Iterable[int]
|
||||
) -> bool:
|
||||
return self.is_reasoning_end(input_ids)
|
||||
|
||||
@abstractmethod
|
||||
def extract_content_ids(self, input_ids: list) -> list:
|
||||
"""Return token ids that belong to the content (post-reasoning) part."""
|
||||
|
||||
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
|
||||
return 0
|
||||
|
||||
@abstractmethod
|
||||
def extract_reasoning(
|
||||
self, model_output: str, request: Any
|
||||
) -> "tuple[Optional[str], Optional[str]]":
|
||||
"""
|
||||
Split a complete model output into (reasoning_text, content_text).
|
||||
Either part may be None.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def extract_reasoning_streaming(
|
||||
self,
|
||||
previous_text: str,
|
||||
current_text: str,
|
||||
delta_text: str,
|
||||
previous_token_ids: Sequence[int],
|
||||
current_token_ids: Sequence[int],
|
||||
delta_token_ids: Sequence[int],
|
||||
) -> Optional["DeltaMessage"]:
|
||||
"""
|
||||
Extract reasoning from a streaming delta.
|
||||
Returns a DeltaMessage with reasoning_content and/or content set,
|
||||
or None if this delta should be suppressed (control token).
|
||||
"""
|
||||
|
||||
|
||||
class BaseThinkingReasoningParser(ReasoningParser):
|
||||
"""
|
||||
Base for parsers that use <start_token>...</end_token> delimiters.
|
||||
Subclasses define start_token / end_token properties.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def start_token(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def end_token(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(self, tokenizer: "AnyTokenizer", *args, **kwargs):
|
||||
super().__init__(tokenizer, *args, **kwargs)
|
||||
|
||||
if not self.model_tokenizer:
|
||||
raise ValueError("Tokenizer must be passed to ReasoningParser.")
|
||||
if not self.start_token or not self.end_token:
|
||||
raise ValueError("start_token and end_token must be defined.")
|
||||
|
||||
self.start_token_id: Optional[int] = self.vocab.get(self.start_token)
|
||||
self.end_token_id: Optional[int] = self.vocab.get(self.end_token)
|
||||
if self.start_token_id is None or self.end_token_id is None:
|
||||
raise RuntimeError(
|
||||
f"{self.__class__.__name__}: could not find think tokens "
|
||||
f"'{self.start_token}'/'{self.end_token}' in tokenizer vocab."
|
||||
)
|
||||
|
||||
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
|
||||
for token_id in reversed(input_ids):
|
||||
if token_id == self.start_token_id:
|
||||
return False
|
||||
if token_id == self.end_token_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_reasoning_end_streaming(
|
||||
self, input_ids: Sequence[int], delta_ids: Iterable[int]
|
||||
) -> bool:
|
||||
return self.end_token_id in delta_ids
|
||||
|
||||
def extract_content_ids(self, input_ids: list) -> list:
|
||||
if self.end_token_id not in input_ids[:-1]:
|
||||
return []
|
||||
return input_ids[input_ids.index(self.end_token_id) + 1:]
|
||||
|
||||
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
|
||||
count = 0
|
||||
depth = 0
|
||||
for tid in token_ids:
|
||||
if tid == self.start_token_id:
|
||||
depth += 1
|
||||
elif tid == self.end_token_id:
|
||||
if depth > 0:
|
||||
depth -= 1
|
||||
elif depth > 0:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def extract_reasoning(
|
||||
self, model_output: str, request: Any
|
||||
) -> "tuple[Optional[str], Optional[str]]":
|
||||
# Strip <think> if the model generated it (old-style template).
|
||||
parts = model_output.partition(self.start_token)
|
||||
model_output = parts[2] if parts[1] else parts[0]
|
||||
|
||||
if self.end_token not in model_output:
|
||||
return model_output, None
|
||||
reasoning, _, content = model_output.partition(self.end_token)
|
||||
return reasoning, content or None
|
||||
|
||||
def extract_reasoning_streaming(
|
||||
self,
|
||||
previous_text: str,
|
||||
current_text: str,
|
||||
delta_text: str,
|
||||
previous_token_ids: Sequence[int],
|
||||
current_token_ids: Sequence[int],
|
||||
delta_token_ids: Sequence[int],
|
||||
) -> Optional["DeltaMessage"]:
|
||||
from vllm.entrypoints.openai.protocol import DeltaMessage as _DeltaMessage
|
||||
|
||||
# Suppress lone control tokens.
|
||||
if len(delta_token_ids) == 1 and delta_token_ids[0] in (
|
||||
self.start_token_id, self.end_token_id
|
||||
):
|
||||
return None
|
||||
|
||||
start_in_prev = self.start_token_id in previous_token_ids
|
||||
start_in_delta = self.start_token_id in delta_token_ids
|
||||
end_in_prev = self.end_token_id in previous_token_ids
|
||||
end_in_delta = self.end_token_id in delta_token_ids
|
||||
|
||||
if start_in_prev:
|
||||
if end_in_delta:
|
||||
end_idx = delta_text.find(self.end_token)
|
||||
reasoning = delta_text[:end_idx] if end_idx >= 0 else ""
|
||||
content = delta_text[end_idx + len(self.end_token):] if end_idx >= 0 else None
|
||||
return _DeltaMessage(
|
||||
reasoning_content=reasoning or None,
|
||||
content=content or None,
|
||||
)
|
||||
elif end_in_prev:
|
||||
return _DeltaMessage(content=delta_text)
|
||||
else:
|
||||
return _DeltaMessage(reasoning_content=delta_text)
|
||||
|
||||
elif start_in_delta:
|
||||
if end_in_delta:
|
||||
start_idx = delta_text.find(self.start_token)
|
||||
end_idx = delta_text.find(self.end_token)
|
||||
reasoning = delta_text[start_idx + len(self.start_token):end_idx]
|
||||
content = delta_text[end_idx + len(self.end_token):]
|
||||
return _DeltaMessage(
|
||||
reasoning_content=reasoning or None,
|
||||
content=content or None,
|
||||
)
|
||||
else:
|
||||
return _DeltaMessage(reasoning_content=delta_text)
|
||||
|
||||
else:
|
||||
return _DeltaMessage(content=delta_text)
|
||||
|
||||
|
||||
class ReasoningParserManager:
|
||||
"""
|
||||
Registry for ReasoningParser implementations.
|
||||
Supports eager and lazy registration.
|
||||
"""
|
||||
|
||||
_parsers: dict = {} # name -> class (eager)
|
||||
_lazy: dict = {} # name -> (module_path, class_name)
|
||||
|
||||
@classmethod
|
||||
def register_module(cls, name: str, parser_cls: type) -> None:
|
||||
"""Eagerly register a ReasoningParser class."""
|
||||
if not issubclass(parser_cls, ReasoningParser):
|
||||
raise TypeError(f"{parser_cls} is not a ReasoningParser subclass.")
|
||||
cls._parsers[name] = parser_cls
|
||||
|
||||
@classmethod
|
||||
def register_lazy(cls, name: str, module_path: str, class_name: str) -> None:
|
||||
"""Register a parser for deferred import."""
|
||||
cls._lazy[name] = (module_path, class_name)
|
||||
|
||||
@classmethod
|
||||
def get_reasoning_parser(cls, name: str) -> type:
|
||||
if name in cls._parsers:
|
||||
return cls._parsers[name]
|
||||
if name in cls._lazy:
|
||||
module_path, class_name = cls._lazy[name]
|
||||
mod = importlib.import_module(module_path)
|
||||
parser_cls = getattr(mod, class_name)
|
||||
cls._parsers[name] = parser_cls
|
||||
return parser_cls
|
||||
registered = sorted(set(cls._parsers) | set(cls._lazy))
|
||||
raise KeyError(
|
||||
f"Reasoning parser '{name}' not found. "
|
||||
f"Available: {registered}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_registered(cls) -> list:
|
||||
return sorted(set(cls._parsers) | set(cls._lazy))
|
||||
110
vllm/reasoning/qwen3_reasoning_parser.py
Normal file
110
vllm/reasoning/qwen3_reasoning_parser.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Reasoning parser for Qwen3 / Qwen3.5 / Qwen3.6 model family.
|
||||
Adapted from vllm-original/vllm/reasoning/qwen3_reasoning_parser.py.
|
||||
|
||||
The model uses <think>...</think> to wrap chain-of-thought output.
|
||||
For Qwen3.5+ the chat template injects <think> into the prompt, so only
|
||||
</think> appears in the generated tokens; older templates generate <think>
|
||||
themselves. Both styles are handled.
|
||||
"""
|
||||
|
||||
from typing import Optional, Sequence, Any
|
||||
|
||||
from vllm.reasoning.abs_reasoning_parsers import (
|
||||
BaseThinkingReasoningParser,
|
||||
ReasoningParserManager,
|
||||
)
|
||||
|
||||
|
||||
class Qwen3ReasoningParser(BaseThinkingReasoningParser):
|
||||
|
||||
def __init__(self, tokenizer: Any, *args, **kwargs):
|
||||
super().__init__(tokenizer, *args, **kwargs)
|
||||
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
|
||||
self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
|
||||
|
||||
@property
|
||||
def start_token(self) -> str:
|
||||
return "<think>"
|
||||
|
||||
@property
|
||||
def end_token(self) -> str:
|
||||
return "</think>"
|
||||
|
||||
def extract_reasoning(
|
||||
self, model_output: str, request: Any
|
||||
) -> "tuple[Optional[str], Optional[str]]":
|
||||
# Strip <think> if the model generated it (old template / edge case).
|
||||
parts = model_output.partition(self.start_token)
|
||||
model_output = parts[2] if parts[1] else parts[0]
|
||||
|
||||
if self.end_token not in model_output:
|
||||
if not self.thinking_enabled:
|
||||
return None, model_output
|
||||
# Thinking enabled but output truncated before </think>.
|
||||
# All output is reasoning; content is None.
|
||||
return model_output, None
|
||||
|
||||
reasoning, _, content = model_output.partition(self.end_token)
|
||||
content = content.strip() if content else ""
|
||||
return reasoning or None, content if content else None
|
||||
|
||||
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
|
||||
token_ids = list(token_ids)
|
||||
if self.start_token_id in token_ids:
|
||||
# Old-style template: model generates <think> itself.
|
||||
# Use depth-counting from the base class.
|
||||
return super().count_reasoning_tokens(token_ids)
|
||||
elif self.end_token_id in token_ids:
|
||||
# New-style template (Qwen3.5+): <think> is injected into the
|
||||
# prompt, so output starts already inside the thinking block.
|
||||
# Every token before </think> is a reasoning token.
|
||||
return token_ids.index(self.end_token_id)
|
||||
else:
|
||||
# No </think> in output: either truncated (all reasoning)
|
||||
# or thinking disabled (none).
|
||||
return len(token_ids) if self.thinking_enabled else 0
|
||||
|
||||
def extract_reasoning_streaming(
|
||||
self,
|
||||
previous_text: str,
|
||||
current_text: str,
|
||||
delta_text: str,
|
||||
previous_token_ids: Sequence[int],
|
||||
current_token_ids: Sequence[int],
|
||||
delta_token_ids: Sequence[int],
|
||||
):
|
||||
from vllm.entrypoints.openai.protocol import DeltaMessage
|
||||
|
||||
if not self.thinking_enabled:
|
||||
return DeltaMessage(content=delta_text) if delta_text else None
|
||||
|
||||
# Strip <think> from delta if the model generates it itself.
|
||||
if self.start_token_id in delta_token_ids:
|
||||
start_idx = delta_text.find(self.start_token)
|
||||
if start_idx >= 0:
|
||||
delta_text = delta_text[start_idx + len(self.start_token):]
|
||||
|
||||
if self.end_token_id in delta_token_ids:
|
||||
end_idx = delta_text.find(self.end_token)
|
||||
if end_idx >= 0:
|
||||
reasoning = delta_text[:end_idx]
|
||||
content = delta_text[end_idx + len(self.end_token):]
|
||||
if not reasoning and not content:
|
||||
return None
|
||||
return DeltaMessage(
|
||||
reasoning_content=reasoning or None,
|
||||
content=content or None,
|
||||
)
|
||||
return None
|
||||
|
||||
if not delta_text:
|
||||
return None
|
||||
elif self.end_token_id in previous_token_ids:
|
||||
return DeltaMessage(content=delta_text)
|
||||
else:
|
||||
return DeltaMessage(reasoning_content=delta_text)
|
||||
|
||||
|
||||
# Register immediately when this module is imported.
|
||||
ReasoningParserManager.register_module("qwen3", Qwen3ReasoningParser)
|
||||
Reference in New Issue
Block a user