fix(responses): keep the reasoning item open while reasoning interleaves with content

This commit is contained in:
devin-ai-integration[bot] 2026-08-11 00:49:45 +00:00
parent abba4600b0
commit 8c64b7980c
2 changed files with 66 additions and 4 deletions

View file

@ -174,13 +174,13 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
chunk.model_copy(update=MappingProxyType({"choices": (content_choice,)})),
)
def _queue_reasoning_lifecycle_events(self, chunk: ModelResponseStream) -> None:
def _queue_reasoning_lifecycle_events(self, chunk: ModelResponseStream, *, ends_reasoning: bool) -> None:
if not self._reasoning_active or self._reasoning_done_emitted:
return
delta: Final = chunk.choices[0].delta if chunk.choices else None
if delta and getattr(delta, "reasoning_content", None):
self._accumulated_reasoning_content_parts.append(delta.reasoning_content)
if not self._is_reasoning_end(chunk):
if not ends_reasoning:
return
reasoning_content: Final = "".join(self._accumulated_reasoning_content_parts)
@ -208,10 +208,22 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._reasoning_done_emitted = True
self._reasoning_active = False
def _chunk_ends_reasoning(self, chunk: ModelResponseStream, *, is_split: bool) -> bool:
if not chunk.choices:
return False
# A chunk carrying both reasoning and content only ends reasoning if the turn ends
# there too: providers that interleave the two keep emitting reasoning afterwards.
if is_split:
return chunk.choices[0].finish_reason is not None
return bool(self._is_reasoning_end(chunk))
def _queue_events_for_chunk(self, chunk: ModelResponseStream) -> None:
for part in self._split_reasoning_and_content(chunk):
parts: Final = self._split_reasoning_and_content(chunk)
ends_reasoning: Final = self._chunk_ends_reasoning(chunk, is_split=len(parts) > 1)
for part in parts:
self._ensure_output_item_for_chunk(part)
self._queue_reasoning_lifecycle_events(part)
if part is parts[0]:
self._queue_reasoning_lifecycle_events(part, ends_reasoning=ends_reasoning)
response_api_chunk = self._transform_chat_completion_chunk_to_response_api_chunk(part)
if response_api_chunk:
self._pending_response_events.append(response_api_chunk)

View file

@ -375,6 +375,56 @@ def test_combined_reasoning_and_content_chunk_emits_both_deltas_sync():
assert "".join(text_deltas) == "MARKER_ALPHA begin middle end"
def _interleaved_reasoning_and_text_chunks() -> list[ModelResponseStream]:
def make(
content: str | None = None,
reasoning: str | None = None,
finish_reason: str | None = None,
) -> ModelResponseStream:
return ModelResponseStream(
id="chatcmpl-interleaved",
created=1234567890,
model="test-model",
object="chat.completion.chunk",
choices=[
StreamingChoices(
finish_reason=finish_reason,
index=0,
delta=Delta(content=content, role="assistant", reasoning_content=reasoning),
)
],
)
return [
make(reasoning="think one ", content="answer one "),
make(reasoning="think two ", content="answer two "),
make(reasoning="think three", content="answer three"),
make(finish_reason="stop"),
]
@pytest.mark.asyncio
async def test_interleaved_reasoning_stays_open_until_the_turn_ends():
"""Reasoning that keeps arriving alongside content must not be closed after the first chunk."""
iterator = _new_iterator(_interleaved_reasoning_and_text_chunks())
types = []
reasoning_deltas = []
summary_done_text = None
async for event in iterator:
types.append(event.type)
if event.type == "response.reasoning_summary_text.delta":
reasoning_deltas.append(event.delta)
elif event.type == "response.reasoning_summary_text.done":
summary_done_text = event.text
assert "".join(reasoning_deltas) == "think one think two think three"
assert summary_done_text == "".join(reasoning_deltas)
assert types.count("response.reasoning_summary_text.done") == 1
last_reasoning_delta = len(types) - 1 - types[::-1].index("response.reasoning_summary_text.delta")
assert last_reasoning_delta < types.index("response.reasoning_summary_text.done")
def test_streaming_chunk_id_raw():
"""Test that streaming chunk IDs are raw (not encoded) to match OpenAI format"""
chunk = ModelResponseStream(