[Fix] Return streaming logprobs when reasoning/tool parser is active (#28601)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Xinyuan Tong
parent
83d32fbc2f
commit
ed26a109ee
@@ -360,6 +360,11 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
delta = content["text"][offset:]
|
delta = content["text"][offset:]
|
||||||
stream_offsets[index] = len(content["text"])
|
stream_offsets[index] = len(content["text"])
|
||||||
|
|
||||||
|
# Attach logprobs to the first chunk emitted this step (reasoning,
|
||||||
|
# tool-call, or content) so they aren't dropped when a parser is active
|
||||||
|
# nor duplicated across chunks; flush any leftover at the end.
|
||||||
|
remaining_logprobs = choice_logprobs
|
||||||
|
|
||||||
# Handle reasoning content
|
# Handle reasoning content
|
||||||
if self.reasoning_parser and request.separate_reasoning:
|
if self.reasoning_parser and request.separate_reasoning:
|
||||||
reasoning_text, delta = self._process_reasoning_stream(
|
reasoning_text, delta = self._process_reasoning_stream(
|
||||||
@@ -380,8 +385,10 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
model=request.model,
|
model=request.model,
|
||||||
index=index,
|
index=index,
|
||||||
reasoning_content=reasoning_text,
|
reasoning_content=reasoning_text,
|
||||||
|
logprobs=remaining_logprobs,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
)
|
)
|
||||||
|
remaining_logprobs = None
|
||||||
|
|
||||||
# Handle tool calls
|
# Handle tool calls
|
||||||
if request.tool_choice != "none" and request.tools and self.tool_call_parser:
|
if request.tool_choice != "none" and request.tools and self.tool_call_parser:
|
||||||
@@ -423,7 +430,33 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
model=request.model,
|
model=request.model,
|
||||||
index=index,
|
index=index,
|
||||||
content=delta,
|
content=delta,
|
||||||
logprobs=choice_logprobs,
|
logprobs=remaining_logprobs,
|
||||||
|
usage=usage,
|
||||||
|
)
|
||||||
|
remaining_logprobs = None
|
||||||
|
|
||||||
|
# Flush logprobs still unattached this step — only when a parser is
|
||||||
|
# active, since _process_tool_call_stream may consume the delta and emit
|
||||||
|
# no content chunk. On the plain path an empty-delta step has no chunk
|
||||||
|
# to attach to either way, and a standalone empty-delta logprobs chunk
|
||||||
|
# is not a shape clients expect.
|
||||||
|
if remaining_logprobs is not None and (
|
||||||
|
self.reasoning_parser or self.tool_call_parser
|
||||||
|
):
|
||||||
|
usage = None
|
||||||
|
if continuous_usage_stats:
|
||||||
|
usage = UsageProcessor.calculate_token_usage(
|
||||||
|
prompt_tokens=prompt_tokens.get(index, 0),
|
||||||
|
reasoning_tokens=reasoning_tokens.get(index, 0),
|
||||||
|
completion_tokens=completion_tokens.get(index, 0),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
yield build_sse_content(
|
||||||
|
chunk_id=content["meta_info"]["id"],
|
||||||
|
created=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
index=index,
|
||||||
|
logprobs=remaining_logprobs,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1448,6 +1448,203 @@ class ServingChatTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(len(chunks), 2)
|
self.assertEqual(len(chunks), 2)
|
||||||
self.assertIn("error", chunks[0])
|
self.assertIn("error", chunks[0])
|
||||||
|
|
||||||
|
def _run_chat_stream(self, adapted_request, req):
|
||||||
|
async def run_stream():
|
||||||
|
chunks = []
|
||||||
|
async for chunk in self.chat._generate_chat_stream(
|
||||||
|
adapted_request, req, self.fastapi_request
|
||||||
|
):
|
||||||
|
chunks.append(chunk)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
return get_or_create_event_loop().run_until_complete(run_stream())
|
||||||
|
|
||||||
|
def _parse_chunks(self, chunks):
|
||||||
|
parsed = []
|
||||||
|
for c in chunks:
|
||||||
|
if c.startswith("data: ") and c != "data: [DONE]\n\n":
|
||||||
|
parsed.append(json.loads(c[len("data: ") :]))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
async def _collect_stream_content(self, content, choice_logprobs, req):
|
||||||
|
chunks = []
|
||||||
|
async for chunk in self.chat._generate_stream_content(
|
||||||
|
content=content,
|
||||||
|
index=0,
|
||||||
|
request=req,
|
||||||
|
stream_offsets={},
|
||||||
|
reasoning_parser_dict={},
|
||||||
|
parser_dict={},
|
||||||
|
has_tool_calls={},
|
||||||
|
choice_logprobs=choice_logprobs,
|
||||||
|
finish_reason_type="stop",
|
||||||
|
continuous_usage_stats=False,
|
||||||
|
prompt_tokens={0: 5},
|
||||||
|
reasoning_tokens={0: 0},
|
||||||
|
completion_tokens={0: 1},
|
||||||
|
):
|
||||||
|
chunks.append(chunk)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
def test_streaming_logprobs_attached_with_reasoning_parser(self):
|
||||||
|
"""Logprobs must ride on the reasoning chunk when a reasoning parser is active."""
|
||||||
|
self.chat.reasoning_parser = "qwen3"
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"text": "thinking...",
|
||||||
|
"meta_info": {
|
||||||
|
"id": "chatcmpl-reasoning",
|
||||||
|
"prompt_tokens": 5,
|
||||||
|
"completion_tokens": 2,
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"finish_reason": {"type": "stop", "matched": None},
|
||||||
|
"output_token_logprobs": [(0.1, 1, "think"), (0.2, 2, "ing")],
|
||||||
|
"output_top_logprobs": [],
|
||||||
|
"output_token_logprobs_length": 2,
|
||||||
|
},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
choice_logprobs = self.chat._process_streaming_logprobs(
|
||||||
|
content, 0, 2
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
req = ChatCompletionRequest(
|
||||||
|
model="x",
|
||||||
|
messages=[{"role": "user", "content": "Hi?"}],
|
||||||
|
stream=True,
|
||||||
|
logprobs=True,
|
||||||
|
separate_reasoning=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(self.chat, "_process_reasoning_stream") as proc_mock:
|
||||||
|
proc_mock.return_value = ("Let me think", "")
|
||||||
|
chunks = get_or_create_event_loop().run_until_complete(
|
||||||
|
self._collect_stream_content(content, choice_logprobs, req)
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = self._parse_chunks(chunks)
|
||||||
|
reasoning_chunks = [
|
||||||
|
c
|
||||||
|
for c in parsed
|
||||||
|
if c["choices"][0]["delta"].get("reasoning_content") is not None
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
reasoning_chunks, "reasoning_parser did not emit a reasoning_content chunk"
|
||||||
|
)
|
||||||
|
logprob_chunks = [
|
||||||
|
c for c in parsed if c["choices"][0].get("logprobs") is not None
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
logprob_chunks,
|
||||||
|
"logprobs dropped: no chunk carried logprobs with reasoning_parser active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_streaming_logprobs_flushed_when_tool_parser_buffers_delta(self):
|
||||||
|
"""Logprobs must be flushed on a standalone chunk when the tool parser emits no content delta."""
|
||||||
|
self.chat.tool_call_parser = "hermes"
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"text": "(<",
|
||||||
|
"meta_info": {
|
||||||
|
"id": "chatcmpl-tool",
|
||||||
|
"prompt_tokens": 5,
|
||||||
|
"completion_tokens": 1,
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"finish_reason": {"type": "stop", "matched": None},
|
||||||
|
"output_token_logprobs": [(0.3, 9, "(<")],
|
||||||
|
"output_top_logprobs": [],
|
||||||
|
"output_token_logprobs_length": 1,
|
||||||
|
},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
choice_logprobs = self.chat._process_streaming_logprobs(
|
||||||
|
content, 0, 1
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
req = ChatCompletionRequest(
|
||||||
|
model="x",
|
||||||
|
messages=[{"role": "user", "content": "Hi?"}],
|
||||||
|
tools=[{"type": "function", "function": {"name": "get_weather"}}],
|
||||||
|
stream=True,
|
||||||
|
logprobs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _empty_tool_stream(*args, **kwargs):
|
||||||
|
return
|
||||||
|
yield # make it an async generator
|
||||||
|
|
||||||
|
with patch.object(self.chat, "_process_tool_call_stream", _empty_tool_stream):
|
||||||
|
chunks = get_or_create_event_loop().run_until_complete(
|
||||||
|
self._collect_stream_content(content, choice_logprobs, req)
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = self._parse_chunks(chunks)
|
||||||
|
logprob_chunks = [
|
||||||
|
c for c in parsed if c["choices"][0].get("logprobs") is not None
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
logprob_chunks,
|
||||||
|
"logprobs dropped: no flush chunk carried logprobs when tool parser buffered the delta",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_streaming_logprobs_not_flushed_on_empty_delta_step_without_parser(self):
|
||||||
|
"""With no parser active, an empty-delta step must not emit a standalone
|
||||||
|
empty-delta logprobs chunk — clients expect each chunk to carry real
|
||||||
|
content/reasoning/tool_calls or a finish_reason."""
|
||||||
|
self.chat.reasoning_parser = None
|
||||||
|
self.chat.tool_call_parser = None
|
||||||
|
|
||||||
|
async def _mock_generate():
|
||||||
|
yield {
|
||||||
|
"text": "",
|
||||||
|
"meta_info": {
|
||||||
|
"id": "chatcmpl-empty",
|
||||||
|
"prompt_tokens": 5,
|
||||||
|
"completion_tokens": 1,
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"finish_reason": {"type": "stop", "matched": None},
|
||||||
|
"output_token_logprobs": [(0.5, 7, "")],
|
||||||
|
"output_top_logprobs": [],
|
||||||
|
"output_token_logprobs_length": 1,
|
||||||
|
},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tm.generate_request.return_value = _mock_generate()
|
||||||
|
|
||||||
|
req = ChatCompletionRequest(
|
||||||
|
model="x",
|
||||||
|
messages=[{"role": "user", "content": "Hi?"}],
|
||||||
|
stream=True,
|
||||||
|
logprobs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sglang.srt.entrypoints.openai.serving_chat.generate_chat_conv"
|
||||||
|
) as conv_mock:
|
||||||
|
conv_ins = Mock()
|
||||||
|
conv_ins.get_prompt.return_value = "Test prompt"
|
||||||
|
conv_mock.return_value = conv_ins
|
||||||
|
adapted_request, _ = self.chat._convert_to_internal_request(
|
||||||
|
req, self.fastapi_request
|
||||||
|
)
|
||||||
|
chunks = self._run_chat_stream(adapted_request, req)
|
||||||
|
|
||||||
|
parsed = self._parse_chunks(chunks)
|
||||||
|
empty_logprob_chunks = [
|
||||||
|
c
|
||||||
|
for c in parsed
|
||||||
|
if c["choices"][0].get("logprobs") is not None
|
||||||
|
and not c["choices"][0]["delta"].get("content")
|
||||||
|
and not c["choices"][0]["delta"].get("reasoning_content")
|
||||||
|
and not c["choices"][0]["delta"].get("tool_calls")
|
||||||
|
and not c["choices"][0].get("finish_reason")
|
||||||
|
]
|
||||||
|
self.assertFalse(
|
||||||
|
empty_logprob_chunks,
|
||||||
|
"empty-delta logprobs chunk emitted without a parser; would break client chunk-shape assumptions",
|
||||||
|
)
|
||||||
|
|
||||||
def test_non_streaming_cached_tokens_details_emits_sglext(self):
|
def test_non_streaming_cached_tokens_details_emits_sglext(self):
|
||||||
"""Test that non-streaming chat responses emit cached token details in sglext."""
|
"""Test that non-streaming chat responses emit cached token details in sglext."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user