Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/guidellm/backends/openai/request_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,14 +512,16 @@ def compile_non_streaming(
text = choice.get("text", "")
input_metrics, output_metrics = self.extract_metrics(usage, text)

return GenerationResponse(
compiled = GenerationResponse(
request_id=request.request_id,
request_args=arguments.model_dump_json(),
response_id=response.get("id"), # use vLLM ID if available
text=text,
input_metrics=input_metrics,
output_metrics=output_metrics,
)
self._validate_compiled_response(compiled)
return compiled

def add_streaming_line(self, line: str) -> int | None:
"""
Expand Down Expand Up @@ -562,14 +564,26 @@ def compile_streaming(
text = "".join(self.streaming_texts)
input_metrics, output_metrics = self.extract_metrics(self.streaming_usage, text)

return GenerationResponse(
compiled = GenerationResponse(
request_id=request.request_id,
request_args=arguments.model_dump_json(),
response_id=self.streaming_response_id, # use vLLM ID if available
text=text,
input_metrics=input_metrics,
output_metrics=output_metrics,
)
self._validate_compiled_response(compiled)
return compiled

def _validate_compiled_response(self, response: GenerationResponse) -> None:
"""Raise when endpoint produced a terminal payload with no usable output."""
has_text = bool(response.text and response.text.strip())
output_tokens = response.output_metrics.total_tokens or 0
if not has_text and output_tokens <= 0:
raise ValueError(
"[UNUSABLE_BACKEND_RESPONSE] backend resolved with empty "
"response payload"
)
Comment on lines +578 to +586

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of "without a usable terminal response payload", we should be more specific. Something like "empty response payload" would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 🙏


def extract_line_data(self, line: str) -> dict[str, Any] | None:
"""
Expand Down Expand Up @@ -1074,7 +1088,7 @@ def compile_non_streaming(
output_metrics, len(tool_calls) if tool_calls else 0, text
)

return GenerationResponse(
compiled = GenerationResponse(
request_id=request.request_id,
request_args=arguments.model_dump_json(),
response_id=response.get("id"), # use vLLM ID if available
Expand All @@ -1084,6 +1098,8 @@ def compile_non_streaming(
input_metrics=input_metrics,
output_metrics=output_metrics,
)
self._validate_compiled_response(compiled)
return compiled

def add_streaming_line(self, line: str) -> int | None:
"""
Expand Down Expand Up @@ -1175,7 +1191,7 @@ def compile_streaming(
:param request: Original generation request
:return: Standardized GenerationResponse with concatenated content and metrics
"""
return _compile_streaming_response(
compiled = _compile_streaming_response(
request,
arguments,
self.streaming_texts,
Expand All @@ -1185,6 +1201,8 @@ def compile_streaming(
self.extract_metrics,
streaming_reasoning_texts=self.streaming_reasoning_texts,
)
self._validate_compiled_response(compiled)
return compiled


@OpenAIRequestHandlerFactory.register(
Expand Down
6 changes: 5 additions & 1 deletion tests/unit/backends/openai/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,11 @@ def mock_fail(*args, **kwargs):
@async_timeout(10.0)
async def test_resolve_with_history(self, httpx_mock: HTTPXMock):
"""Test resolve method handles conversation history."""
backend = _make_backend(target="http://test", request_format="/v1/completions")
backend = _make_backend(
target="http://test",
request_format="/v1/completions",
stream=False,
)

# Mock the models endpoint
httpx_mock.add_response(
Expand Down
114 changes: 81 additions & 33 deletions tests/unit/backends/openai/test_request_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,9 +575,6 @@ def test_format_ignore_eos(self, valid_instances):
10,
5,
),
({"choices": [{"text": ""}], "usage": {}}, "", None, None),
({"choices": [], "usage": {}}, "", None, None),
({}, "", None, None),
],
)
def test_non_streaming(
Expand Down Expand Up @@ -638,7 +635,6 @@ def test_non_streaming(
None,
None,
),
(["", "data: [DONE]"], "", None, None),
],
)
def test_streaming(
Expand Down Expand Up @@ -672,6 +668,47 @@ def test_streaming(
assert response.output_metrics.text_words == len(expected_text.split())
assert response.output_metrics.text_characters == len(expected_text)

@pytest.mark.regression
@pytest.mark.parametrize(
"response",
[
{"choices": [{"text": ""}], "usage": {}},
{"choices": [], "usage": {}},
{},
],
)
def test_non_streaming_raises_for_unusable_terminal_payload(
self, valid_instances, generation_request, response
):
"""Test unusable non-streaming text response raises.

### WRITTEN BY AI ###
"""
instance = valid_instances
arguments = instance.format(generation_request)

with pytest.raises(ValueError, match="UNUSABLE_BACKEND_RESPONSE"):
instance.compile_non_streaming(generation_request, arguments, response)

@pytest.mark.regression
def test_streaming_raises_for_unusable_terminal_payload(
self, valid_instances, generation_request
):
"""Test unusable streaming text response raises.

### WRITTEN BY AI ###
"""
instance = valid_instances
arguments = instance.format(generation_request)

for line in ["", "data: [DONE]"]:
result = instance.add_streaming_line(line)
if result is None:
break

with pytest.raises(ValueError, match="UNUSABLE_BACKEND_RESPONSE"):
instance.compile_streaming(generation_request, arguments)

@pytest.mark.smoke
@pytest.mark.parametrize(
("line", "expected_output"),
Expand Down Expand Up @@ -1112,18 +1149,6 @@ def test_format_multimodal(self, valid_instances):
10,
5,
),
(
{"choices": [{"message": {"content": ""}}], "usage": {}},
"",
None,
None,
),
(
{"choices": [], "usage": {}},
"",
None,
None,
),
],
)
def test_non_streaming(
Expand Down Expand Up @@ -1177,12 +1202,6 @@ def test_non_streaming(
None,
None,
),
(
["", "data: [DONE]"],
"",
None,
None,
),
],
)
def test_streaming(
Expand Down Expand Up @@ -1214,6 +1233,46 @@ def test_streaming(
assert response.input_metrics.text_tokens == expected_input_tokens
assert response.output_metrics.text_tokens == expected_output_tokens

@pytest.mark.regression
@pytest.mark.parametrize(
"response",
[
{"choices": [{"message": {"content": ""}}], "usage": {}},
{"choices": [], "usage": {}},
],
)
def test_non_streaming_raises_for_unusable_terminal_payload(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests (and the ones starting at 671) feel like duplicates of each other. But that's an issue for a different PR.

Tests are working for me, and I don't really have any additional complaints to add onto what the others have said.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

self, valid_instances, generation_request, response
):
"""Test unusable non-streaming chat response raises.

### WRITTEN BY AI ###
"""
instance = valid_instances
arguments = instance.format(generation_request)

with pytest.raises(ValueError, match="UNUSABLE_BACKEND_RESPONSE"):
instance.compile_non_streaming(generation_request, arguments, response)

@pytest.mark.regression
def test_streaming_raises_for_unusable_terminal_payload(
self, valid_instances, generation_request
):
"""Test unusable streaming chat response raises.

### WRITTEN BY AI ###
"""
instance = valid_instances
arguments = instance.format(generation_request)

for line in ["", "data: [DONE]"]:
result = instance.add_streaming_line(line)
if result is None:
break

with pytest.raises(ValueError, match="UNUSABLE_BACKEND_RESPONSE"):
instance.compile_streaming(generation_request, arguments)

@pytest.mark.sanity
def test_streaming_reasoning_tokens(self, valid_instances, generation_request):
"""Test that reasoning tokens are properly detected for TTFT measurement.
Expand All @@ -1228,15 +1287,12 @@ def test_streaming_reasoning_tokens(self, valid_instances, generation_request):
arguments = instance.format(generation_request)

lines = [
# First chunk has reasoning token
(
'data: {"id": "chatcmpl-123", "choices": '
'[{"index": 0, "delta": {"reasoning": "Okay"}}], "usage": {}}'
),
# More reasoning tokens
'data: {"choices": [{"delta": {"reasoning": ", let me"}}], "usage": {}}',
'data: {"choices": [{"delta": {"reasoning": " think..."}}], "usage": {}}',
# Finally content tokens
'data: {"choices": [{"delta": {"content": "Hello"}}], "usage": {}}',
(
'data: {"choices": [{"delta": {"content": " world!"}}], '
Expand All @@ -1256,17 +1312,13 @@ def test_streaming_reasoning_tokens(self, valid_instances, generation_request):
elif result is None:
break

# Verify that the first update happened on the first reasoning token (line 0)
assert first_update_on_line == 0, (
f"Expected first token detection on line 0 (reasoning token), "
f"but got {first_update_on_line}"
)

# Verify all chunks with content were counted (5 lines with tokens)
assert updated_count == 5

response = instance.compile_streaming(generation_request, arguments)
# Reasoning tokens should NOT appear in response.text; only content does
assert "Okay" not in response.text
assert "let me think..." not in response.text
assert response.text == "Hello world!"
Expand All @@ -1289,7 +1341,6 @@ def test_streaming_both_reasoning_and_content_in_same_chunk(
arguments = instance.format(generation_request)

lines = [
# Chunk with both reasoning and content (edge case)
(
'data: {"choices": [{"delta": '
'{"reasoning": "Let me think...", "content": "Answer: "}}], '
Expand All @@ -1307,12 +1358,9 @@ def test_streaming_both_reasoning_and_content_in_same_chunk(
if result > 0:
updated_count += 1

# First chunk has both reasoning and content (counts as 1 iteration)
# Second chunk has content only (counts as 1 iteration)
assert updated_count == 2

response = instance.compile_streaming(generation_request, arguments)
# Reasoning text should NOT appear; only content is captured
assert "Let me think..." not in response.text
assert response.text == "Answer: 42"

Expand Down