97e91a83f3
Ruff / Ruff (push) Has been cancelled
Test / Core Tests (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.10) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.11) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.12) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.13) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.9) (push) Has been cancelled
Test / Full Coverage (Python 3.11) (push) Has been cancelled
Test / Core Provider Tests (OpenAI) (push) Has been cancelled
Test / Core Provider Tests (Anthropic) (push) Has been cancelled
Test / Core Provider Tests (Google) (push) Has been cancelled
Test / Core Provider Tests (Other) (push) Has been cancelled
Test / Anthropic Tests (push) Has been cancelled
Test / Gemini Tests (push) Has been cancelled
Test / Google GenAI Tests (push) Has been cancelled
Test / Vertex AI Tests (push) Has been cancelled
Test / OpenAI Tests (push) Has been cancelled
Test / Writer Tests (push) Has been cancelled
Test / Auto Client Tests (push) Has been cancelled
ty / type-check (push) Has been cancelled
214 lines
7.3 KiB
Python
214 lines
7.3 KiB
Python
"""Tests for reasoning summary event surfacing in RESPONSES_TOOLS streaming.
|
|
|
|
These tests verify the fix for issue #2291: ResponseReasoningSummaryTextDeltaEvent
|
|
and ResponseReasoningSummaryTextDoneEvent are forwarded to an on_event callback
|
|
instead of being silently dropped during create_partial streaming.
|
|
|
|
All tests use mock event objects — no API key required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from instructor.dsl.partial import PartialBase
|
|
from instructor.mode import Mode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lightweight mock event classes matching openai.types.responses shapes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _MockFunctionCallArgumentsDeltaEvent:
|
|
"""Mimics ResponseFunctionCallArgumentsDeltaEvent."""
|
|
|
|
type: str = "response.function_call_arguments.delta"
|
|
|
|
def __init__(self, delta: str) -> None:
|
|
self.delta = delta
|
|
|
|
|
|
class _MockReasoningSummaryTextDeltaEvent:
|
|
"""Mimics ResponseReasoningSummaryTextDeltaEvent."""
|
|
|
|
type: str = "response.reasoning_summary_text.delta"
|
|
|
|
def __init__(self, delta: str) -> None:
|
|
self.delta = delta
|
|
|
|
|
|
class _MockReasoningSummaryTextDoneEvent:
|
|
"""Mimics ResponseReasoningSummaryTextDoneEvent."""
|
|
|
|
type: str = "response.reasoning_summary_text.done"
|
|
|
|
def __init__(self, text: str) -> None:
|
|
self.text = text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Monkeypatch helper: replace the openai import inside extract_json
|
|
# so our mocks are recognised by isinstance checks.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_openai_response_types(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Replace openai.types.responses event classes with our mocks."""
|
|
import openai.types.responses as responses_mod
|
|
|
|
monkeypatch.setattr(
|
|
responses_mod,
|
|
"ResponseFunctionCallArgumentsDeltaEvent",
|
|
_MockFunctionCallArgumentsDeltaEvent,
|
|
)
|
|
monkeypatch.setattr(
|
|
responses_mod,
|
|
"ResponseReasoningSummaryTextDeltaEvent",
|
|
_MockReasoningSummaryTextDeltaEvent,
|
|
)
|
|
monkeypatch.setattr(
|
|
responses_mod,
|
|
"ResponseReasoningSummaryTextDoneEvent",
|
|
_MockReasoningSummaryTextDoneEvent,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync tests — extract_json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractJsonReasoningEvents:
|
|
"""Tests for PartialBase.extract_json with RESPONSES_TOOLS mode."""
|
|
|
|
def test_yields_function_call_deltas(self) -> None:
|
|
"""Existing behaviour: FunctionCallArgumentsDelta events yield JSON text."""
|
|
chunks = [
|
|
_MockFunctionCallArgumentsDeltaEvent('{"name":'),
|
|
_MockFunctionCallArgumentsDeltaEvent('"Alice"}'),
|
|
]
|
|
result = list(PartialBase.extract_json(iter(chunks), Mode.RESPONSES_TOOLS))
|
|
assert result == ['{"name":', '"Alice"}']
|
|
|
|
def test_forwards_reasoning_delta_to_on_event(self) -> None:
|
|
"""Reasoning delta events are forwarded to on_event callback."""
|
|
reasoning_chunk = _MockReasoningSummaryTextDeltaEvent("thinking...")
|
|
fn_chunk = _MockFunctionCallArgumentsDeltaEvent('{"a":1}')
|
|
chunks = [reasoning_chunk, fn_chunk]
|
|
|
|
callback = MagicMock()
|
|
json_parts = list(
|
|
PartialBase.extract_json(
|
|
iter(chunks), Mode.RESPONSES_TOOLS, on_event=callback
|
|
)
|
|
)
|
|
|
|
# JSON yield is preserved
|
|
assert json_parts == ['{"a":1}']
|
|
# Callback was invoked with the reasoning event
|
|
callback.assert_called_once_with(reasoning_chunk)
|
|
|
|
def test_forwards_reasoning_done_to_on_event(self) -> None:
|
|
"""Reasoning done events are forwarded to on_event callback."""
|
|
done_chunk = _MockReasoningSummaryTextDoneEvent("full reasoning text")
|
|
chunks = [done_chunk]
|
|
|
|
callback = MagicMock()
|
|
list(
|
|
PartialBase.extract_json(
|
|
iter(chunks), Mode.RESPONSES_TOOLS, on_event=callback
|
|
)
|
|
)
|
|
|
|
callback.assert_called_once_with(done_chunk)
|
|
|
|
def test_no_on_event_silently_ignores_reasoning_events(self) -> None:
|
|
"""Without on_event, reasoning events are silently dropped (backward compat)."""
|
|
chunks: list[Any] = [
|
|
_MockReasoningSummaryTextDeltaEvent("ignored"),
|
|
_MockFunctionCallArgumentsDeltaEvent('{"ok":true}'),
|
|
]
|
|
|
|
# Should not raise — reasoning events are simply skipped
|
|
result = list(PartialBase.extract_json(iter(chunks), Mode.RESPONSES_TOOLS))
|
|
assert result == ['{"ok":true}']
|
|
|
|
def test_works_with_inbuilt_tools_mode(self) -> None:
|
|
"""on_event also works with RESPONSES_TOOLS_WITH_INBUILT_TOOLS mode."""
|
|
reasoning_chunk = _MockReasoningSummaryTextDeltaEvent("reasoning")
|
|
fn_chunk = _MockFunctionCallArgumentsDeltaEvent('{"b":2}')
|
|
chunks = [reasoning_chunk, fn_chunk]
|
|
|
|
callback = MagicMock()
|
|
json_parts = list(
|
|
PartialBase.extract_json(
|
|
iter(chunks),
|
|
Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS,
|
|
on_event=callback,
|
|
)
|
|
)
|
|
|
|
assert json_parts == ['{"b":2}']
|
|
callback.assert_called_once_with(reasoning_chunk)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async tests — extract_json_async
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _async_iter(items: list[Any]):
|
|
"""Convert a list into an async generator."""
|
|
for item in items:
|
|
yield item
|
|
|
|
|
|
class TestExtractJsonAsyncReasoningEvents:
|
|
"""Tests for PartialBase.extract_json_async with RESPONSES_TOOLS mode."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forwards_reasoning_events_async(self) -> None:
|
|
"""Async path forwards reasoning events to sync callback."""
|
|
reasoning_chunk = _MockReasoningSummaryTextDeltaEvent("async thinking")
|
|
fn_chunk = _MockFunctionCallArgumentsDeltaEvent('{"c":3}')
|
|
|
|
callback = MagicMock()
|
|
json_parts: list[str] = []
|
|
async for part in PartialBase.extract_json_async(
|
|
_async_iter([reasoning_chunk, fn_chunk]),
|
|
Mode.RESPONSES_TOOLS,
|
|
on_event=callback,
|
|
):
|
|
json_parts.append(part)
|
|
|
|
assert json_parts == ['{"c":3}']
|
|
callback.assert_called_once_with(reasoning_chunk)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_on_event_callback_awaited(self) -> None:
|
|
"""Async callbacks are properly awaited in extract_json_async."""
|
|
received: list[Any] = []
|
|
|
|
async def async_callback(event: Any) -> None:
|
|
received.append(event)
|
|
|
|
reasoning_chunk = _MockReasoningSummaryTextDeltaEvent("awaited")
|
|
fn_chunk = _MockFunctionCallArgumentsDeltaEvent('{"d":4}')
|
|
|
|
json_parts: list[str] = []
|
|
async for part in PartialBase.extract_json_async(
|
|
_async_iter([reasoning_chunk, fn_chunk]),
|
|
Mode.RESPONSES_TOOLS,
|
|
on_event=async_callback,
|
|
):
|
|
json_parts.append(part)
|
|
|
|
assert json_parts == ['{"d":4}']
|
|
assert len(received) == 1
|
|
assert received[0] is reasoning_chunk
|