Skip to content

Commit 2ef78c0

Browse files
cpsievertclaude
andcommitted
feat: add data_model support to stream() and stream_async() methods
Enable structured data extraction while streaming responses. The data_model parameter constrains the response format to match a Pydantic model. Users can parse the accumulated chunks with model.model_validate_json("".join(chunks)). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6180d14 commit 2ef78c0

File tree

5 files changed

+412
-2
lines changed

5 files changed

+412
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [UNRELEASED]
1111

12+
### New features
1213

14+
* `.stream()` and `.stream_async()` now support a `data_model` parameter for structured data extraction while streaming. (#262)
1315

1416
## [0.15.0] - 2026-01-06
1517

chatlas/_chat.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,7 @@ def stream(
11261126
*args: Content | str,
11271127
content: Literal["text"] = "text",
11281128
echo: EchoOptions = "none",
1129+
data_model: Optional[type[BaseModel]] = None,
11291130
kwargs: Optional[SubmitInputArgsT] = None,
11301131
) -> Generator[str, None, None]: ...
11311132

@@ -1135,6 +1136,7 @@ def stream(
11351136
*args: Content | str,
11361137
content: Literal["all"],
11371138
echo: EchoOptions = "none",
1139+
data_model: Optional[type[BaseModel]] = None,
11381140
kwargs: Optional[SubmitInputArgsT] = None,
11391141
) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]: ...
11401142

@@ -1143,6 +1145,7 @@ def stream(
11431145
*args: Content | str,
11441146
content: Literal["text", "all"] = "text",
11451147
echo: EchoOptions = "none",
1148+
data_model: Optional[type[BaseModel]] = None,
11461149
kwargs: Optional[SubmitInputArgsT] = None,
11471150
) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]:
11481151
"""
@@ -1161,15 +1164,38 @@ def stream(
11611164
- `"output"`: Echo text and tool call content.
11621165
- `"all"`: Echo both the assistant and user turn.
11631166
- `"none"`: Do not echo any content.
1167+
data_model
1168+
A Pydantic model describing the structure of the data to extract.
1169+
When provided, the response will be constrained to match this structure.
1170+
The streamed chunks will be JSON text that, when concatenated, forms
1171+
a valid JSON object matching the model. After consuming the stream,
1172+
use `data_model.model_validate_json("".join(chunks))` to parse the result.
11641173
kwargs
11651174
Additional keyword arguments to pass to the method used for requesting
11661175
the response.
11671176
11681177
Returns
11691178
-------
1170-
ChatResponse
1179+
Generator
11711180
An (unconsumed) response from the chat. Iterate over this object to
11721181
consume the response.
1182+
1183+
Examples
1184+
--------
1185+
```python
1186+
from chatlas import ChatOpenAI
1187+
from pydantic import BaseModel
1188+
1189+
1190+
class Person(BaseModel):
1191+
name: str
1192+
age: int
1193+
1194+
1195+
chat = ChatOpenAI()
1196+
chunks = list(chat.stream("John is 25 years old", data_model=Person))
1197+
person = Person.model_validate_json("".join(chunks))
1198+
```
11731199
"""
11741200
turn = user_turn(*args, prior_turns=self.get_turns())
11751201

@@ -1181,6 +1207,7 @@ def stream(
11811207
echo=echo,
11821208
content=content,
11831209
kwargs=kwargs,
1210+
data_model=data_model,
11841211
)
11851212

11861213
def wrapper() -> Generator[
@@ -1198,6 +1225,7 @@ async def stream_async(
11981225
*args: Content | str,
11991226
content: Literal["text"] = "text",
12001227
echo: EchoOptions = "none",
1228+
data_model: Optional[type[BaseModel]] = None,
12011229
kwargs: Optional[SubmitInputArgsT] = None,
12021230
) -> AsyncGenerator[str, None]: ...
12031231

@@ -1207,6 +1235,7 @@ async def stream_async(
12071235
*args: Content | str,
12081236
content: Literal["all"],
12091237
echo: EchoOptions = "none",
1238+
data_model: Optional[type[BaseModel]] = None,
12101239
kwargs: Optional[SubmitInputArgsT] = None,
12111240
) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]: ...
12121241

@@ -1215,6 +1244,7 @@ async def stream_async(
12151244
*args: Content | str,
12161245
content: Literal["text", "all"] = "text",
12171246
echo: EchoOptions = "none",
1247+
data_model: Optional[type[BaseModel]] = None,
12181248
kwargs: Optional[SubmitInputArgsT] = None,
12191249
) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]:
12201250
"""
@@ -1233,15 +1263,40 @@ async def stream_async(
12331263
- `"output"`: Echo text and tool call content.
12341264
- `"all"`: Echo both the assistant and user turn.
12351265
- `"none"`: Do not echo any content.
1266+
data_model
1267+
A Pydantic model describing the structure of the data to extract.
1268+
When provided, the response will be constrained to match this structure.
1269+
The streamed chunks will be JSON text that, when concatenated, forms
1270+
a valid JSON object matching the model. After consuming the stream,
1271+
use `data_model.model_validate_json("".join(chunks))` to parse the result.
12361272
kwargs
12371273
Additional keyword arguments to pass to the method used for requesting
12381274
the response.
12391275
12401276
Returns
12411277
-------
1242-
ChatResponseAsync
1278+
AsyncGenerator
12431279
An (unconsumed) response from the chat. Iterate over this object to
12441280
consume the response.
1281+
1282+
Examples
1283+
--------
1284+
```python
1285+
from chatlas import ChatOpenAI
1286+
from pydantic import BaseModel
1287+
1288+
1289+
class Person(BaseModel):
1290+
name: str
1291+
age: int
1292+
1293+
1294+
chat = ChatOpenAI()
1295+
chunks = [chunk async for chunk in await chat.stream_async(
1296+
"John is 25 years old", data_model=Person
1297+
)]
1298+
person = Person.model_validate_json("".join(chunks))
1299+
```
12451300
"""
12461301
turn = user_turn(*args, prior_turns=self.get_turns())
12471302

@@ -1257,6 +1312,7 @@ async def wrapper() -> AsyncGenerator[
12571312
echo=echo,
12581313
content=content,
12591314
kwargs=kwargs,
1315+
data_model=data_model,
12601316
):
12611317
yield chunk
12621318

@@ -2396,6 +2452,7 @@ def _chat_impl(
23962452
content: Literal["text"],
23972453
stream: bool,
23982454
kwargs: Optional[SubmitInputArgsT] = None,
2455+
data_model: Optional[type[BaseModel]] = None,
23992456
) -> Generator[str, None, None]: ...
24002457

24012458
@overload
@@ -2406,6 +2463,7 @@ def _chat_impl(
24062463
content: Literal["all"],
24072464
stream: bool,
24082465
kwargs: Optional[SubmitInputArgsT] = None,
2466+
data_model: Optional[type[BaseModel]] = None,
24092467
) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]: ...
24102468

24112469
def _chat_impl(
@@ -2415,13 +2473,15 @@ def _chat_impl(
24152473
content: Literal["text", "all"],
24162474
stream: bool,
24172475
kwargs: Optional[SubmitInputArgsT] = None,
2476+
data_model: Optional[type[BaseModel]] = None,
24182477
) -> Generator[str | ContentToolRequest | ContentToolResult, None, None]:
24192478
user_turn_result: UserTurn | None = user_turn
24202479
while user_turn_result is not None:
24212480
for chunk in self._submit_turns(
24222481
user_turn_result,
24232482
echo=echo,
24242483
stream=stream,
2484+
data_model=data_model,
24252485
kwargs=kwargs,
24262486
):
24272487
yield chunk
@@ -2459,6 +2519,7 @@ def _chat_impl_async(
24592519
content: Literal["text"],
24602520
stream: bool,
24612521
kwargs: Optional[SubmitInputArgsT] = None,
2522+
data_model: Optional[type[BaseModel]] = None,
24622523
) -> AsyncGenerator[str, None]: ...
24632524

24642525
@overload
@@ -2469,6 +2530,7 @@ def _chat_impl_async(
24692530
content: Literal["all"],
24702531
stream: bool,
24712532
kwargs: Optional[SubmitInputArgsT] = None,
2533+
data_model: Optional[type[BaseModel]] = None,
24722534
) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]: ...
24732535

24742536
async def _chat_impl_async(
@@ -2478,13 +2540,15 @@ async def _chat_impl_async(
24782540
content: Literal["text", "all"],
24792541
stream: bool,
24802542
kwargs: Optional[SubmitInputArgsT] = None,
2543+
data_model: Optional[type[BaseModel]] = None,
24812544
) -> AsyncGenerator[str | ContentToolRequest | ContentToolResult, None]:
24822545
user_turn_result: UserTurn | None = user_turn
24832546
while user_turn_result is not None:
24842547
async for chunk in self._submit_turns_async(
24852548
user_turn_result,
24862549
echo=echo,
24872550
stream=stream,
2551+
data_model=data_model,
24882552
kwargs=kwargs,
24892553
):
24902554
yield chunk
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
interactions:
2+
- request:
3+
body: '{"input": [{"role": "user", "content": [{"type": "input_text", "text":
4+
"John, age 15, won first prize"}]}], "model": "gpt-4.1", "store": false, "stream":
5+
true, "text": {"format": {"type": "json_schema", "name": "structured_data",
6+
"schema": {"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
7+
"required": ["name", "age"], "type": "object", "additionalProperties": false},
8+
"strict": true}}}'
9+
headers:
10+
Accept:
11+
- application/json
12+
Accept-Encoding:
13+
- gzip, deflate
14+
Connection:
15+
- keep-alive
16+
Content-Length:
17+
- '373'
18+
Content-Type:
19+
- application/json
20+
Host:
21+
- api.openai.com
22+
X-Stainless-Async:
23+
- async:asyncio
24+
x-stainless-read-timeout:
25+
- '600'
26+
method: POST
27+
uri: https://api.openai.com/v1/responses
28+
response:
29+
body:
30+
string: 'event: response.created
31+
32+
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":{}}}
33+
34+
35+
event: response.in_progress
36+
37+
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":{}}}
38+
39+
40+
event: response.output_item.added
41+
42+
data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","type":"message","status":"in_progress","content":[],"role":"assistant"}}
43+
44+
45+
event: response.content_part.added
46+
47+
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":""}}
48+
49+
50+
event: response.output_text.delta
51+
52+
data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"{\"","logprobs":[],"obfuscation":"GBMfTKXTGoa4A1"}
53+
54+
55+
event: response.output_text.delta
56+
57+
data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"name","logprobs":[],"obfuscation":"rLUZVZrTWM6Z"}
58+
59+
60+
event: response.output_text.delta
61+
62+
data: {"type":"response.output_text.delta","sequence_number":6,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"\":\"","logprobs":[],"obfuscation":"bbwC7TwzilROd"}
63+
64+
65+
event: response.output_text.delta
66+
67+
data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"John","logprobs":[],"obfuscation":"KLxWngetKu7y"}
68+
69+
70+
event: response.output_text.delta
71+
72+
data: {"type":"response.output_text.delta","sequence_number":8,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"\",\"","logprobs":[],"obfuscation":"bOsT2m95GfXmQ"}
73+
74+
75+
event: response.output_text.delta
76+
77+
data: {"type":"response.output_text.delta","sequence_number":9,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"age","logprobs":[],"obfuscation":"dFQvAZmfnCkBT"}
78+
79+
80+
event: response.output_text.delta
81+
82+
data: {"type":"response.output_text.delta","sequence_number":10,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"\":","logprobs":[],"obfuscation":"T8F95uvPiwB3lj"}
83+
84+
85+
event: response.output_text.delta
86+
87+
data: {"type":"response.output_text.delta","sequence_number":11,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"15","logprobs":[],"obfuscation":"6OGFpzssVl3sVX"}
88+
89+
90+
event: response.output_text.delta
91+
92+
data: {"type":"response.output_text.delta","sequence_number":12,"item_id":"msg_0f07d7b28142433a01695d29df98fc81968781402b6544c770","output_index":0,"content_index":0,"delta":"}","logprobs":[],"obfuscation":"DJzTyaU3Jz9ZCjG"}
93+
94+
95+
event: response.output_text.done
96+
97+
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":[]}
98+
99+
100+
event: response.content_part.done
101+
102+
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}"}}
103+
104+
105+
event: response.output_item.done
106+
107+
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"}}
108+
109+
110+
event: response.completed
111+
112+
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":{}}}
113+
114+
115+
'
116+
headers:
117+
CF-RAY:
118+
- 9b9c3d4f9f641103-ORD
119+
Connection:
120+
- keep-alive
121+
Content-Type:
122+
- text/event-stream; charset=utf-8
123+
Date:
124+
- Tue, 06 Jan 2026 15:27:27 GMT
125+
Server:
126+
- cloudflare
127+
Strict-Transport-Security:
128+
- max-age=31536000; includeSubDomains; preload
129+
Transfer-Encoding:
130+
- chunked
131+
X-Content-Type-Options:
132+
- nosniff
133+
alt-svc:
134+
- h3=":443"; ma=86400
135+
cf-cache-status:
136+
- DYNAMIC
137+
openai-processing-ms:
138+
- '50'
139+
openai-version:
140+
- '2020-10-01'
141+
x-envoy-upstream-service-time:
142+
- '53'
143+
status:
144+
code: 200
145+
message: OK
146+
version: 1

0 commit comments

Comments
 (0)