Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/guidellm/backends/openai/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ async def _resolve_non_streaming(
response.raise_for_status()
data = response.json()
gen_response = request_handler.compile_non_streaming(request, arguments, data)
request_handler.post_validation(gen_response)
yield gen_response, request_info
self._check_tool_call_expectations(request, gen_response)

Expand Down Expand Up @@ -553,6 +554,7 @@ async def _resolve_streaming(

request_info.timings.request_end = time.time()
gen_response = request_handler.compile_streaming(request, arguments)
request_handler.post_validation(gen_response)
self._check_tool_call_expectations(request, gen_response)
yield gen_response, request_info
except asyncio.CancelledError as err:
Expand Down
53 changes: 53 additions & 0 deletions src/guidellm/backends/openai/request_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ def compile_streaming(
"""
...

def post_validation(self, response: GenerationResponse) -> None:
"""Validate a compiled response before returning it.

Default implementation is permissive (no-op). Handlers override
this to reject responses that lack usable output for their
endpoint type.

:param response: The compiled generation response to validate.
:raises ValueError: If the response is unusable.
"""
...


class OpenAIRequestHandlerFactory(RegistryMixin[type[OpenAIRequestHandler]]):
"""
Expand Down Expand Up @@ -272,6 +284,18 @@ def compile_streaming(
"""
...

def post_validation(self, response: GenerationResponse) -> None:
Comment thread
dbutenhof marked this conversation as resolved.
"""Validate a compiled response before returning it.

Default implementation is permissive (no-op). Handlers override
this to reject responses that lack usable output for their
endpoint type.

:param response: The compiled generation response to validate.
:raises ValueError: If the response is unusable.
"""
...


class OpenAIWSRequestHandlerFactory(RegistryMixin["type[OpenAIWSRequestHandler]"]):
"""Factory for registering and creating WebSocket request handlers by path."""
Expand Down Expand Up @@ -317,6 +341,24 @@ def _apply_tool_call_metrics(
output_metrics.mixed_content_tool_tokens = output_metrics.text_tokens


def _validate_text_response(response: GenerationResponse) -> None:
"""Reject a compiled response that has no usable output.

A response is considered usable if it has non-empty text, tool calls,
or output tokens. Used by text/chat completions and responses handlers.

:param response: The compiled generation response to validate.
:raises ValueError: If the response contains no usable output.
"""
has_text = bool(response.text and response.text.strip())
has_tool_calls = bool(response.tool_calls)
output_tokens = response.output_metrics.total_tokens or 0
if not has_text and not has_tool_calls and output_tokens <= 0:
raise ValueError(
"[UNUSABLE_BACKEND_RESPONSE] backend resolved with empty response payload"
)


_DEFAULT_REASONING_TEMPLATE = "<think>{reasoning}</think>"


Expand Down Expand Up @@ -571,6 +613,10 @@ def compile_streaming(
output_metrics=output_metrics,
)

def post_validation(self, response: GenerationResponse) -> None:
"""Reject responses with no text, tool calls, or output tokens."""
_validate_text_response(response)

Comment thread
jaredoconnell marked this conversation as resolved.
def extract_line_data(self, line: str) -> dict[str, Any] | None:
"""
Extract JSON data from a streaming response line.
Expand Down Expand Up @@ -1848,6 +1894,10 @@ def compile_streaming(
streaming_reasoning_texts=self.streaming_reasoning_texts,
)

def post_validation(self, response: GenerationResponse) -> None:
"""Reject responses with no text, tool calls, or output tokens."""
_validate_text_response(response)

def extract_line_data(self, line: str) -> dict[str, Any] | None:
"""Parse a Responses API SSE line.

Expand Down Expand Up @@ -2147,6 +2197,9 @@ class PoolingRequestHandler(ChatCompletionsRequestHandler):
pooling-specific request structure with nested data fields.
"""

def post_validation(self, response: GenerationResponse) -> None: # noqa: ARG002
Comment thread
dbutenhof marked this conversation as resolved.
"""Pooling responses produce non-text output; skip validation."""

def format(
self,
data: GenerationRequest,
Expand Down
4 changes: 3 additions & 1 deletion src/guidellm/backends/openai/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,9 @@ async def resolve( # type: ignore[override, misc] # noqa: C901, PLR0912, PLR09
f"(last type={event.get('type')!r})."
)

yield handler.compile_streaming(request, arguments), request_info
compiled = handler.compile_streaming(request, arguments)
handler.post_validation(compiled)
yield compiled, request_info

except asyncio.CancelledError as err:
yield handler.compile_streaming(request, arguments), request_info
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
Loading