diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6bbfea..6ef1011e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### New features +* `.stream()` and `.stream_async()` now support a `data_model` parameter for structured data extraction while streaming. (#262) ## [0.15.0] - 2026-01-06 diff --git a/chatlas/_chat.py b/chatlas/_chat.py index 1c69a3e4..a0ee8f98 100644 --- a/chatlas/_chat.py +++ b/chatlas/_chat.py @@ -1126,6 +1126,7 @@ def stream( *args: Content | str, content: Literal["text"] = "text", echo: EchoOptions = "none", + data_model: Optional[type[BaseModel]] = None, kwargs: Optional[SubmitInputArgsT] = None, ) -> Generator[str, None, None]: ... @@ -1135,6 +1136,7 @@ def stream( *args: Content | str, content: Literal["all"], echo: EchoOptions = "none", + data_model: Optional[type[BaseModel]] = None, kwargs: Optional[SubmitInputArgsT] = None, ) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]: ... @@ -1143,6 +1145,7 @@ def stream( *args: Content | str, content: Literal["text", "all"] = "text", echo: EchoOptions = "none", + data_model: Optional[type[BaseModel]] = None, kwargs: Optional[SubmitInputArgsT] = None, ) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]: """ @@ -1161,15 +1164,38 @@ def stream( - `"output"`: Echo text and tool call content. - `"all"`: Echo both the assistant and user turn. - `"none"`: Do not echo any content. + data_model + A Pydantic model describing the structure of the data to extract. + When provided, the response will be constrained to match this structure. + The streamed chunks will be JSON text that, when concatenated, forms + a valid JSON object matching the model. After consuming the stream, + use `data_model.model_validate_json("".join(chunks))` to parse the result. kwargs Additional keyword arguments to pass to the method used for requesting the response. Returns ------- - ChatResponse + Generator An (unconsumed) response from the chat. Iterate over this object to consume the response. + + Examples + -------- + ```python + from chatlas import ChatOpenAI + from pydantic import BaseModel + + + class Person(BaseModel): + name: str + age: int + + + chat = ChatOpenAI() + chunks = list(chat.stream("John is 25 years old", data_model=Person)) + person = Person.model_validate_json("".join(chunks)) + ``` """ turn = user_turn(*args, prior_turns=self.get_turns()) @@ -1181,6 +1207,7 @@ def stream( echo=echo, content=content, kwargs=kwargs, + data_model=data_model, ) def wrapper() -> Generator[ @@ -1198,6 +1225,7 @@ async def stream_async( *args: Content | str, content: Literal["text"] = "text", echo: EchoOptions = "none", + data_model: Optional[type[BaseModel]] = None, kwargs: Optional[SubmitInputArgsT] = None, ) -> AsyncGenerator[str, None]: ... @@ -1207,6 +1235,7 @@ async def stream_async( *args: Content | str, content: Literal["all"], echo: EchoOptions = "none", + data_model: Optional[type[BaseModel]] = None, kwargs: Optional[SubmitInputArgsT] = None, ) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]: ... @@ -1215,6 +1244,7 @@ async def stream_async( *args: Content | str, content: Literal["text", "all"] = "text", echo: EchoOptions = "none", + data_model: Optional[type[BaseModel]] = None, kwargs: Optional[SubmitInputArgsT] = None, ) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]: """ @@ -1233,15 +1263,40 @@ async def stream_async( - `"output"`: Echo text and tool call content. - `"all"`: Echo both the assistant and user turn. - `"none"`: Do not echo any content. + data_model + A Pydantic model describing the structure of the data to extract. + When provided, the response will be constrained to match this structure. + The streamed chunks will be JSON text that, when concatenated, forms + a valid JSON object matching the model. After consuming the stream, + use `data_model.model_validate_json("".join(chunks))` to parse the result. kwargs Additional keyword arguments to pass to the method used for requesting the response. Returns ------- - ChatResponseAsync + AsyncGenerator An (unconsumed) response from the chat. Iterate over this object to consume the response. + + Examples + -------- + ```python + from chatlas import ChatOpenAI + from pydantic import BaseModel + + + class Person(BaseModel): + name: str + age: int + + + chat = ChatOpenAI() + chunks = [chunk async for chunk in await chat.stream_async( + "John is 25 years old", data_model=Person + )] + person = Person.model_validate_json("".join(chunks)) + ``` """ turn = user_turn(*args, prior_turns=self.get_turns()) @@ -1257,6 +1312,7 @@ async def wrapper() -> AsyncGenerator[ echo=echo, content=content, kwargs=kwargs, + data_model=data_model, ): yield chunk @@ -2396,6 +2452,7 @@ def _chat_impl( content: Literal["text"], stream: bool, kwargs: Optional[SubmitInputArgsT] = None, + data_model: Optional[type[BaseModel]] = None, ) -> Generator[str, None, None]: ... @overload @@ -2406,6 +2463,7 @@ def _chat_impl( content: Literal["all"], stream: bool, kwargs: Optional[SubmitInputArgsT] = None, + data_model: Optional[type[BaseModel]] = None, ) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]: ... def _chat_impl( @@ -2415,6 +2473,7 @@ def _chat_impl( content: Literal["text", "all"], stream: bool, kwargs: Optional[SubmitInputArgsT] = None, + data_model: Optional[type[BaseModel]] = None, ) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]: user_turn_result: UserTurn | None = user_turn while user_turn_result is not None: @@ -2422,6 +2481,7 @@ def _chat_impl( user_turn_result, echo=echo, stream=stream, + data_model=data_model, kwargs=kwargs, ): yield chunk @@ -2459,6 +2519,7 @@ def _chat_impl_async( content: Literal["text"], stream: bool, kwargs: Optional[SubmitInputArgsT] = None, + data_model: Optional[type[BaseModel]] = None, ) -> AsyncGenerator[str, None]: ... @overload @@ -2469,6 +2530,7 @@ def _chat_impl_async( content: Literal["all"], stream: bool, kwargs: Optional[SubmitInputArgsT] = None, + data_model: Optional[type[BaseModel]] = None, ) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]: ... async def _chat_impl_async( @@ -2478,6 +2540,7 @@ async def _chat_impl_async( content: Literal["text", "all"], stream: bool, kwargs: Optional[SubmitInputArgsT] = None, + data_model: Optional[type[BaseModel]] = None, ) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]: user_turn_result: UserTurn | None = user_turn while user_turn_result is not None: @@ -2485,6 +2548,7 @@ async def _chat_impl_async( user_turn_result, echo=echo, stream=stream, + data_model=data_model, kwargs=kwargs, ): yield chunk diff --git a/tests/_vcr/test_chat/test_stream_async_with_data_model.yaml b/tests/_vcr/test_chat/test_stream_async_with_data_model.yaml new file mode 100644 index 00000000..ddc337fc --- /dev/null +++ b/tests/_vcr/test_chat/test_stream_async_with_data_model.yaml @@ -0,0 +1,146 @@ +interactions: +- request: + body: '{"input": [{"role": "user", "content": [{"type": "input_text", "text": + "John, age 15, won first prize"}]}], "model": "gpt-4.1", "store": false, "stream": + true, "text": {"format": {"type": "json_schema", "name": "structured_data", + "schema": {"properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + "required": ["name", "age"], "type": "object", "additionalProperties": false}, + "strict": true}}}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '373' + Content-Type: + - application/json + Host: + - api.openai.com + X-Stainless-Async: + - async:asyncio + x-stainless-read-timeout: + - '600' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_0f07d7b28142433a01695d29df22e88196b8369f690b02a071","object":"response","created_at":1767713247,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + + event: response.in_progress + + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_0f07d7b28142433a01695d29df22e88196b8369f690b02a071","object":"response","created_at":1767713247,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + + event: response.content_part.added + + data: {"type":"response.content_part.added","sequence_number":3,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"{\"","logprobs":[],"obfuscation":"GBMfTKXTGoa4A1"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"name","logprobs":[],"obfuscation":"rLUZVZrTWM6Z"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":6,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"\":\"","logprobs":[],"obfuscation":"bbwC7TwzilROd"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"John","logprobs":[],"obfuscation":"KLxWngetKu7y"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":8,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"\",\"","logprobs":[],"obfuscation":"bOsT2m95GfXmQ"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":9,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"age","logprobs":[],"obfuscation":"dFQvAZmfnCkBT"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":10,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"\":","logprobs":[],"obfuscation":"T8F95uvPiwB3lj"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":11,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"15","logprobs":[],"obfuscation":"6OGFpzssVl3sVX"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":12,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"}","logprobs":[],"obfuscation":"DJzTyaU3Jz9ZCjG"} + + + event: response.output_text.done + + data: {"type":"response.output_text.done","sequence_number":13,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"text":"{\"name\":\"John\",\"age\":15}","logprobs":[]} + + + event: response.content_part.done + + data: {"type":"response.content_part.done","sequence_number":14,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"name\":\"John\",\"age\":15}"}} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","sequence_number":15,"output_index":0,"item":{"id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"name\":\"John\",\"age\":15}"}],"role":"assistant"}} + + + event: response.completed + + data: {"type":"response.completed","sequence_number":16,"response":{"id":"resp_0f07d7b28142433a01695d29df22e88196b8369f690b02a071","object":"response","created_at":1767713247,"status":"completed","background":false,"completed_at":1767713247,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"name\":\"John\",\"age\":15}"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":46,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":56},"user":null,"metadata":{}}} + + + ' + headers: + CF-RAY: + - 9b9c3d4f9f641103-ORD + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 06 Jan 2026 15:27:27 GMT + Server: + - cloudflare + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-processing-ms: + - '50' + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '53' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/_vcr/test_chat/test_stream_with_data_model.yaml b/tests/_vcr/test_chat/test_stream_with_data_model.yaml new file mode 100644 index 00000000..b4638f30 --- /dev/null +++ b/tests/_vcr/test_chat/test_stream_with_data_model.yaml @@ -0,0 +1,146 @@ +interactions: +- request: + body: '{"input": [{"role": "user", "content": [{"type": "input_text", "text": + "John, age 15, won first prize"}]}], "model": "gpt-4.1", "store": false, "stream": + true, "text": {"format": {"type": "json_schema", "name": "structured_data", + "schema": {"properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + "required": ["name", "age"], "type": "object", "additionalProperties": false}, + "strict": true}}}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '373' + Content-Type: + - application/json + Host: + - api.openai.com + X-Stainless-Async: + - 'false' + x-stainless-read-timeout: + - '600' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_030733b111d9c11a01695d29dd72a88190932fb92266c92a2f","object":"response","created_at":1767713245,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + + event: response.in_progress + + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_030733b111d9c11a01695d29dd72a88190932fb92266c92a2f","object":"response","created_at":1767713245,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + + event: response.content_part.added + + data: {"type":"response.content_part.added","sequence_number":3,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"{\"","logprobs":[],"obfuscation":"atMxi7bPIt8tLl"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"name","logprobs":[],"obfuscation":"ma34eWxslhWL"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":6,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"\":\"","logprobs":[],"obfuscation":"Bv3PcrPLJZd0m"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"John","logprobs":[],"obfuscation":"4s8KguDQaMzu"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":8,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"\",\"","logprobs":[],"obfuscation":"n9VjLKPzZLFtI"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":9,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"age","logprobs":[],"obfuscation":"bmKtVf8r3lNDF"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":10,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"\":","logprobs":[],"obfuscation":"P1EOqGNkXkgtq3"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":11,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"15","logprobs":[],"obfuscation":"wRFP40JnQo2tDB"} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","sequence_number":12,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"delta":"}","logprobs":[],"obfuscation":"dsalUXWwC7GEbaY"} + + + event: response.output_text.done + + data: {"type":"response.output_text.done","sequence_number":13,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"text":"{\"name\":\"John\",\"age\":15}","logprobs":[]} + + + event: response.content_part.done + + data: {"type":"response.content_part.done","sequence_number":14,"item_id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"name\":\"John\",\"age\":15}"}} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","sequence_number":15,"output_index":0,"item":{"id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"name\":\"John\",\"age\":15}"}],"role":"assistant"}} + + + event: response.completed + + data: {"type":"response.completed","sequence_number":16,"response":{"id":"resp_030733b111d9c11a01695d29dd72a88190932fb92266c92a2f","object":"response","created_at":1767713245,"status":"completed","background":false,"completed_at":1767713246,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"msg_030733b111d9c11a01695d29de23688190a52b0b91b99c29d3","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"name\":\"John\",\"age\":15}"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":46,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":56},"user":null,"metadata":{}}} + + + ' + headers: + CF-RAY: + - 9b9c3d448fb119b0-ORD + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 06 Jan 2026 15:27:25 GMT + Server: + - cloudflare + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-processing-ms: + - '43' + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '45' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_chat.py b/tests/test_chat.py index 7aec7a72..8d05b62f 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -142,6 +142,58 @@ class Person(BaseModel): assert data == Person(name="John", age=15) +@pytest.mark.vcr +def test_stream_with_data_model(): + from chatlas._content import ContentJson + + chat = ChatOpenAI() + + class Person(BaseModel): + name: str + age: int + + chunks = list(chat.stream("John, age 15, won first prize", data_model=Person)) + result = "".join(chunks) + person = Person.model_validate_json(result) + assert person == Person(name="John", age=15) + + # Verify the last turn contains ContentJson with the structured data + turn = chat.get_last_turn() + assert turn is not None + assert len(turn.contents) == 1 + assert isinstance(turn.contents[0], ContentJson) + assert turn.contents[0].value == {"name": "John", "age": 15} + + +@pytest.mark.vcr +@pytest.mark.asyncio +async def test_stream_async_with_data_model(): + from chatlas._content import ContentJson + + chat = ChatOpenAI() + + class Person(BaseModel): + name: str + age: int + + chunks = [ + chunk + async for chunk in await chat.stream_async( + "John, age 15, won first prize", data_model=Person + ) + ] + result = "".join(chunks) + person = Person.model_validate_json(result) + assert person == Person(name="John", age=15) + + # Verify the last turn contains ContentJson with the structured data + turn = chat.get_last_turn() + assert turn is not None + assert len(turn.contents) == 1 + assert isinstance(turn.contents[0], ContentJson) + assert turn.contents[0].value == {"name": "John", "age": 15} + + @pytest.mark.vcr def test_last_turn_retrieval(): chat = ChatOpenAI()