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
244 lines
7.3 KiB
Python
244 lines
7.3 KiB
Python
"""Test for streaming reask bug fix.
|
|
|
|
Bug: When using streaming mode with max_retries > 1, if validation fails,
|
|
the reask handlers crash with "'Stream' object has no attribute 'choices'"
|
|
because they expect a ChatCompletion but receive a Stream object.
|
|
|
|
GitHub Issue: https://github.com/jxnl/instructor/issues/1991
|
|
"""
|
|
|
|
from typing import Any, Optional
|
|
|
|
import pytest
|
|
from pydantic import ValidationError, BaseModel, field_validator
|
|
|
|
from instructor.mode import Mode
|
|
from instructor.processing.response import handle_reask_kwargs
|
|
|
|
|
|
class MockStream:
|
|
"""Mock Stream object that mimics openai.Stream behavior."""
|
|
|
|
def __iter__(self):
|
|
return iter([])
|
|
|
|
def __next__(self):
|
|
raise StopIteration
|
|
|
|
|
|
class MockResponsesToolCall:
|
|
"""Mock tool call item in a responses output list."""
|
|
|
|
def __init__(
|
|
self,
|
|
arguments: str,
|
|
name: Optional[str] = None,
|
|
call_id: Optional[str] = None,
|
|
item_type: str = "function_call",
|
|
) -> None:
|
|
self.arguments = arguments
|
|
self.name = name
|
|
self.call_id = call_id
|
|
self.type = item_type
|
|
|
|
|
|
class MockResponsesReasoningItem:
|
|
"""Mock reasoning item in a responses output list."""
|
|
|
|
type = "reasoning"
|
|
|
|
|
|
class MockResponsesResponse:
|
|
"""Mock Responses API response with output items."""
|
|
|
|
def __init__(self, output: list[Any]) -> None:
|
|
self.output = output
|
|
|
|
|
|
def create_mock_validation_error():
|
|
"""Create a real Pydantic ValidationError for testing."""
|
|
|
|
class TestModel(BaseModel):
|
|
name: str
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def must_have_space(cls, v):
|
|
if " " not in v:
|
|
raise ValueError("must contain space")
|
|
return v
|
|
|
|
try:
|
|
TestModel(name="John")
|
|
except ValidationError as e:
|
|
return e
|
|
|
|
|
|
class TestStreamingReaskBug:
|
|
"""Tests for the streaming reask bug fix."""
|
|
|
|
def test_reask_tools_with_stream_object_does_not_crash(self):
|
|
"""Test that reask_tools handles Stream objects without crashing.
|
|
|
|
Previously, this would crash with:
|
|
"'Stream' object has no attribute 'choices'"
|
|
"""
|
|
mock_stream = MockStream()
|
|
kwargs = {
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
"tools": [{"type": "function", "function": {"name": "test"}}],
|
|
}
|
|
exception = create_mock_validation_error()
|
|
|
|
# This should not raise an AttributeError
|
|
result = handle_reask_kwargs(
|
|
kwargs=kwargs,
|
|
mode=Mode.TOOLS,
|
|
response=mock_stream,
|
|
exception=exception,
|
|
)
|
|
|
|
# Should return modified kwargs with error message
|
|
assert "messages" in result
|
|
assert len(result["messages"]) > 1 # Original + error message
|
|
|
|
def test_reask_anthropic_tools_with_stream_object(self):
|
|
"""Test that Anthropic reask handler handles Stream objects."""
|
|
mock_stream = MockStream()
|
|
kwargs = {
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
}
|
|
exception = create_mock_validation_error()
|
|
|
|
result = handle_reask_kwargs(
|
|
kwargs=kwargs,
|
|
mode=Mode.ANTHROPIC_TOOLS,
|
|
response=mock_stream,
|
|
exception=exception,
|
|
)
|
|
|
|
assert "messages" in result
|
|
|
|
def test_reask_with_none_response(self):
|
|
"""Test that reask handlers handle None response gracefully."""
|
|
kwargs = {
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
}
|
|
exception = create_mock_validation_error()
|
|
|
|
result = handle_reask_kwargs(
|
|
kwargs=kwargs,
|
|
mode=Mode.TOOLS,
|
|
response=None,
|
|
exception=exception,
|
|
)
|
|
|
|
assert "messages" in result
|
|
|
|
def test_reask_responses_tools_skips_reasoning_items_and_includes_details(self):
|
|
"""Test responses reask ignores reasoning items and adds tool details."""
|
|
mock_response = MockResponsesResponse(
|
|
output=[
|
|
MockResponsesReasoningItem(),
|
|
MockResponsesToolCall(
|
|
arguments='{"name": "Jane"}',
|
|
name="extract_person",
|
|
call_id="call_123",
|
|
),
|
|
]
|
|
)
|
|
kwargs = {
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
}
|
|
exception = create_mock_validation_error()
|
|
|
|
result = handle_reask_kwargs(
|
|
kwargs=kwargs,
|
|
mode=Mode.RESPONSES_TOOLS,
|
|
response=mock_response,
|
|
exception=exception,
|
|
)
|
|
|
|
assert "messages" in result
|
|
assert len(result["messages"]) == 2
|
|
reask_content = result["messages"][-1]["content"]
|
|
assert "tool call name=extract_person, id=call_123" in reask_content
|
|
assert '{"name": "Jane"}' in reask_content
|
|
|
|
def test_reask_md_json_with_stream_object(self):
|
|
"""Test that MD_JSON reask handler handles Stream objects."""
|
|
mock_stream = MockStream()
|
|
kwargs = {
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
}
|
|
exception = create_mock_validation_error()
|
|
|
|
result = handle_reask_kwargs(
|
|
kwargs=kwargs,
|
|
mode=Mode.MD_JSON,
|
|
response=mock_stream,
|
|
exception=exception,
|
|
)
|
|
|
|
assert "messages" in result
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not pytest.importorskip("openai", reason="openai not installed"),
|
|
reason="openai not installed",
|
|
)
|
|
class TestStreamingReaskIntegration:
|
|
"""Integration tests that require OpenAI API key."""
|
|
|
|
@pytest.fixture
|
|
def client(self):
|
|
"""Create instructor client if API key available."""
|
|
import os
|
|
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
pytest.skip(
|
|
"OPENAI_API_KEY not set" # ty: ignore[too-many-positional-arguments]
|
|
)
|
|
|
|
import instructor
|
|
from openai import OpenAI
|
|
|
|
return instructor.from_openai(OpenAI())
|
|
|
|
def test_streaming_with_retries_and_failing_validator(self, client):
|
|
"""Test that streaming with retries doesn't crash on validation failure.
|
|
|
|
This test verifies that the reask handler doesn't crash with
|
|
"'Stream' object has no attribute 'choices'" when validation fails
|
|
during streaming. The actual validation outcome depends on LLM behavior.
|
|
"""
|
|
|
|
class ImpossibleModel(BaseModel):
|
|
"""Model with a validator that always fails."""
|
|
|
|
value: str
|
|
|
|
@field_validator("value")
|
|
@classmethod
|
|
def always_fail(cls, v: str) -> str: # noqa: ARG003
|
|
raise ValueError("This validator always fails for testing")
|
|
|
|
# This should not crash with AttributeError about Stream.choices
|
|
# It should raise InstructorRetryException after retries are exhausted
|
|
from instructor.core.exceptions import InstructorRetryException
|
|
|
|
with pytest.raises(InstructorRetryException):
|
|
list(
|
|
client.chat.completions.create_partial(
|
|
model="gpt-4o-mini",
|
|
max_retries=2,
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": "Return value='test'",
|
|
}
|
|
],
|
|
response_model=ImpossibleModel,
|
|
)
|
|
)
|