Skip to content

Commit 96ca9fe

Browse files
jxnlcursoragent
andauthored
Rawresponse attribute error (#2012)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 2067118 commit 96ca9fe

File tree

6 files changed

+175
-29
lines changed

6 files changed

+175
-29
lines changed

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ All notable changes to this project will be documented in this file. The format
1212
- Simplified `JsonCompleteness` by using `jiter` parsing and a sibling-based completeness heuristic (#2000)
1313

1414
### Fixed
15+
16+
- Fixed Google GenAI `safety_settings` causing `400 INVALID_ARGUMENT` when requests include image content by using image-specific harm categories when needed (#1773)
17+
- Fixed `create_with_completion()` crashing for `list[T]` response models (where `T` is a Pydantic model) by preserving `_raw_response` on list outputs (#1303)
1518
- Fixed Responses API retries crashing on reasoning items by skipping non-tool-call items in `reask_responses_tools` (#2002)
1619
- Fixed Google GenAI dict-style `config` handling to preserve `labels` and other settings like `cached_content` and `thinking_config` (#2005)
17-
- Fixed Google GenAI `safety_settings` causing `400 INVALID_ARGUMENT` when requests include image content by using image-specific harm categories when needed (#2007, #1773)
18-
- Fixed `create_with_completion()` crashing when using `list[T]` response models by preserving `_raw_response` on list outputs, and hardened optional `vertexai` imports (#2011, #1303)
20+
1921

2022
## [1.14.3] - 2026-01-13
2123

instructor/dsl/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from .maybe import Maybe
33
from .partial import Partial
44
from .citation import CitationMixin
5-
from .response_list import ListResponse
65
from .simple_type import is_simple_type, ModelAdapter
6+
from .response_list import ListResponse, ResponseList
77
from . import validators # Backwards compatibility module
88

99
__all__ = [ # noqa: F405
@@ -12,6 +12,7 @@
1212
"ListResponse",
1313
"Maybe",
1414
"Partial",
15+
"ResponseList",
1516
"is_simple_type",
1617
"ModelAdapter",
1718
"validators",

instructor/dsl/response_list.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
"""List-like response wrapper.
2+
3+
When a response model returns a list (for example `list[User]`), we still want to
4+
attach the provider's raw response so `create_with_completion()` can return it.
5+
"""
6+
17
from __future__ import annotations
28

39
from typing import Any, Generic, TypeVar
@@ -31,3 +37,7 @@ def __getitem__(self, key): # type: ignore[no-untyped-def]
3137
if isinstance(key, slice):
3238
return type(self)(value, _raw_response=self._raw_response)
3339
return value
40+
41+
42+
# Backwards-friendly alias
43+
ResponseList = ListResponse

instructor/processing/response.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ async def process_response_async(
178178
validation_context: dict[str, Any] | None = None,
179179
strict: bool | None = None,
180180
mode: Mode = Mode.TOOLS,
181-
) -> T_Model | ChatCompletion:
181+
) -> Any:
182182
"""Asynchronously process and transform LLM responses into structured models.
183183
184184
This function is the async entry point for converting raw LLM responses into validated
@@ -227,14 +227,23 @@ async def process_response_async(
227227

228228
if (
229229
inspect.isclass(response_model)
230-
and issubclass(response_model, (IterableBase, PartialBase))
230+
and issubclass(response_model, IterableBase)
231231
and stream
232232
):
233-
# from_streaming_response_async returns an AsyncGenerator
234-
# Yield each item as it comes in
235-
# Note: response type varies by mode (ChatCompletion, AsyncGenerator, etc.)
236-
return response_model.from_streaming_response_async( # type: ignore[return-value]
237-
cast(AsyncGenerator[Any, None], response), # type: ignore[arg-type]
233+
# Preserve streaming behavior for `create_iterable()` (async for).
234+
return response_model.from_streaming_response_async( # type: ignore[return-value,arg-type]
235+
cast(AsyncGenerator[Any, None], response),
236+
mode=mode,
237+
)
238+
239+
if (
240+
inspect.isclass(response_model)
241+
and issubclass(response_model, PartialBase)
242+
and stream
243+
):
244+
# Return the AsyncGenerator directly for streaming Partial responses.
245+
return response_model.from_streaming_response_async( # type: ignore[return-value,arg-type]
246+
cast(AsyncGenerator[Any, None], response),
238247
mode=mode,
239248
)
240249

@@ -256,6 +265,7 @@ async def process_response_async(
256265

257266
if isinstance(response_model, ParallelBase):
258267
logger.debug(f"Returning model from ParallelBase")
268+
model._raw_response = response
259269
return model
260270

261271
if isinstance(model, AdapterBase):
@@ -274,7 +284,7 @@ def process_response(
274284
validation_context: dict[str, Any] | None = None,
275285
strict=None,
276286
mode: Mode = Mode.TOOLS,
277-
) -> T_Model | list[T_Model] | None:
287+
) -> Any:
278288
"""Process and transform LLM responses into structured models (synchronous).
279289
280290
This is the main entry point for converting raw LLM responses into validated Pydantic
@@ -333,18 +343,27 @@ class to parse the response into. Special DSL types supported:
333343

334344
if (
335345
inspect.isclass(response_model)
336-
and issubclass(response_model, (IterableBase, PartialBase))
346+
and issubclass(response_model, IterableBase)
347+
and stream
348+
):
349+
# Preserve streaming behavior for `create_iterable()` (for/async for).
350+
return response_model.from_streaming_response( # type: ignore[return-value]
351+
response,
352+
mode=mode,
353+
)
354+
355+
if (
356+
inspect.isclass(response_model)
357+
and issubclass(response_model, PartialBase)
337358
and stream
338359
):
339-
# from_streaming_response returns a Generator
340-
# Collect all yielded values into a list
341-
tasks = list(
360+
# Collect partial stream to surface validation errors inside retry logic.
361+
return list(
342362
response_model.from_streaming_response( # type: ignore
343363
response,
344364
mode=mode,
345365
)
346366
)
347-
return tasks
348367

349368
model = response_model.from_response( # type: ignore
350369
response,
@@ -364,6 +383,7 @@ class to parse the response into. Special DSL types supported:
364383

365384
if isinstance(response_model, ParallelBase):
366385
logger.debug(f"Returning model from ParallelBase")
386+
model._raw_response = response
367387
return model
368388

369389
if isinstance(model, AdapterBase):

instructor/utils/core.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -596,11 +596,13 @@ def prepare_response_model(response_model: type[T] | None) -> type[T] | None:
596596
if response_model is None:
597597
return None
598598

599-
# `list[int | str]` and similar scalar lists are treated as simple types and should
600-
# be adapted, not converted into an IterableModel.
601599
origin = get_origin(response_model)
600+
601+
# For `list[int | str]` and other scalar lists, keep the simple-type adapter path.
602+
# However, for `list[User]` (or `list[Union[User, Other]]`) we want IterableModel.
602603
if origin is list and is_simple_type(response_model):
603604
args = get_args(response_model)
605+
inner = args[0] if args else None
604606

605607
def _is_model_type(t: Any) -> bool:
606608
if inspect.isclass(t) and issubclass(t, BaseModel):
@@ -609,26 +611,30 @@ def _is_model_type(t: Any) -> bool:
609611
inspect.isclass(m) and issubclass(m, BaseModel) for m in get_args(t)
610612
)
611613

612-
# If the list element is a Pydantic model (or union of models), this is a
613-
# structured "iterable extraction" response model, not a simple scalar list.
614-
if args and _is_model_type(args[0]):
615-
origin = None
614+
if inner is not None and _is_model_type(inner):
615+
# Treat as structured iterable extraction.
616+
origin = list
616617
else:
617618
from instructor.dsl.simple_type import ModelAdapter
618619

619-
response_model = ModelAdapter[response_model] # type: ignore[invalid-type-form]
620+
# Avoid `ModelAdapter[response_model]` so type checkers don't treat this
621+
# as a type expression. This is a runtime wrapper.
622+
response_model = ModelAdapter.__class_getitem__(response_model) # type: ignore[arg-type]
620623
origin = get_origin(response_model)
621624

625+
# Convert TypedDict -> BaseModel
622626
if is_typed_dict(response_model):
627+
model_name = getattr(response_model, "__name__", "TypedDictModel")
628+
annotations = getattr(response_model, "__annotations__", {})
623629
response_model = cast(
624630
type[BaseModel],
625631
create_model(
626-
response_model.__name__,
627-
**{k: (v, ...) for k, v in response_model.__annotations__.items()},
632+
model_name,
633+
**{k: (v, ...) for k, v in annotations.items()},
628634
),
629635
)
630636

631-
# Recompute after potential wrapping/conversion above.
637+
# Convert Iterable[T] or list[T] (where T is a model) -> IterableModel(T)
632638
origin = get_origin(response_model)
633639
if origin in {Iterable, list}:
634640
from instructor.dsl.iterable import IterableModel
@@ -643,10 +649,12 @@ def _is_model_type(t: Any) -> bool:
643649
iterable_element_class = cast(
644650
type[BaseModel],
645651
create_model(
646-
iterable_element_class.__name__,
652+
getattr(iterable_element_class, "__name__", "TypedDictModel"),
647653
**{
648654
k: (v, ...)
649-
for k, v in iterable_element_class.__annotations__.items()
655+
for k, v in getattr(
656+
iterable_element_class, "__annotations__", {}
657+
).items()
650658
},
651659
),
652660
)
@@ -655,7 +663,9 @@ def _is_model_type(t: Any) -> bool:
655663
if is_simple_type(response_model):
656664
from instructor.dsl.simple_type import ModelAdapter
657665

658-
response_model = ModelAdapter[response_model] # type: ignore[invalid-type-form]
666+
# Avoid `ModelAdapter[response_model]` so type checkers don't treat this as
667+
# a type expression. This is a runtime wrapper.
668+
response_model = ModelAdapter.__class_getitem__(response_model) # type: ignore[arg-type]
659669

660670
# Import here to avoid circular dependency
661671
from ..processing.function_calls import OpenAISchema, openai_schema
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncGenerator, Generator
4+
5+
import pytest
6+
from pydantic import BaseModel
7+
8+
from instructor.dsl.iterable import IterableBase
9+
from instructor.dsl.response_list import ListResponse
10+
from instructor.mode import Mode
11+
from instructor.processing.response import process_response, process_response_async
12+
from instructor.utils.core import prepare_response_model
13+
14+
15+
class DummyIterableModel(BaseModel, IterableBase):
16+
tasks: list[int]
17+
18+
@classmethod
19+
def from_response(cls, completion, **kwargs): # noqa: ANN001,ARG003
20+
return cls(tasks=[1, 2])
21+
22+
@classmethod
23+
def from_streaming_response( # noqa: ANN001
24+
cls, _completion, mode: Mode, **_kwargs
25+
) -> Generator[int, None, None]:
26+
del mode
27+
yield 1
28+
yield 2
29+
30+
@classmethod
31+
def from_streaming_response_async( # noqa: ANN001
32+
cls, _completion: AsyncGenerator[object, None], mode: Mode, **_kwargs
33+
) -> AsyncGenerator[int, None]:
34+
del mode
35+
36+
async def gen() -> AsyncGenerator[int, None]:
37+
yield 1
38+
yield 2
39+
40+
return gen()
41+
42+
43+
class DummyCompletion(BaseModel):
44+
"""Minimal stand-in for a provider completion object."""
45+
46+
47+
def test_process_response_returns_list_response_for_iterable_model():
48+
raw = DummyCompletion()
49+
50+
result = process_response(
51+
raw,
52+
response_model=DummyIterableModel,
53+
stream=False,
54+
mode=Mode.TOOLS,
55+
)
56+
57+
assert isinstance(result, ListResponse)
58+
assert list(result) == [1, 2]
59+
assert result._raw_response == raw
60+
61+
62+
def test_process_response_streaming_returns_list_response_for_iterable_model():
63+
raw = DummyCompletion()
64+
65+
result = process_response(
66+
raw,
67+
response_model=DummyIterableModel,
68+
stream=True,
69+
mode=Mode.TOOLS,
70+
)
71+
72+
# Streaming IterableBase should preserve generator behavior (used by create_iterable()).
73+
assert list(result) == [1, 2]
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_process_response_async_streaming_returns_list_response_for_iterable_model():
78+
async def completion_stream() -> AsyncGenerator[object, None]:
79+
yield object()
80+
81+
raw = completion_stream()
82+
83+
result = await process_response_async(
84+
raw, # type: ignore[arg-type]
85+
response_model=DummyIterableModel,
86+
stream=True,
87+
mode=Mode.TOOLS,
88+
)
89+
90+
# Streaming IterableBase should preserve async generator behavior (used by create_iterable()).
91+
collected: list[int] = []
92+
async for item in result:
93+
collected.append(item)
94+
assert collected == [1, 2]
95+
96+
97+
def test_prepare_response_model_treats_list_as_iterable_model():
98+
class User(BaseModel):
99+
name: str
100+
101+
prepared = prepare_response_model(list[User])
102+
assert prepared is not None
103+
assert issubclass(prepared, IterableBase)

0 commit comments

Comments
 (0)