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
26 changes: 22 additions & 4 deletions examples/structured_output_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from pydantic import BaseModel

from droid_sdk import run
from droid_sdk import RunSuccess, SessionConfig, run


class Finding(BaseModel):
Expand All @@ -22,11 +22,29 @@ class Review(BaseModel):

async def main() -> None:
result = await run(
"Return a short repository summary and zero or more findings.",
(
'Return summary exactly "Structured output works." and one finding '
'with severity "low" and message exactly "Example complete."'
),
output=Review,
timeout=180,
timeout=60,
config=SessionConfig(
disable_builtin_skills=True,
restrict_tools=(),
),
)
assert isinstance(result, RunSuccess), (
result.error.message if result.error else result.subtype
)
assert result.output == Review(
summary="Structured output works.",
findings=[
Finding(
severity="low",
message="Example complete.",
)
],
)
assert result.output is not None, result.output_validation_error
print(result.output.summary)


Expand Down
12 changes: 9 additions & 3 deletions src/droid_sdk/_high_level/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ def adapt(self, raw: object | None) -> OutputAdaptation[T_co]:
return OutputAdaptation(None, None, None)

if self._model is not None:
structured_output = _raw_object(raw)
validation_input = (
thaw_json(structured_output)
if structured_output is not None
else raw
)
try:
value = self._model.model_validate(raw)
value = self._model.model_validate(validation_input)
except ValidationError as exc:
return OutputAdaptation(None, _raw_object(raw), exc)
return OutputAdaptation(cast("T_co", value), _raw_object(raw), None)
return OutputAdaptation(None, structured_output, exc)
return OutputAdaptation(cast("T_co", value), structured_output, None)

raw_value = _validate_json_object(raw)
return OutputAdaptation(
Expand Down
60 changes: 59 additions & 1 deletion tests/test_v5_streaming_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import Any, cast

import pytest
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict

from droid_sdk import (
AssistantMessage,
Expand Down Expand Up @@ -780,6 +780,20 @@ class NumericOutput(BaseModel):
value: float


class StrictFinding(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")

severity: str
message: str


class StrictReview(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")

summary: str
findings: list[StrictFinding]


@pytest.mark.asyncio
async def test_structured_output_raw_adapted_fallback_and_validation_error() -> None:
valid = RunStream[Review](
Expand Down Expand Up @@ -845,6 +859,50 @@ async def test_structured_output_raw_adapted_fallback_and_validation_error() ->
assert raw.result.output == {"summary": "fallback"}


@pytest.mark.asyncio
async def test_strict_nested_output_validates_frozen_stream_data() -> None:
stream = RunStream[StrictReview](
expected_turn_id="turn",
session_id="session",
output_adapter=prepare_output_adapter(StrictReview),
)
raw = {
"summary": "ok",
"findings": [
{
"severity": "high",
"message": "Validate ordinary JSON containers",
}
],
}
stream.feed_notification(
{
"type": "structured_output",
"messageId": "assistant",
"structuredOutput": raw,
}
)
stream.feed_notification(_complete())

await _collect(stream)

assert isinstance(stream.result, RunSuccess)
assert stream.result.output == StrictReview.model_validate(raw)
structured_output = stream.result.structured_output
assert structured_output is not None
assert structured_output == {
"summary": "ok",
"findings": (
{
"severity": "high",
"message": "Validate ordinary JSON containers",
},
),
}
assert isinstance(structured_output["findings"], tuple)
assert stream.result.output_validation_error is None


@pytest.mark.asyncio
async def test_requested_output_that_never_arrives_is_a_failure() -> None:
missing = RunStream[Mapping[str, object]](
Expand Down