FIX: warn instead of raising on reasoning-model token truncation (Chat and Responses targets)#2166
Open
romanlutz wants to merge 16 commits into
Open
FIX: warn instead of raising on reasoning-model token truncation (Chat and Responses targets)#2166romanlutz wants to merge 16 commits into
romanlutz wants to merge 16 commits into
Conversation
When a reasoning model hits max_completion_tokens, the API returns finish_reason='length' with empty visible content. Previously this raised EmptyResponseException (misleadingly reported as 'Status Code: 204') and triggered the 10x retry storm. _validate_response now logs a warning explaining that reasoning models consume tokens on hidden reasoning, and returns a graceful empty response (error='empty') instead of raising, so runs continue. Non-length empty responses still raise as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
jbolor21
approved these changes
Jul 11, 2026
behnam-o
reviewed
Jul 13, 2026
…se target Mirror the OpenAIChatTarget truncation fix in OpenAIResponseTarget. When the Responses API returns status='incomplete' with reason='max_output_tokens', warn and preserve any completed output (reasoning, partial text) instead of raising a PyritException that halts the run. Falls back to a graceful empty text piece when no visible text was produced, and skips partial tool/function-call sections so an incomplete call cannot re-enter the agentic loop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
…construction Address review feedback: _validate_response no longer returns a Message. It now only validates (warns and returns None on token-limit truncation, raises on genuinely empty responses so retries still fire). Building the graceful-empty or partial truncated Message moves into _construct_message_from_response_async in both OpenAIChatTarget and OpenAIResponseTarget, and the shared _validate_response return type narrows to None. Behavior is unchanged; responsibilities are cleaner. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Reconcile the reasoning-model truncation fix with main's extraction of shared Chat Completions response parsing. The chat target keeps its truncation policy (warn on finish_reason='length', graceful empty on no content) layered over the shared validate/build helpers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
behnam-o
approved these changes
Jul 16, 2026
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Mirror OpenAIResponseTarget by centralizing the token-limit truncation check in a _is_truncated_response predicate (keyed on finish_reason == 'length'), used by both _validate_response and _construct_message_from_response_async. build_empty_response_for_truncated_completion is now an unconditional builder, with the truncation decision owned by the target. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Use the OpenAI SDK Response type and direct attribute access in the Responses API truncation helper so type checking can catch field changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Replace Any annotations on get_finish_reason (shared Chat Completions parser) and the OpenAIChatTarget _validate_response / _is_truncated_response helpers with the concrete openai.types.chat.ChatCompletion type, and switch get_finish_reason from defensive getattr lookups to direct attribute access so field renames are caught by the type checker. Remove the now-obsolete test covering a choice without a finish_reason attribute, which cannot occur on a real ChatCompletion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adjusts OpenAI prompt targets to treat token-limit truncation as a valid but incomplete response: it logs a clear warning when truncation is detected and, when truncation produces no visible output, it returns a graceful empty response (error="empty") instead of raising EmptyResponseException (avoiding futile retries with the same token budget).
Changes:
- Update the OpenAI target validation contract so
_validate_responseraises on invalid responses (no longer returns an errorMessage). - For Chat Completions (
finish_reason="length") and Responses API (status="incomplete",reason="max_output_tokens"), warn on truncation and allow construction to produce partial output or a graceful empty message. - Add/extend unit tests to assert warnings are emitted and truncated-empty responses yield
error="empty"withprompt_metadata["truncated"]=True.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
pyrit/prompt_target/openai/openai_target.py |
Simplifies the validation flow: _validate_response is now “raise-only” and no longer returns an error Message. |
pyrit/prompt_target/openai/openai_chat_target.py |
Detects finish_reason="length" truncation, warns instead of raising, and returns a graceful empty piece when truncated output has no visible content. |
pyrit/prompt_target/openai/openai_response_target.py |
Detects Responses API truncation via status/incomplete_details, warns, tolerates empty sections on truncation, and appends a graceful empty text piece when needed. |
pyrit/prompt_target/common/chat_completions_response_parser.py |
Adds helper functions to read finish_reason and to build a graceful empty response for truncated Chat Completions. |
tests/unit/prompt_target/target/test_openai_chat_target.py |
Adds coverage for truncation warnings and truncated-empty construction behavior (including token usage capture). |
tests/unit/prompt_target/target/test_openai_response_target.py |
Adds coverage for Responses API truncation detection, warning behavior, and truncated message construction edge-cases (empty output, partial text, tool-call skipping). |
tests/unit/prompt_target/target/test_chat_completions_helpers.py |
Adds unit tests for new shared helper functions used by Chat Completions targets. |
Three EmptyResponseException messages in OpenAIResponseTarget said 'The chat returned ...' even though this is the Responses API target, not the Chat Completions target. Reword them to 'The response returned ...', matching the existing wording already used elsewhere in the same file, so failures point clearly at the Responses API output section. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
Follow-up to the reasoning-model truncation handling: - Dedup: move the empty-truncated response recipe into a shared build_empty_truncated_response helper in common/utils.py and use it from both the Chat and Responses targets instead of the Chat-parser copy the Responses target had inlined. - Typing: narrow the Responses _validate_response and _construct_message_from_response_async params from Any to Response (and the Chat construct param to ChatCompletion), and read response.output directly instead of getattr fallback. - Token usage: add token_usage_from_responses and capture usage into the first piece metadata on both the normal and truncated paths, giving the Responses target token-usage parity with Chat. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00b73356-ae65-4d83-8220-ebad37cc5850
| # Extract and parse message pieces from validated output sections | ||
| extracted_response_pieces: list[MessagePiece] = [] | ||
| has_text = False | ||
| for section in response.output: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Reasoning models spend completion tokens on hidden reasoning before emitting any visible output. When the token budget (
max_completion_tokensfor Chat Completions,max_output_tokensfor the Responses API) is set too low, the API can return a truncated response with empty visible content:finish_reason="length"for Chat, orstatus="incomplete"with reasonmax_output_tokensfor Responses.Previously both OpenAI targets treated that empty content as a hard failure and raised
EmptyResponseException(surfaced with a misleading "Status Code: 204"), which also triggered the full retry loop even though retrying with the same token budget cannot succeed.Since a low token budget can be a deliberate configuration choice, this changes the behavior to warn rather than raise, consistently across the Chat and Responses targets:
error="empty") so the run continues instead of raising and retrying.prompt_metadata["truncated"] = Trueon the first piece so downstream scorers and attacks can distinguish a truncated-empty message from a normal empty one.EmptyResponseExceptionas before.Refactoring
_validate_responseis now validation-only: it inspects the response and raises on genuinely invalid ones, while constructing the resultingMessage(including the graceful empty path) is the responsibility of_construct_message_from_response_async. The base-class contract changed from returningMessage | NonetoNone.build_empty_truncated_responsehelper inprompt_target/common/utils.py, used by both targets instead of duplicating the empty-response recipe._validate_responseand_construct_message_from_response_asyncparameters fromAnytoResponse, and typed the Chat validators withChatCompletion.token_usage_from_responsesto parse the Responses API usage shape, giving the Responses target token-usage parity with Chat.Tests and Documentation
truncatedmetadata marker, and token-usage capture (including on the truncated path).build_empty_truncated_responsehelper and fortoken_usage_from_responses.tests/unit/prompt_targetunit suite passes.No documentation changes needed. JupyText not run (no doc or notebook changes).