Fix worker status for unusable terminal backend responses#615
Conversation
|
This introduce worker dependency on GenerationResponse, not sure if that's the right way to go, |
|
Hmm, yeah I am not a fan of depending on |
|
Done |
sjmonson
left a comment
There was a problem hiding this comment.
Can you rebase and get rid of the merge commits?
|
@ushaket, this project requires a linear history on feature branches. You can do this by running: |
60e1abc to
d35b823
Compare
d35b823 to
d82eb51
Compare
d82eb51 to
4388149
Compare
dbutenhof
left a comment
There was a problem hiding this comment.
I have really mixed feelings about this. I don't like the idea of hacking this into each request handler separately -- and you've only handled a subset of the http request handlers in this PR. Maybe that's OK, but it feels weird.
I like your original idea of triggering this through the worker, perhaps by delegating to a backend validate_response method (which could, if necessary, delegate to individual request handlers where more specialized knowledge is required). Your check, for example, is pretty much generic and might even be a default implementation on the base Backend class that everyone would get by default.
(The risk, of course, is that such a default might break some weird special handlers that don't have "normal" text/token outputs; which means a generalization would require extensive testing through all the validations.)
| {"choices": [], "usage": {}}, | ||
| ], | ||
| ) | ||
| def test_non_streaming_raises_for_unusable_terminal_payload( |
There was a problem hiding this comment.
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.
|
@dbutenhof Thanks for the feedback, I agree the per-handler approach is a bit awkward, and it currently only covers text/chat. I prototyped extending this to Responses (same text / tool_calls / tokens check) and Embeddings (raw data[].embedding check, since those responses never have text). Happy to include either or both in this PR if that’s useful. How would you like to proceed? Keep this PR scoped to text/chat and take broader coverage in a follow-up I’m fine with any of these, just want to align before I push more surface area into this PR. |
Yeah, the risk is the "special cases" that don't have text, like embeddings, Geospatial pooling requests (I think), and maybe others. While I like the idea of a meaningful A safer alternative is probably a base implementation that does nothing, overridden by the various backends & request format handlers as necessary... I do prefer centralizing it from the worker loop, delegating through the backend to the request format... but as most of the implementation probably falls to the request formatter, maybe it doesn't matter much. |
Dave's proposal sounds good. It can have a default implementation that is permissive, and can be overridden whenever you need to validate this. The function could maybe be called "post_validation". One thing that needs to be kept in mind is that for the chat completions endpoints and the responses API, no text is valid due to tool calls, and for other endpoints it's valid due to non-token content. |
Signed-off-by: Uri Shaket <ushaket@redhat.com>
Signed-off-by: Uri Shaket <ushaket@redhat.com>
Signed-off-by: Uri Shaket <ushaket@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Add post_validation to OpenAIRequestHandler and OpenAIWSRequestHandler protocols with a permissive no-op default. TextCompletionsRequestHandler and ResponsesRequestHandler override with a check for text, tool_calls, or output_tokens. PoolingRequestHandler and EmbeddingsRequestHandler keep the default no-op since they produce non-text output. Validation is called from the backend resolve methods (http.py, websocket.py) after compile, not inside each handler's compile methods. Cancellation paths skip validation for partial responses. Fixes tool-call-only false negative: responses with tool_calls but no text are now correctly treated as valid output. Assisted-by: Claude Signed-off-by: Uri Shaket <ushaket@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
509ce95 to
1789158
Compare
|
Thanks @dbutenhof, @jaredoconnell , pushed the refactor:
Kept it at the backend resolve level rather than the worker loop since the implementation lives on the request handlers anyway as @dbutenhof pointed out |
jaredoconnell
left a comment
There was a problem hiding this comment.
This seems like an appropriate design. One thing is that OpenAIRequestHandler is a protocol, so there is no default implementation. So no-op calls need to be added to RealtimeTranscriptionWSRequestHandler and EmbeddingsRequestHandler.
And I added a comment regarding de-duplicating the function you added.
| def post_validation(self, response: GenerationResponse) -> None: | ||
| 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" | ||
| ) | ||
|
|
There was a problem hiding this comment.
It might be worth de-duplicating this and the other identical post_validation methods by making them both call the same helper function.
There was a problem hiding this comment.
Done, extracted _validate_text_response helper, both handlers call it now.
dbutenhof
left a comment
There was a problem hiding this comment.
I think there are a lot of compromises in this approach, but it's probably the lowest touch and safest. I'd like to see more consistent style in your validation methods, including docstrings; but aside from that, it's probably as good as it gets.
| """ | ||
| ... | ||
|
|
||
| def post_validation(self, response: GenerationResponse) -> None: |
There was a problem hiding this comment.
Duplicating this is ugly -- but I guess I also have to agree with not constructing a proper class hierarchy in this PR that would allow sharing it. That was why I was hoping to put it on Backend, because the backend classes do have a working hierarchy while the request handlers are a lot more chaotic. But this is probably safer since it minimizes the blast radius.
There was a problem hiding this comment.
Agreed, extracted a shared _validate_text_response helper to avoid the copy-paste.
| ) | ||
|
|
||
| def post_validation(self, response: GenerationResponse) -> None: | ||
| has_text = bool(response.text and response.text.strip()) |
There was a problem hiding this comment.
You wrote a big docstring for the null implementations, but nothing for the ones that do something? 😁
There was a problem hiding this comment.
added docstrings to all three overrides (Text, Responses, Pooling).
| pooling-specific request structure with nested data fields. | ||
| """ | ||
|
|
||
| def post_validation(self, response: GenerationResponse) -> None: # noqa: ARG002 |
There was a problem hiding this comment.
And ... completely different style here, without a docstring?
There was a problem hiding this comment.
added a docstring explaining why pooling skips validation.
Extract _validate_text_response helper shared by TextCompletionsRequestHandler and ResponsesRequestHandler. Add docstrings to all post_validation overrides. Assisted-by: Claude Signed-off-by: Uri Shaket <ushaket@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
This PR fixes a scheduler correctness bug where requests could be marked as
completedeven when the backend resolved without a usable terminal response. It adds explicit terminal-response validation in the worker so malformed/empty terminal results are surfaced aserroredwith a clear diagnostic instead of being counted as successful requests.Details
WorkerProcessto guard final status transitions.errored[UNUSABLE_BACKEND_RESPONSE] backend resolved without a usable terminal response payloadGenerationResponse-aware usability criteria:textoroutput_metrics.total_tokens > 0Noneterminal response is always unusableGenerationResponsefallback remainsbool(response)for generic/test compatibilitytests/unit/scheduler/test_worker.pyfor:erroredGenerationResponse->erroredGenerationResponsewith empty text ->completedTest Plan
uv run pytest -q tests/unit/scheduler/test_worker.py -k "terminal_response or empty_generation_response or generation_response_with_tokens or invalid_initialization"completedGenerationResponseis not markedcompletedoutput_tokens > 0) is accepted ascompletedRelated Issues
Use of AI
## WRITTEN BY AI ##)git log
commit ffedece
Author: Uri Shaket ushaket@redhat.com
Date: Mon Mar 2 23:55:49 2026 +0200
commit 03cdec7
Author: Uri Shaket ushaket@redhat.com
Date: Mon Mar 2 23:57:34 2026 +0200
commit e5fda32
Author: Uri Shaket ushaket@redhat.com
Date: Wed Jul 15 11:25:12 2026 +0300
commit d459b2e
Author: Uri Shaket ushaket@redhat.com
Date: Wed Jul 15 13:23:05 2026 +0300
commit 36036d0
Author: Uri Shaket ushaket@redhat.com
Date: Thu Jul 16 17:28:58 2026 +0300
commit 1789158
Author: Uri Shaket ushaket@redhat.com
Date: Tue Jul 21 20:39:29 2026 +0300
commit 6de940a
Author: Uri Shaket ushaket@redhat.com
Date: Thu Jul 23 09:44:58 2026 +0300
Assisted-by: Claude
Co-authored-by: Cursor cursoragent@cursor.com
Signed-off-by: Uri Shaket ushaket@redhat.com