diff --git a/xinference/model/llm/tests/test_utils.py b/xinference/model/llm/tests/test_utils.py index de445cf9ca..3691b8ebda 100644 --- a/xinference/model/llm/tests/test_utils.py +++ b/xinference/model/llm/tests/test_utils.py @@ -2830,6 +2830,46 @@ def test_post_process_completion_with_parser(): ), f"Mismatch: expected {expected_filtered}, got {result_filtered}" +def test_post_process_completion_stopped_while_thinking(): + # Generation hits max_tokens inside the thinking block, so the output has + # no tool call and no content. Like the request without tools, the + # response must keep "length" and must not repeat the reasoning as content. + mixin = ChatModelMixin() + mixin.tool_parser = QwenToolParser() + mixin.reasoning_parser = ReasoningParser( + reasoning_content=True, + reasoning_start_tag="", + reasoning_end_tag="", + enable_thinking=True, + ) + test_case = { + "id": "1", + "object": "text_completion", + "created": 0, + "model": "qwen3", + "choices": [ + { + "text": "The user asks for the weather in Shanghai, so I should call", + "index": 0, + "logprobs": None, + "finish_reason": "length", + } + ], + "usage": {"prompt_tokens": 159, "completion_tokens": 12, "total_tokens": 171}, + } + + result = mixin._post_process_completion(None, model_uid="qwen3", c=test_case) + + choice = result["choices"][0] + assert choice["finish_reason"] == "length" + assert choice["message"]["content"] == "" + assert choice["message"]["tool_calls"] == [] + assert ( + choice["message"]["reasoning_content"] + == "The user asks for the weather in Shanghai, so I should call" + ) + + # ── Security tests for Llama3ToolParser ──────────────────── diff --git a/xinference/model/llm/utils.py b/xinference/model/llm/utils.py index 784499035a..e91d4794f1 100644 --- a/xinference/model/llm/utils.py +++ b/xinference/model/llm/utils.py @@ -1155,9 +1155,10 @@ def _post_process_completion( reasoning_content, processed_content = ( self.reasoning_parser.extract_reasoning_content(text) ) - # Use the processed content (without thinking tags) for tool parsing - if processed_content: - text = processed_content + # Use the processed content (without thinking tags) for tool parsing. + # It is empty when the output only contains reasoning, e.g. when + # generation stops inside the thinking block. + text = processed_content or "" # Then, extract tool calls from the processed text (without thinking tags) tool_calls = [] @@ -1192,7 +1193,11 @@ def _post_process_completion( # For non-tool calls, use the processed content from reasoning parser content = text - finish_reason = "tool_calls" if tool_calls else "stop" + finish_reason = ( + "tool_calls" + if tool_calls + else c["choices"][0].get("finish_reason") or "stop" + ) m = { "role": "assistant",