| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent b095ceb commit 1da9ba0
8 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | 7 | import ai | |
| 8 | 8 | ||
| 9 | - model = ai.ai_gateway("anthropic/claude-sonnet-4") | ||
| 9 | + model = ai.ai_gateway("anthropic/claude-sonnet-4.6") | ||
| 10 | 10 | ||
| 11 | 11 | ||
| 12 | 12 | class Recipe(pydantic.BaseModel): | |
@@ -20,9 +20,21 @@ class Recipe(pydantic.BaseModel): | |||
| 20 | 20 | ||
| 21 | 21 | ||
| 22 | 22 | 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") | ||
| 26 | 38 | ||
| 27 | 39 | ||
| 28 | 40 | if __name__ == "__main__": | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -84,6 +84,7 @@ def __init__( | |||
| 84 | 84 | gen: AsyncGenerator[types.events.Event], | |
| 85 | 85 | *, | |
| 86 | 86 | seed_message: types.messages.Message | None = None, | |
| 87 | + output_type: type[pydantic.BaseModel] | None = None, | ||
| 87 | 88 | ) -> None: | |
| 88 | 89 | """Wrap an event generator. | |
| 89 | 90 | ||
@@ -93,12 +94,18 @@ def __init__( | |||
| 93 | 94 | being rebuilt from synthetic events. When ``None`` (default), | |
| 94 | 95 | an empty assistant message is created and rebuilt from the | |
| 95 | 96 | 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. | ||
| 96 | 102 | """ | |
| 97 | 103 | self._gen = gen | |
| 98 | 104 | self._message: types.messages.Message = seed_message or types.messages.Message( | |
| 99 | 105 | role="assistant", parts=[] | |
| 100 | 106 | ) | |
| 101 | 107 | self._parts: dict[str, types.messages.Part] = {} | |
| 108 | + self._output_type = output_type | ||
| 102 | 109 | ||
| 103 | 110 | async def aclose(self) -> None: | |
| 104 | 111 | await self._gen.aclose() | |
@@ -146,7 +153,15 @@ def tool_calls(self) -> list[types.messages.ToolCallPart]: | |||
| 146 | 153 | ||
| 147 | 154 | @property | |
| 148 | 155 | 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) | ||
| 150 | 165 | ||
| 151 | 166 | def _aggregate_event(self, event: types.events.Event) -> dict[str, Any]: | |
| 152 | 167 | updates: dict[str, Any] = {} | |
@@ -393,7 +408,11 @@ async def _stream( | |||
| 393 | 408 | ) -> AsyncIterator[Stream]: | |
| 394 | 409 | if messages and messages[-1].replay: | |
| 395 | 410 | 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 | + ) | ||
| 397 | 416 | else: | |
| 398 | 417 | prepared = integrity.prepare_messages(messages) | |
| 399 | 418 | request = StreamRequest( | |
@@ -403,7 +422,7 @@ async def _stream( | |||
| 403 | 422 | output_type, | |
| 404 | 423 | params, | |
| 405 | 424 | ) | |
| 406 | - s = Stream(executor._do_stream(request)) | ||
| 425 | + s = Stream(executor._do_stream(request), output_type=output_type) | ||
| 407 | 426 | try: | |
| 408 | 427 | yield s | |
| 409 | 428 | finally: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -18,7 +18,6 @@ | |||
| 18 | 18 | Message, | |
| 19 | 19 | Part, | |
| 20 | 20 | ReasoningPart, | |
| 21 | - StructuredOutputPart, | ||
| 22 | 21 | TextPart, | |
| 23 | 22 | ToolCallPart, | |
| 24 | 23 | ToolResultPart, | |
@@ -30,7 +29,6 @@ | |||
| 30 | 29 | ToolResultPart, | |
| 31 | 30 | ReasoningPart, | |
| 32 | 31 | HookPart, | |
| 33 | - StructuredOutputPart, | ||
| 34 | 32 | FilePart, | |
| 35 | 33 | ) | |
| 36 | 34 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -206,8 +206,8 @@ async def replay_message_events( | |||
| 206 | 206 | not recoverable from a complete message. | |
| 207 | 207 | ||
| 208 | 208 | 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. | ||
| 211 | 211 | """ | |
| 212 | 212 | yield StreamStart() | |
| 213 | 213 | for part in msg.parts: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,4 +1,3 @@ | |||
| 1 | - import importlib | ||
| 2 | 1 | import uuid | |
| 3 | 2 | from typing import Annotated, Any, Literal, Self | |
| 4 | 3 | ||
@@ -110,56 +109,6 @@ class HookPart[T](pydantic.BaseModel): | |||
| 110 | 109 | model_config = pydantic.ConfigDict(frozen=True) | |
| 111 | 110 | ||
| 112 | 111 | ||
| 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 | - | ||
| 163 | 112 | class FilePart(pydantic.BaseModel): | |
| 164 | 113 | """File, image, or audio content part. | |
| 165 | 114 | ||
@@ -229,7 +178,6 @@ def from_bytes( | |||
| 229 | 178 | | BuiltinToolReturnPart | |
| 230 | 179 | | ReasoningPart | |
| 231 | 180 | | HookPart[Any] | |
| 232 | - | StructuredOutputPart | ||
| 233 | 181 | | FilePart, | |
| 234 | 182 | pydantic.Field(discriminator="kind"), | |
| 235 | 183 | ] | |
@@ -288,11 +236,3 @@ def images(self) -> list[FilePart]: | |||
| 288 | 236 | @property | |
| 289 | 237 | def videos(self) -> list[FilePart]: | |
| 290 | 238 | 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 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -139,8 +139,6 @@ async def emit_events_for_messages( | |||
| 139 | 139 | media_type=part.media_type, | |
| 140 | 140 | data=part.data if isinstance(part.data, str) else "", | |
| 141 | 141 | ) | |
| 142 | - # StructuredOutputPart is not a streamed part; tests that need it | ||
| 143 | - # construct a tailored adapter directly. | ||
| 144 | 142 | yield events_.StreamEnd(usage=usage) | |
| 145 | 143 | ||
| 146 | 144 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -120,7 +120,7 @@ def test_internal_strict_raises() -> None: | |||
| 120 | 120 | ||
| 121 | 121 | ||
| 122 | 122 | # --------------------------------------------------------------------------- | |
| 123 | - # Internal parts (HookPart, StructuredOutputPart) | ||
| 123 | + # Internal parts (HookPart) | ||
| 124 | 124 | # --------------------------------------------------------------------------- | |
| 125 | 125 | ||
| 126 | 126 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,70 +2,11 @@ | |||
| 2 | 2 | ||
| 3 | 3 | from __future__ import annotations | |
| 4 | 4 | ||
| 5 | - import pydantic | ||
| 6 | 5 | import pytest | |
| 7 | 6 | ||
| 8 | 7 | from ai.types import messages, usage | |
| 9 | 8 | ||
| 10 | 9 | ||
| 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 | - | ||
| 69 | 10 | def test_usage_add_merges_optional_fields() -> None: | |
| 70 | 11 | a = usage.Usage( | |
| 71 | 12 | input_tokens=100, | |
| Back | FazBrowse Home | New Git URL |
0 commit comments