Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 66 additions & 2 deletions chatlas/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...

Expand All @@ -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]: ...

Expand All @@ -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]:
"""
Expand All @@ -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())

Expand All @@ -1181,6 +1207,7 @@ def stream(
echo=echo,
content=content,
kwargs=kwargs,
data_model=data_model,
)

def wrapper() -> Generator[
Expand All @@ -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]: ...

Expand All @@ -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]: ...

Expand All @@ -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]:
"""
Expand All @@ -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())

Expand All @@ -1257,6 +1312,7 @@ async def wrapper() -> AsyncGenerator[
echo=echo,
content=content,
kwargs=kwargs,
data_model=data_model,
):
yield chunk

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -2415,13 +2473,15 @@ 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:
for chunk in self._submit_turns(
user_turn_result,
echo=echo,
stream=stream,
data_model=data_model,
kwargs=kwargs,
):
yield chunk
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -2478,13 +2540,15 @@ 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:
async for chunk in self._submit_turns_async(
user_turn_result,
echo=echo,
stream=stream,
data_model=data_model,
kwargs=kwargs,
):
yield chunk
Expand Down
146 changes: 146 additions & 0 deletions tests/_vcr/test_chat/test_stream_async_with_data_model.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading