fix(responses): carry streamed logprobs into the completed snapshot and drop them on guardrail rewrites

Greptile review follow-ups on the bridge logprobs change. The response.completed
snapshot is rebuilt through stream_chunk_builder, which drops chat logprobs, so the
text part now takes the logprobs the stream carried. The per-chunk accumulator appends
and flattens once instead of recopying every earlier token. CarriesOutputTextLogprobs
carries a frozen-ok reason since guardrails rewrite text parts and stream events in
place. A guardrail rewrite of output_text (part, delta, done) now also clears the
logprobs that would spell out the original text.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-11-01 00:17:01 +00:00
parent 45805384e5
commit 3588a1e877
5 changed files with 132 additions and 11 deletions

View file

@ -1425,11 +1425,18 @@ class OpenAIResponsesHandler(BaseTranslation):
else:
setattr(event, field, value)
@staticmethod
def _write_rewritten_text(event: object, field: str, text: str) -> None:
"""A rewrite replaces the text, so the logprobs that spell out the original text go with it."""
OpenAIResponsesHandler._write_event_field(event, field, text)
if stream_item_field(event, "logprobs") is not None:
OpenAIResponsesHandler._write_event_field(event, "logprobs", None)
@staticmethod
def _write_content_part_text(part: object, text: str) -> None:
field: Final = _content_part_text_field(part)
if _has_writable_field(part, field):
OpenAIResponsesHandler._write_event_field(part, field, text)
OpenAIResponsesHandler._write_rewritten_text(part, field, text)
@staticmethod
def _write_compaction_summary(item: object, summary: str) -> None:
@ -1468,9 +1475,9 @@ class OpenAIResponsesHandler(BaseTranslation):
continue
position = (output_index, content_index)
if event_type in _TEXT_DELTA_EVENT_TYPES and position in delta_replacements:
self._write_event_field(event, "delta", next(delta_replacements[position]))
self._write_rewritten_text(event, "delta", next(delta_replacements[position]))
elif event_type == "response.output_text.done" and position in rewrites_by_position:
self._write_event_field(event, "text", rewrites_by_position[position])
self._write_rewritten_text(event, "text", rewrites_by_position[position])
elif event_type == "response.refusal.done" and position in rewrites_by_position:
self._write_event_field(event, "refusal", rewrites_by_position[position])
elif event_type == "response.content_part.done" and position in rewrites_by_position:

View file

@ -1,7 +1,7 @@
import time
import uuid
from collections.abc import Mapping, Sequence
from itertools import filterfalse
from itertools import chain, filterfalse
from types import MappingProxyType
from typing import Any, Final, cast
@ -63,7 +63,11 @@ from litellm.types.llms.openai import (
WebSearchCallInProgressEvent,
WebSearchCallSearchingEvent,
)
from litellm.types.responses.main import GenericResponseOutputItemContentAnnotation
from litellm.types.responses.main import (
GenericResponseOutputItem,
GenericResponseOutputItemContentAnnotation,
OutputText,
)
from litellm.types.utils import Delta as ChatCompletionDelta
from litellm.types.utils import (
ModelResponse,
@ -213,7 +217,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sent_reasoning_summary_text_done_event: bool = False
self._sent_reasoning_summary_part_done_event: bool = False
self._reasoning_summary_text: str = ""
self._text_logprobs: tuple[OutputTextLogprob, ...] = ()
self._text_logprob_chunks: list[
tuple[OutputTextLogprob, ...]
] = [] # mutable-ok: appended per chunk, flattened once at done
# -- GENERIC RESPONSE-EVENTS PENDING QUEUE as required by fix --
self._pending_response_events: list[BaseLiteLLMOpenAIResponseObject] = []
self._reasoning_active = False
@ -1021,9 +1027,15 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
logprobs=self._final_text_logprobs(litellm_complete_object),
)
def _streamed_text_logprobs(self) -> tuple[OutputTextLogprob, ...] | None:
if not self._text_logprob_chunks:
return None
return tuple(chain.from_iterable(self._text_logprob_chunks))
def _final_text_logprobs(self, litellm_complete_object: ModelResponse) -> tuple[OutputTextLogprob, ...] | None:
if self._text_logprobs:
return self._text_logprobs
streamed: Final = self._streamed_text_logprobs()
if streamed is not None:
return streamed
return output_text_logprobs(getattr(litellm_complete_object.choices[0], "logprobs", None))
def create_output_content_part_done_event(self, litellm_complete_object: ModelResponse) -> ContentPartDoneEvent:
@ -1525,7 +1537,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
delta_logprobs: Final = output_text_logprobs(getattr(chunk.choices[0], "logprobs", None))
if delta_logprobs is not None:
self._text_logprobs = (*self._text_logprobs, *delta_logprobs)
self._text_logprob_chunks.append(delta_logprobs)
text_delta_event: Final = OutputTextDeltaEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA,
item_id=item_id,
@ -1582,7 +1594,18 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
)
compaction_aligned: Final = _output_items_with_id(reasoning_aligned, "compaction", self._compaction_item_id)
streamed_items: Final = filterfalse(self._is_unstreamed_empty_message, compaction_aligned)
return tuple(sorted(streamed_items, key=self._streamed_output_position))
return tuple(map(self._with_streamed_logprobs, sorted(streamed_items, key=self._streamed_output_position)))
def _with_streamed_logprobs(self, item: object) -> object:
"""stream_chunk_builder drops chat logprobs, so the snapshot's text part takes the ones the stream carried."""
logprobs: Final = self._streamed_text_logprobs()
if logprobs is None or not isinstance(item, GenericResponseOutputItem):
return item
content: Final = tuple(
part.model_copy(update={"logprobs": logprobs}) if isinstance(part, OutputText) else part
for part in item.content
)
return item.model_copy(update={"content": list(content)})
def _is_unstreamed_empty_message(self, item: object) -> bool:
if getattr(item, "type", None) != "message" or self._message_output_index is not None:

View file

@ -100,7 +100,7 @@ class OutputTextLogprob(TypedDict):
top_logprobs: ReadOnly[Sequence[OutputTextTopLogprob]]
class CarriesOutputTextLogprobs(BaseLiteLLMOpenAIResponseObject):
class CarriesOutputTextLogprobs(BaseLiteLLMOpenAIResponseObject): # frozen-ok: guardrails rewrite these in place
"""A Responses text payload carries ``logprobs`` only when the chat choice behind it sent them."""
logprobs: Sequence[OutputTextLogprob] | None = None

View file

@ -36,6 +36,8 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.llms import get_guardrail_translation_mapping
from litellm.llms.openai.responses.guardrail_translation.handler import (
OpenAIResponsesHandler,
stream_item_field,
stream_item_items,
)
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api import GenericGuardrailAPI
@ -46,8 +48,11 @@ from litellm.types.guardrails import ContentFilterAction, ContentFilterPattern
from litellm.types.llms.openai import (
ChatCompletionToolCallChunk,
ContentPartDoneEvent,
ContentPartDonePartOutputText,
ContentPartDonePartRefusal,
OutputItemDoneEvent,
OutputTextDeltaEvent,
OutputTextDoneEvent,
RefusalDeltaEvent,
RefusalDoneEvent,
ResponseIncompleteEvent,
@ -5229,3 +5234,87 @@ class TestRefusalPartsReachTextGuardrails:
)
assert (events[0].delta, events[1].refusal) == (_MASKED_REFUSAL, _MASKED_REFUSAL)
class _PersimmonTextMaskingGuardrail(CustomGuardrail):
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[LiteLLMLoggingObj] = None,
) -> GenericGuardrailAPIInputs:
return {**inputs, "texts": [text.replace("persimmon", "[MASKED]") for text in inputs.get("texts", [])]}
_PERSIMMON_LOGPROBS: Final = (
{"token": "ripe", "logprob": -0.1, "bytes": None, "top_logprobs": []},
{"token": " persimmon", "logprob": -0.2, "bytes": None, "top_logprobs": []},
)
@pytest.mark.asyncio
async def test_masked_output_text_drops_the_logprobs_that_spell_the_original_text() -> None:
response = ResponsesAPIResponse.model_validate(
{
"id": "resp_1",
"created_at": 1,
"model": "gpt-5.6",
"object": "response",
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{"type": "output_text", "text": "ripe persimmon", "annotations": [], "logprobs": list(_PERSIMMON_LOGPROBS)}
],
}
],
}
)
result = await OpenAIResponsesHandler().process_output_response(
response, _PersimmonTextMaskingGuardrail(guardrail_name="mask")
)
part = stream_item_items(result.output[0], "content")[0]
assert (stream_item_field(part, "text"), stream_item_field(part, "logprobs")) == ("ripe [MASKED]", None)
def test_stream_rewrite_drops_logprobs_from_the_delta_done_and_part_events() -> None:
delta = OutputTextDeltaEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA,
item_id="msg_1",
output_index=0,
content_index=0,
delta="ripe persimmon",
logprobs=_PERSIMMON_LOGPROBS,
)
done = OutputTextDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE,
item_id="msg_1",
output_index=0,
content_index=0,
text="ripe persimmon",
logprobs=_PERSIMMON_LOGPROBS,
)
part_done = ContentPartDoneEvent(
type=ResponsesAPIStreamEvents.CONTENT_PART_DONE,
item_id="msg_1",
output_index=0,
content_index=0,
part=ContentPartDonePartOutputText(
type="output_text", text="ripe persimmon", annotations=[], logprobs=_PERSIMMON_LOGPROBS
),
)
OpenAIResponsesHandler()._sync_stream_events_with_rewrites(
stream_events=(delta, done, part_done), rewrites_by_position={(0, 0): "ripe [MASKED]"}
)
assert (delta.delta, delta.logprobs) == ("ripe [MASKED]", None)
assert (done.text, done.logprobs) == ("ripe [MASKED]", None)
assert (part_done.part.text, part_done.part.logprobs) == ("ripe [MASKED]", None)

View file

@ -2023,6 +2023,8 @@ def test_streamed_chat_logprobs_reach_the_text_delta_and_done_events() -> None:
assert done["logprobs"] == [hello, period]
assert part_done["part"]["logprobs"] == [hello, period]
assert item_done["item"]["content"][0]["logprobs"] == [hello, period]
completed: Final = next(event for event in events if event["type"] == "response.completed")
assert completed["response"]["output"][0]["content"][0]["logprobs"] == [hello, period]
def test_streamed_chat_without_logprobs_emits_text_events_without_a_logprobs_key() -> None: