FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Support output_type on stream · vercel-labs/ai-python@1da9ba0 · GitHub

Repository navigation

Commit 1da9ba0

Browse files
committed
Support output_type on stream
For now, it is dropped from `message` and just added to `Stream`, and we drop StructuredMessagePart. We could add something like that back if we really wanted, but I'm not sure it would be worth it. The parsing would still probably be located in Stream, since that is where Message construction happens now.
1 parent b095ceb commit 1da9ba0

8 files changed

Lines changed: 41 additions & 133 deletions

File tree

‎examples/samples/structured_output.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import ai
88

9-
model = ai.ai_gateway("anthropic/claude-sonnet-4")
9+
model = ai.ai_gateway("anthropic/claude-sonnet-4.6")
1010

1111

1212
class Recipe(pydantic.BaseModel):
@@ -20,9 +20,21 @@ class Recipe(pydantic.BaseModel):
2020

2121

2222
async def main() -> None:
23-
# Broken for now: stream(output_type=...) requests JSON/schema mode, but
24-
# the stream wrapper does not yet validate final text into s.output.
25-
raise RuntimeError("structured output aggregation needs to be implemented")
23+
# Stream with structured output — watch JSON arrive, get validated at the end.
24+
async with ai.stream(model, messages, output_type=Recipe) as s:
25+
async for event in s:
26+
if isinstance(event, ai.events.TextDelta):
27+
print(event.chunk, end="", flush=True)
28+
elif isinstance(event, ai.events.StreamEnd):
29+
print()
30+
31+
# After iteration, s.output validates the streamed JSON against
32+
# the output_type the stream was opened with.
33+
recipe = s.output
34+
assert isinstance(recipe, Recipe)
35+
print(f"\n\nParsed recipe: {recipe.name}")
36+
print(f" Ingredients: {', '.join(recipe.ingredients)}")
37+
print(f" Prep time: {recipe.prep_time_minutes} min")
2638

2739

2840
if __name__ == "__main__":

‎src/ai/models/core/api.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def __init__(
8484
gen: AsyncGenerator[types.events.Event],
8585
*,
8686
seed_message: types.messages.Message | None = None,
87+
output_type: type[pydantic.BaseModel] | None = None,
8788
) -> None:
8889
"""Wrap an event generator.
8990
@@ -93,12 +94,18 @@ def __init__(
9394
being rebuilt from synthetic events. When ``None`` (default),
9495
an empty assistant message is created and rebuilt from the
9596
incoming events.
97+
98+
``output_type`` is the Pydantic model the request was constrained
99+
to. When set, ``Stream.output`` validates the streamed JSON text
100+
against it. When ``None`` (default), ``Stream.output`` returns
101+
the concatenated text content unchanged.
96102
"""
97103
self._gen = gen
98104
self._message: types.messages.Message = seed_message or types.messages.Message(
99105
role="assistant", parts=[]
100106
)
101107
self._parts: dict[str, types.messages.Part] = {}
108+
self._output_type = output_type
102109

103110
async def aclose(self) -> None:
104111
await self._gen.aclose()
@@ -146,7 +153,15 @@ def tool_calls(self) -> list[types.messages.ToolCallPart]:
146153

147154
@property
148155
def output(self) -> Any:
149-
return self._message.output
156+
"""Return the streamed output as the ``output_type`` passed in.
157+
158+
Defaults to the concatenated message text. When a Pydantic
159+
model subclass was passed, validates the streamed JSON against
160+
it and returns the parsed instance.
161+
"""
162+
if self._output_type is None:
163+
return self._message.text
164+
return self._output_type.model_validate_json(self._message.text)
150165

151166
def _aggregate_event(self, event: types.events.Event) -> dict[str, Any]:
152167
updates: dict[str, Any] = {}
@@ -393,7 +408,11 @@ async def _stream(
393408
) -> AsyncIterator[Stream]:
394409
if messages and messages[-1].replay:
395410
last = messages[-1]
396-
s = Stream(_replay_tool_calls(last), seed_message=last.model_copy(deep=True))
411+
s = Stream(
412+
_replay_tool_calls(last),
413+
seed_message=last.model_copy(deep=True),
414+
output_type=output_type,
415+
)
397416
else:
398417
prepared = integrity.prepare_messages(messages)
399418
request = StreamRequest(
@@ -403,7 +422,7 @@ async def _stream(
403422
output_type,
404423
params,
405424
)
406-
s = Stream(executor._do_stream(request))
425+
s = Stream(executor._do_stream(request), output_type=output_type)
407426
try:
408427
yield s
409428
finally:

‎src/ai/types/builders.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
Message,
1919
Part,
2020
ReasoningPart,
21-
StructuredOutputPart,
2221
TextPart,
2322
ToolCallPart,
2423
ToolResultPart,
@@ -30,7 +29,6 @@
3029
ToolResultPart,
3130
ReasoningPart,
3231
HookPart,
33-
StructuredOutputPart,
3432
FilePart,
3533
)
3634

‎src/ai/types/events.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,8 @@ async def replay_message_events(
206206
not recoverable from a complete message.
207207
208208
Parts with no model-layer event analog — ``ToolResultPart``,
209-
``HookPart``, ``StructuredOutputPart`` — are skipped silently; they
210-
are agent-layer concerns and never appear on the model stream.
209+
``HookPart`` — are skipped silently; they are agent-layer concerns
210+
and never appear on the model stream.
211211
"""
212212
yield StreamStart()
213213
for part in msg.parts:

‎src/ai/types/messages.py‎

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import importlib
21
import uuid
32
from typing import Annotated, Any, Literal, Self
43

@@ -110,56 +109,6 @@ class HookPart[T](pydantic.BaseModel):
110109
model_config = pydantic.ConfigDict(frozen=True)
111110

112111

113-
# todo redo this structured output situation and simplify it
114-
def _resolve_class(fully_qualified_name: str) -> type[pydantic.BaseModel]:
115-
"""Import and return a class from its fully qualified name.
116-
117-
E.g. ``"myapp.models.WeatherForecast"`` → the ``WeatherForecast`` class.
118-
"""
119-
module_path, _, class_name = fully_qualified_name.rpartition(".")
120-
if not module_path:
121-
raise ImportError(
122-
f"Cannot resolve '{fully_qualified_name}': "
123-
"expected a fully qualified name like 'mypackage.module.ClassName'"
124-
)
125-
module = importlib.import_module(module_path)
126-
cls = getattr(module, class_name, None)
127-
if cls is None:
128-
raise ImportError(f"Module '{module_path}' has no attribute '{class_name}'")
129-
if not (isinstance(cls, type) and issubclass(cls, pydantic.BaseModel)):
130-
raise TypeError(
131-
f"'{fully_qualified_name}' is not a pydantic.BaseModel subclass"
132-
)
133-
return cls
134-
135-
136-
class StructuredOutputPart(pydantic.BaseModel):
137-
"""Part containing a validated structured output from the LLM.
138-
139-
``data`` stores the parsed JSON dict (always serializable).
140-
``output_type_name`` stores the fully qualified class name so the typed
141-
Pydantic model can be lazily rehydrated via the ``value`` property.
142-
"""
143-
144-
model_config = pydantic.ConfigDict(frozen=True)
145-
146-
id: str = pydantic.Field(default_factory=generate_id)
147-
data: dict[str, Any]
148-
output_type_name: str
149-
kind: Literal["structured_output"] = "structured_output"
150-
provider_metadata: dict[str, Any] | None = None
151-
152-
_hydrated: Any = pydantic.PrivateAttr(default=None)
153-
154-
@property
155-
def value(self) -> Any:
156-
"""Lazily resolve the output type class and validate ``data`` into it."""
157-
if self._hydrated is None:
158-
cls = _resolve_class(self.output_type_name)
159-
self._hydrated = cls.model_validate(self.data)
160-
return self._hydrated
161-
162-
163112
class FilePart(pydantic.BaseModel):
164113
"""File, image, or audio content part.
165114
@@ -229,7 +178,6 @@ def from_bytes(
229178
| BuiltinToolReturnPart
230179
| ReasoningPart
231180
| HookPart[Any]
232-
| StructuredOutputPart
233181
| FilePart,
234182
pydantic.Field(discriminator="kind"),
235183
]
@@ -288,11 +236,3 @@ def images(self) -> list[FilePart]:
288236
@property
289237
def videos(self) -> list[FilePart]:
290238
return [p for p in self.files if p.media_type.startswith("video/")]
291-
292-
@property
293-
def output(self) -> Any:
294-
"""Parsed structured output from the first structured-output part."""
295-
for part in self.parts:
296-
if isinstance(part, StructuredOutputPart):
297-
return part.value
298-
return None

‎tests/conftest.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,6 @@ async def emit_events_for_messages(
139139
media_type=part.media_type,
140140
data=part.data if isinstance(part.data, str) else "",
141141
)
142-
# StructuredOutputPart is not a streamed part; tests that need it
143-
# construct a tailored adapter directly.
144142
yield events_.StreamEnd(usage=usage)
145143

146144

‎tests/types/test_integrity.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def test_internal_strict_raises() -> None:
120120

121121

122122
# ---------------------------------------------------------------------------
123-
# Internal parts (HookPart, StructuredOutputPart)
123+
# Internal parts (HookPart)
124124
# ---------------------------------------------------------------------------
125125

126126

‎tests/types/test_messages.py‎

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,70 +2,11 @@
22

33
from __future__ import annotations
44

5-
import pydantic
65
import pytest
76

87
from ai.types import messages, usage
98

109

11-
class _Weather(pydantic.BaseModel):
12-
city: str
13-
temperature: float
14-
15-
16-
_WEATHER_DATA = {"city": "SF", "temperature": 62.0}
17-
_WEATHER_TYPE_NAME = f"{_Weather.__module__}.{_Weather.__qualname__}"
18-
19-
20-
def test_structured_output_part_value() -> None:
21-
part = messages.StructuredOutputPart(
22-
data=_WEATHER_DATA, output_type_name=_WEATHER_TYPE_NAME
23-
)
24-
val = part.value
25-
assert isinstance(val, _Weather)
26-
assert val.city == "SF"
27-
assert part.value is val
28-
29-
30-
def test_structured_output_part_bad_class_name() -> None:
31-
part = messages.StructuredOutputPart(
32-
data=_WEATHER_DATA, output_type_name="nonexistent.module.Cls"
33-
)
34-
with pytest.raises(ImportError):
35-
_ = part.value
36-
37-
38-
def test_message_output_from_part() -> None:
39-
m = messages.Message(
40-
id="m1",
41-
role="assistant",
42-
parts=[
43-
messages.TextPart(text="{}"),
44-
messages.StructuredOutputPart(
45-
data=_WEATHER_DATA, output_type_name=_WEATHER_TYPE_NAME
46-
),
47-
],
48-
)
49-
assert isinstance(m.output, _Weather)
50-
assert m.output.city == "SF"
51-
52-
53-
def test_structured_output_round_trip() -> None:
54-
m = messages.Message(
55-
id="m1",
56-
role="assistant",
57-
parts=[
58-
messages.TextPart(text="{}"),
59-
messages.StructuredOutputPart(
60-
data=_WEATHER_DATA, output_type_name=_WEATHER_TYPE_NAME
61-
),
62-
],
63-
)
64-
restored = messages.Message.model_validate(m.model_dump())
65-
assert isinstance(restored.output, _Weather)
66-
assert restored.output.city == "SF"
67-
68-
6910
def test_usage_add_merges_optional_fields() -> None:
7011
a = usage.Usage(
7112
input_tokens=100,

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL