Skip to content

Commit d5ff4a1

Browse files
fswairDouweM
andauthored
Add StructuredDict for structured outputs with custom JSON schema (#2157)
Co-authored-by: Douwe Maan <[email protected]>
1 parent 3ef42ed commit d5ff4a1

File tree

9 files changed

+204
-14
lines changed

9 files changed

+204
-14
lines changed

docs/api/output.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
- NativeOutput
1010
- PromptedOutput
1111
- TextOutput
12+
- StructuredDict

docs/output.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ _(This example is complete, it can be run "as is")_
3131

3232
## Output data {#structured-output}
3333

34-
The [`Agent`][pydantic_ai.Agent] class constructor takes an `output_type` argument that takes one or more types or [output functions](#output-functions). It supports simple scalar types, list and dict types, dataclasses and Pydantic models, as well as type unions -- generally everything supported as type hints in a Pydantic model. You can also pass a list of multiple choices.
34+
The [`Agent`][pydantic_ai.Agent] class constructor takes an `output_type` argument that takes one or more types or [output functions](#output-functions). It supports simple scalar types, list and dict types (including `TypedDict`s and [`StructuredDict`s](#structured-dict)), dataclasses and Pydantic models, as well as type unions -- generally everything supported as type hints in a Pydantic model. You can also pass a list of multiple choices.
3535

3636
By default, Pydantic AI leverages the model's tool calling capability to make it return structured data. When multiple output types are specified (in a union or list), each member is registered with the model as a separate output tool in order to reduce the complexity of the schema and maximise the chances a model will respond correctly. This has been shown to work well across a wide range of models. If you'd like to change the names of the output tools, use a model's native structured output feature, or pass the output schema to the model in its [instructions](agents.md#instructions), you can use an [output mode](#output-modes) marker class.
3737

@@ -117,7 +117,6 @@ print(result.output)
117117

118118
_(This example is complete, it can be run "as is")_
119119

120-
121120
### Output functions
122121

123122
Instead of plain text or structured data, you may want the output of your agent run to be the result of a function called with arguments provided by the model, for example to further process or validate the data provided through the arguments (with the option to tell the model to try again), or to hand off to another agent.
@@ -387,6 +386,37 @@ print(repr(result.output))
387386

388387
_(This example is complete, it can be run "as is")_
389388

389+
### Custom JSON schema {#structured-dict}
390+
391+
If it's not feasible to define your desired structured output object using a Pydantic `BaseModel`, dataclass, or `TypedDict`, for example when you get a JSON schema from an external source or generate it dynamically, you can use the [`StructuredDict()`][pydantic_ai.output.StructuredDict] helper function to generate a `dict[str, Any]` subclass with a JSON schema attached that Pydantic AI will pass to the model.
392+
393+
Note that Pydantic AI will not perform any validation of the received JSON object and it's up to the model to correctly interpret the schema and any constraints expressed in it, like required fields or integer value ranges.
394+
395+
The output type will be a `dict[str, Any]` and it's up to your code to defensively read from it in case the model made a mistake. You can use an [output validator](#output-validator-functions) to reflect validation errors back to the model and get it to try again.
396+
397+
Along with the JSON schema, you can optionally pass `name` and `description` arguments to provide additional context to the model:
398+
399+
```python
400+
from pydantic_ai import Agent, StructuredDict
401+
402+
HumanDict = StructuredDict(
403+
{
404+
"type": "object",
405+
"properties": {
406+
"name": {"type": "string"},
407+
"age": {"type": "integer"}
408+
},
409+
"required": ["name", "age"]
410+
},
411+
name="Human",
412+
description="A human with a name and age",
413+
)
414+
415+
agent = Agent('openai:gpt-4o', output_type=HumanDict)
416+
result = agent.run_sync("Create a person")
417+
#> {'name': 'John Doe', 'age': 30}
418+
```
419+
390420
### Output validators {#output-validator-functions}
391421

392422
Some validation is inconvenient or impossible to do in Pydantic validators, in particular when the validation requires IO and is asynchronous. PydanticAI provides a way to add validation functions via the [`agent.output_validator`][pydantic_ai.Agent.output_validator] decorator.

pydantic_ai_slim/pydantic_ai/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
from .format_prompt import format_as_xml
1414
from .messages import AudioUrl, BinaryContent, DocumentUrl, ImageUrl, VideoUrl
15-
from .output import NativeOutput, PromptedOutput, TextOutput, ToolOutput
15+
from .output import NativeOutput, PromptedOutput, StructuredDict, TextOutput, ToolOutput
1616
from .tools import RunContext, Tool
1717

1818
__all__ = (
@@ -46,6 +46,7 @@
4646
'NativeOutput',
4747
'PromptedOutput',
4848
'TextOutput',
49+
'StructuredDict',
4950
# format_prompt
5051
'format_as_xml',
5152
)

pydantic_ai_slim/pydantic_ai/_output.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,22 +264,23 @@ def _build_tools(
264264

265265
output = output.output
266266

267+
description = description or default_description
268+
if strict is None:
269+
strict = default_strict
270+
271+
processor = ObjectOutputProcessor(output=output, description=description, strict=strict)
272+
267273
if name is None:
268274
name = default_name
269275
if multiple:
270-
name += f'_{output.__name__}'
276+
name += f'_{processor.object_def.name}'
271277

272278
i = 1
273279
original_name = name
274280
while name in tools:
275281
i += 1
276282
name = f'{original_name}_{i}'
277283

278-
description = description or default_description
279-
if strict is None:
280-
strict = default_strict
281-
282-
processor = ObjectOutputProcessor(output=output, description=description, strict=strict)
283284
tools[name] = OutputTool(name=name, processor=processor, multiple=multiple)
284285

285286
return tools
@@ -616,6 +617,9 @@ def __init__(
616617
# including `response_data_typed_dict` as a title here doesn't add anything and could confuse the LLM
617618
json_schema.pop('title')
618619

620+
if name is None and (json_schema_title := json_schema.get('title', None)):
621+
name = json_schema_title
622+
619623
if json_schema_description := json_schema.pop('description', None):
620624
if description is None:
621625
description = json_schema_description

pydantic_ai_slim/pydantic_ai/_utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ def is_model_like(type_: Any) -> bool:
6060
return (
6161
isinstance(type_, type)
6262
and not isinstance(type_, GenericAlias)
63-
and (issubclass(type_, BaseModel) or is_dataclass(type_) or is_typeddict(type_)) # pyright: ignore[reportUnknownArgumentType]
63+
and (
64+
issubclass(type_, BaseModel)
65+
or is_dataclass(type_) # pyright: ignore[reportUnknownArgumentType]
66+
or is_typeddict(type_) # pyright: ignore[reportUnknownArgumentType]
67+
or getattr(type_, '__is_model_like__', False) # pyright: ignore[reportUnknownArgumentType]
68+
)
6469
)
6570

6671

pydantic_ai_slim/pydantic_ai/output.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22

33
from collections.abc import Awaitable, Sequence
44
from dataclasses import dataclass
5-
from typing import Callable, Generic, Literal, Union
5+
from typing import Any, Callable, Generic, Literal, Union
66

7+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
8+
from pydantic.json_schema import JsonSchemaValue
9+
from pydantic_core import core_schema
710
from typing_extensions import TypeAliasType, TypeVar
811

12+
from . import _utils
913
from .tools import RunContext
1014

1115
__all__ = (
@@ -14,6 +18,7 @@
1418
'NativeOutput',
1519
'PromptedOutput',
1620
'TextOutput',
21+
'StructuredDict',
1722
# types
1823
'OutputDataT',
1924
'OutputMode',
@@ -266,6 +271,65 @@ def split_into_words(text: str) -> list[str]:
266271
"""The function that will be called to process the model's plain text output. The function must take a single string argument."""
267272

268273

274+
def StructuredDict(
275+
json_schema: JsonSchemaValue, name: str | None = None, description: str | None = None
276+
) -> type[JsonSchemaValue]:
277+
"""Returns a `dict[str, Any]` subclass with a JSON schema attached that will be used for structured output.
278+
279+
Args:
280+
json_schema: A JSON schema of type `object` defining the structure of the dictionary content.
281+
name: Optional name of the structured output. If not provided, the `title` field of the JSON schema will be used if it's present.
282+
description: Optional description of the structured output. If not provided, the `description` field of the JSON schema will be used if it's present.
283+
284+
Example:
285+
```python {title="structured_dict.py"}
286+
from pydantic_ai import Agent, StructuredDict
287+
288+
289+
schema = {
290+
"type": "object",
291+
"properties": {
292+
"name": {"type": "string"},
293+
"age": {"type": "integer"}
294+
},
295+
"required": ["name", "age"]
296+
}
297+
298+
agent = Agent('openai:gpt-4o', output_type=StructuredDict(schema))
299+
result = agent.run_sync("Create a person")
300+
print(result.output)
301+
#> {'name': 'John Doe', 'age': 30}
302+
```
303+
"""
304+
json_schema = _utils.check_object_json_schema(json_schema)
305+
306+
if name:
307+
json_schema['title'] = name
308+
309+
if description:
310+
json_schema['description'] = description
311+
312+
class _StructuredDict(JsonSchemaValue):
313+
__is_model_like__ = True
314+
315+
@classmethod
316+
def __get_pydantic_core_schema__(
317+
cls, source_type: Any, handler: GetCoreSchemaHandler
318+
) -> core_schema.CoreSchema:
319+
return core_schema.dict_schema(
320+
keys_schema=core_schema.str_schema(),
321+
values_schema=core_schema.any_schema(),
322+
)
323+
324+
@classmethod
325+
def __get_pydantic_json_schema__(
326+
cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
327+
) -> JsonSchemaValue:
328+
return json_schema
329+
330+
return _StructuredDict
331+
332+
269333
OutputSpec = TypeAliasType(
270334
'OutputSpec',
271335
Union[

tests/test_agent.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
)
4242
from pydantic_ai.models.function import AgentInfo, FunctionModel
4343
from pydantic_ai.models.test import TestModel
44-
from pydantic_ai.output import ToolOutput
44+
from pydantic_ai.output import StructuredDict, ToolOutput
4545
from pydantic_ai.profiles import ModelProfile
4646
from pydantic_ai.result import Usage
4747
from pydantic_ai.tools import ToolDefinition
@@ -1266,6 +1266,77 @@ def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
12661266
)
12671267

12681268

1269+
def test_output_type_structured_dict():
1270+
PersonDict = StructuredDict(
1271+
{
1272+
'type': 'object',
1273+
'properties': {
1274+
'name': {'type': 'string'},
1275+
'age': {'type': 'integer'},
1276+
},
1277+
'required': ['name', 'age'],
1278+
},
1279+
name='Person',
1280+
description='A person',
1281+
)
1282+
AnimalDict = StructuredDict(
1283+
{
1284+
'type': 'object',
1285+
'properties': {
1286+
'name': {'type': 'string'},
1287+
'species': {'type': 'string'},
1288+
},
1289+
'required': ['name', 'species'],
1290+
},
1291+
name='Animal',
1292+
description='An animal',
1293+
)
1294+
1295+
output_tools = None
1296+
1297+
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
1298+
assert info.output_tools is not None
1299+
1300+
nonlocal output_tools
1301+
output_tools = info.output_tools
1302+
1303+
args_json = '{"name": "John Doe", "age": 30}'
1304+
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
1305+
1306+
agent = Agent(
1307+
FunctionModel(call_tool),
1308+
output_type=[PersonDict, AnimalDict],
1309+
)
1310+
1311+
result = agent.run_sync('Generate a person')
1312+
1313+
assert result.output == snapshot({'name': 'John Doe', 'age': 30})
1314+
assert output_tools == snapshot(
1315+
[
1316+
ToolDefinition(
1317+
name='final_result_Person',
1318+
parameters_json_schema={
1319+
'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}},
1320+
'required': ['name', 'age'],
1321+
'title': 'Person',
1322+
'type': 'object',
1323+
},
1324+
description='A person',
1325+
),
1326+
ToolDefinition(
1327+
name='final_result_Animal',
1328+
parameters_json_schema={
1329+
'properties': {'name': {'type': 'string'}, 'species': {'type': 'string'}},
1330+
'required': ['name', 'species'],
1331+
'title': 'Animal',
1332+
'type': 'object',
1333+
},
1334+
description='An animal',
1335+
),
1336+
]
1337+
)
1338+
1339+
12691340
def test_default_structured_output_mode():
12701341
def hello(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
12711342
return ModelResponse(parts=[TextPart(content='hello')]) # pragma: no cover

tests/test_examples.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,10 @@ async def list_tools() -> list[None]:
444444
'What is a Ford Explorer?': '{"result": {"kind": "Vehicle", "data": {"name": "Ford Explorer", "wheels": 4}}}',
445445
'What is a MacBook?': '{"result": {"kind": "Device", "data": {"name": "MacBook", "kind": "laptop"}}}',
446446
'Write a creative story about space exploration': 'In the year 2157, Captain Maya Chen piloted her spacecraft through the vast expanse of the Andromeda Galaxy. As she discovered a planet with crystalline mountains that sang in harmony with the cosmic winds, she realized that space exploration was not just about finding new worlds, but about finding new ways to understand the universe and our place within it.',
447+
'Create a person': ToolCallPart(
448+
tool_name='final_result',
449+
args={'name': 'John Doe', 'age': 30},
450+
),
447451
}
448452

449453
tool_responses: dict[tuple[str, str], str] = {

tests/typed_agent.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
from collections.abc import Awaitable
55
from dataclasses import dataclass
66
from decimal import Decimal
7-
from typing import Callable, TypeAlias, Union
7+
from typing import Any, Callable, TypeAlias, Union
88

99
from typing_extensions import assert_type
1010

1111
from pydantic_ai import Agent, ModelRetry, RunContext, Tool
1212
from pydantic_ai.agent import AgentRunResult
13-
from pydantic_ai.output import TextOutput, ToolOutput
13+
from pydantic_ai.output import StructuredDict, TextOutput, ToolOutput
1414
from pydantic_ai.tools import ToolDefinition
1515

1616
# Define here so we can check `if MYPY` below. This will not be executed, MYPY will always set it to True
@@ -170,6 +170,16 @@ def run_sync3() -> None:
170170
union_agent2: Agent[None, MyUnion] = Agent(output_type=MyUnion) # type: ignore[call-overload]
171171
assert_type(union_agent2, Agent[None, MyUnion])
172172

173+
structured_dict = StructuredDict(
174+
{
175+
'type': 'object',
176+
'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}},
177+
'required': ['name', 'age'],
178+
}
179+
)
180+
structured_dict_agent = Agent(output_type=structured_dict)
181+
assert_type(structured_dict_agent, Agent[None, dict[str, Any]])
182+
173183

174184
def foobar_ctx(ctx: RunContext[int], x: str, y: int) -> Decimal:
175185
return Decimal(x) + y

0 commit comments

Comments
 (0)