Skip to content

Commit 4cc4f35

Browse files
authored
FallbackModel support for Native and Prompted output modes and ModelProfile.default_structured_output_mode (#3303)
1 parent cf7ce9f commit 4cc4f35

35 files changed

+763
-517
lines changed

docs/api/models/function.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ async def model_function(
3636
print(info)
3737
"""
3838
AgentInfo(
39-
function_tools=[], allow_text_output=True, output_tools=[], model_settings=None
39+
function_tools=[],
40+
allow_text_output=True,
41+
output_tools=[],
42+
model_settings=None,
43+
model_request_parameters=ModelRequestParameters(
44+
function_tools=[], builtin_tools=[], output_tools=[]
45+
),
46+
instructions=None,
4047
)
4148
"""
4249
return ModelResponse(parts=[TextPart('hello world')])

docs/builtin-tools.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ making it ideal for queries that require up-to-date data.
3131
|----------|-----------|-------|
3232
| OpenAI Responses || Full feature support. To include search results on the [`BuiltinToolReturnPart`][pydantic_ai.messages.BuiltinToolReturnPart] that's available via [`ModelResponse.builtin_tool_calls`][pydantic_ai.messages.ModelResponse.builtin_tool_calls], enable the [`OpenAIResponsesModelSettings.openai_include_web_search_sources`][pydantic_ai.models.openai.OpenAIResponsesModelSettings.openai_include_web_search_sources] [model setting](agents.md#model-run-settings). |
3333
| Anthropic || Full feature support |
34-
| Google || No parameter support. No [`BuiltinToolCallPart`][pydantic_ai.messages.BuiltinToolCallPart] or [`BuiltinToolReturnPart`][pydantic_ai.messages.BuiltinToolReturnPart] is generated when streaming. Using built-in tools and user tools (including [output tools](output.md#tool-output)) at the same time is not supported; to use structured output, use [`PromptedOutput`](output.md#prompted-output) instead. |
34+
| Google || No parameter support. No [`BuiltinToolCallPart`][pydantic_ai.messages.BuiltinToolCallPart] or [`BuiltinToolReturnPart`][pydantic_ai.messages.BuiltinToolReturnPart] is generated when streaming. Using built-in tools and function tools (including [output tools](output.md#tool-output)) at the same time is not supported; to use structured output, use [`PromptedOutput`](output.md#prompted-output) instead. |
3535
| Groq || Limited parameter support. To use web search capabilities with Groq, you need to use the [compound models](https://console.groq.com/docs/compound). |
3636
| OpenAI Chat Completions || Not supported |
3737
| Bedrock || Not supported |
@@ -123,7 +123,7 @@ in a secure environment, making it perfect for computational tasks, data analysi
123123
| Provider | Supported | Notes |
124124
|----------|-----------|-------|
125125
| OpenAI || To include code execution output on the [`BuiltinToolReturnPart`][pydantic_ai.messages.BuiltinToolReturnPart] that's available via [`ModelResponse.builtin_tool_calls`][pydantic_ai.messages.ModelResponse.builtin_tool_calls], enable the [`OpenAIResponsesModelSettings.openai_include_code_execution_outputs`][pydantic_ai.models.openai.OpenAIResponsesModelSettings.openai_include_code_execution_outputs] [model setting](agents.md#model-run-settings). If the code execution generated images, like charts, they will be available on [`ModelResponse.images`][pydantic_ai.messages.ModelResponse.images] as [`BinaryImage`][pydantic_ai.messages.BinaryImage] objects. The generated image can also be used as [image output](output.md#image-output) for the agent run. |
126-
| Google || Using built-in tools and user tools (including [output tools](output.md#tool-output)) at the same time is not supported; to use structured output, use [`PromptedOutput`](output.md#prompted-output) instead. |
126+
| Google || Using built-in tools and function tools (including [output tools](output.md#tool-output)) at the same time is not supported; to use structured output, use [`PromptedOutput`](output.md#prompted-output) instead. |
127127
| Anthropic || |
128128
| Groq || |
129129
| Bedrock || |
@@ -315,7 +315,7 @@ allowing it to pull up-to-date information from the web.
315315

316316
| Provider | Supported | Notes |
317317
|----------|-----------|-------|
318-
| Google || No [`BuiltinToolCallPart`][pydantic_ai.messages.BuiltinToolCallPart] or [`BuiltinToolReturnPart`][pydantic_ai.messages.BuiltinToolReturnPart] is currently generated; please submit an issue if you need this. Using built-in tools and user tools (including [output tools](output.md#tool-output)) at the same time is not supported; to use structured output, use [`PromptedOutput`](output.md#prompted-output) instead. |
318+
| Google || No [`BuiltinToolCallPart`][pydantic_ai.messages.BuiltinToolCallPart] or [`BuiltinToolReturnPart`][pydantic_ai.messages.BuiltinToolReturnPart] is currently generated; please submit an issue if you need this. Using built-in tools and function tools (including [output tools](output.md#tool-output)) at the same time is not supported; to use structured output, use [`PromptedOutput`](output.md#prompted-output) instead. |
319319
| OpenAI || |
320320
| Anthropic || |
321321
| Groq || |

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -374,9 +374,10 @@ async def _prepare_request_parameters(
374374
) -> models.ModelRequestParameters:
375375
"""Build tools and create an agent model."""
376376
output_schema = ctx.deps.output_schema
377-
output_object = None
378-
if isinstance(output_schema, _output.NativeOutputSchema):
379-
output_object = output_schema.object_def
377+
378+
prompted_output_template = (
379+
output_schema.template if isinstance(output_schema, _output.PromptedOutputSchema) else None
380+
)
380381

381382
function_tools: list[ToolDefinition] = []
382383
output_tools: list[ToolDefinition] = []
@@ -391,7 +392,8 @@ async def _prepare_request_parameters(
391392
builtin_tools=ctx.deps.builtin_tools,
392393
output_mode=output_schema.mode,
393394
output_tools=output_tools,
394-
output_object=output_object,
395+
output_object=output_schema.object_def,
396+
prompted_output_template=prompted_output_template,
395397
allow_text_output=output_schema.allows_text,
396398
allow_image_output=output_schema.allows_image,
397399
)
@@ -489,7 +491,6 @@ async def _prepare_request(
489491
message_history = _clean_message_history(message_history)
490492

491493
model_request_parameters = await _prepare_request_parameters(ctx)
492-
model_request_parameters = ctx.deps.model.customize_request_parameters(model_request_parameters)
493494

494495
model_settings = ctx.deps.model_settings
495496
usage = ctx.state.usage
@@ -570,7 +571,7 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa
570571
# we got an empty response.
571572
# this sometimes happens with anthropic (and perhaps other models)
572573
# when the model has already returned text along side tool calls
573-
if text_processor := output_schema.text_processor:
574+
if text_processor := output_schema.text_processor: # pragma: no branch
574575
# in this scenario, if text responses are allowed, we return text from the most recent model
575576
# response, if any
576577
for message in reversed(ctx.state.message_history):
@@ -584,8 +585,12 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa
584585
# not part of the final result output, so we reset the accumulated text
585586
text = '' # pragma: no cover
586587
if text:
587-
self._next_node = await self._handle_text_response(ctx, text, text_processor)
588-
return
588+
try:
589+
self._next_node = await self._handle_text_response(ctx, text, text_processor)
590+
return
591+
except ToolRetryError:
592+
# If the text from the preview response was invalid, ignore it.
593+
pass
589594

590595
# Go back to the model request node with an empty request, which means we'll essentially
591596
# resubmit the most recent request that resulted in an empty response,
@@ -622,11 +627,11 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa
622627
else:
623628
assert_never(part)
624629

625-
# At the moment, we prioritize at least executing tool calls if they are present.
626-
# In the future, we'd consider making this configurable at the agent or run level.
627-
# This accounts for cases like anthropic returns that might contain a text response
628-
# and a tool call response, where the text response just indicates the tool call will happen.
629630
try:
631+
# At the moment, we prioritize at least executing tool calls if they are present.
632+
# In the future, we'd consider making this configurable at the agent or run level.
633+
# This accounts for cases like anthropic returns that might contain a text response
634+
# and a tool call response, where the text response just indicates the tool call will happen.
630635
alternatives: list[str] = []
631636
if tool_calls:
632637
async for event in self._handle_tool_calls(ctx, tool_calls):

pydantic_ai_slim/pydantic_ai/_output.py

Lines changed: 20 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from pydantic import Json, TypeAdapter, ValidationError
1212
from pydantic_core import SchemaValidator, to_json
13-
from typing_extensions import Self, TypedDict, TypeVar, assert_never
13+
from typing_extensions import Self, TypedDict, TypeVar
1414

1515
from pydantic_ai._instrumentation import InstrumentationNames
1616

@@ -26,7 +26,6 @@
2626
OutputSpec,
2727
OutputTypeOrFunction,
2828
PromptedOutput,
29-
StructuredOutputMode,
3029
TextOutput,
3130
TextOutputFunc,
3231
ToolOutput,
@@ -36,7 +35,7 @@
3635
from .toolsets.abstract import AbstractToolset, ToolsetTool
3736

3837
if TYPE_CHECKING:
39-
from .profiles import ModelProfile
38+
pass
4039

4140
T = TypeVar('T')
4241
"""An invariant TypeVar."""
@@ -212,59 +211,30 @@ async def validate(
212211

213212

214213
@dataclass(kw_only=True)
215-
class BaseOutputSchema(ABC, Generic[OutputDataT]):
214+
class OutputSchema(ABC, Generic[OutputDataT]):
216215
text_processor: BaseOutputProcessor[OutputDataT] | None = None
217216
toolset: OutputToolset[Any] | None = None
217+
object_def: OutputObjectDefinition | None = None
218218
allows_deferred_tools: bool = False
219219
allows_image: bool = False
220220

221-
@abstractmethod
222-
def with_default_mode(self, mode: StructuredOutputMode) -> OutputSchema[OutputDataT]:
221+
@property
222+
def mode(self) -> OutputMode:
223223
raise NotImplementedError()
224224

225225
@property
226226
def allows_text(self) -> bool:
227227
return self.text_processor is not None
228228

229-
230-
@dataclass(init=False)
231-
class OutputSchema(BaseOutputSchema[OutputDataT], ABC):
232-
"""Model the final output from an agent run."""
233-
234-
@classmethod
235-
@overload
236-
def build(
237-
cls,
238-
output_spec: OutputSpec[OutputDataT],
239-
*,
240-
default_mode: StructuredOutputMode,
241-
name: str | None = None,
242-
description: str | None = None,
243-
strict: bool | None = None,
244-
) -> OutputSchema[OutputDataT]: ...
245-
246-
@classmethod
247-
@overload
248-
def build(
249-
cls,
250-
output_spec: OutputSpec[OutputDataT],
251-
*,
252-
default_mode: None = None,
253-
name: str | None = None,
254-
description: str | None = None,
255-
strict: bool | None = None,
256-
) -> BaseOutputSchema[OutputDataT]: ...
257-
258229
@classmethod
259230
def build( # noqa: C901
260231
cls,
261232
output_spec: OutputSpec[OutputDataT],
262233
*,
263-
default_mode: StructuredOutputMode | None = None,
264234
name: str | None = None,
265235
description: str | None = None,
266236
strict: bool | None = None,
267-
) -> BaseOutputSchema[OutputDataT]:
237+
) -> OutputSchema[OutputDataT]:
268238
"""Build an OutputSchema dataclass from an output type."""
269239
outputs = _flatten_output_spec(output_spec)
270240

@@ -382,15 +352,12 @@ def build( # noqa: C901
382352
)
383353

384354
if len(other_outputs) > 0:
385-
schema = OutputSchemaWithoutMode(
355+
return AutoOutputSchema(
386356
processor=cls._build_processor(other_outputs, name=name, description=description, strict=strict),
387357
toolset=toolset,
388358
allows_deferred_tools=allows_deferred_tools,
389359
allows_image=allows_image,
390360
)
391-
if default_mode:
392-
schema = schema.with_default_mode(default_mode)
393-
return schema
394361

395362
if allows_image:
396363
return ImageOutputSchema(allows_deferred_tools=allows_deferred_tools)
@@ -410,22 +377,9 @@ def _build_processor(
410377

411378
return UnionOutputProcessor(outputs=outputs, strict=strict, name=name, description=description)
412379

413-
@property
414-
@abstractmethod
415-
def mode(self) -> OutputMode:
416-
raise NotImplementedError()
417-
418-
def raise_if_unsupported(self, profile: ModelProfile) -> None:
419-
"""Raise an error if the mode is not supported by this model."""
420-
if self.allows_image and not profile.supports_image_output:
421-
raise UserError('Image output is not supported by this model.')
422-
423-
def with_default_mode(self, mode: StructuredOutputMode) -> OutputSchema[OutputDataT]:
424-
return self
425-
426380

427381
@dataclass(init=False)
428-
class OutputSchemaWithoutMode(BaseOutputSchema[OutputDataT]):
382+
class AutoOutputSchema(OutputSchema[OutputDataT]):
429383
processor: BaseObjectOutputProcessor[OutputDataT]
430384

431385
def __init__(
@@ -439,32 +393,17 @@ def __init__(
439393
# At that point we may not know yet what output mode we're going to use if no model was provided or it was deferred until agent.run time,
440394
# but we cover ourselves just in case we end up using the tool output mode.
441395
super().__init__(
442-
allows_deferred_tools=allows_deferred_tools,
443396
toolset=toolset,
397+
object_def=processor.object_def,
444398
text_processor=processor,
399+
allows_deferred_tools=allows_deferred_tools,
445400
allows_image=allows_image,
446401
)
447402
self.processor = processor
448403

449-
def with_default_mode(self, mode: StructuredOutputMode) -> OutputSchema[OutputDataT]:
450-
if mode == 'native':
451-
return NativeOutputSchema(
452-
processor=self.processor,
453-
allows_deferred_tools=self.allows_deferred_tools,
454-
allows_image=self.allows_image,
455-
)
456-
elif mode == 'prompted':
457-
return PromptedOutputSchema(
458-
processor=self.processor,
459-
allows_deferred_tools=self.allows_deferred_tools,
460-
allows_image=self.allows_image,
461-
)
462-
elif mode == 'tool':
463-
return ToolOutputSchema(
464-
toolset=self.toolset, allows_deferred_tools=self.allows_deferred_tools, allows_image=self.allows_image
465-
)
466-
else:
467-
assert_never(mode)
404+
@property
405+
def mode(self) -> OutputMode:
406+
return 'auto'
468407

469408

470409
@dataclass(init=False)
@@ -486,10 +425,6 @@ def __init__(
486425
def mode(self) -> OutputMode:
487426
return 'text'
488427

489-
def raise_if_unsupported(self, profile: ModelProfile) -> None:
490-
"""Raise an error if the mode is not supported by this model."""
491-
super().raise_if_unsupported(profile)
492-
493428

494429
class ImageOutputSchema(OutputSchema[OutputDataT]):
495430
def __init__(self, *, allows_deferred_tools: bool):
@@ -499,11 +434,6 @@ def __init__(self, *, allows_deferred_tools: bool):
499434
def mode(self) -> OutputMode:
500435
return 'image'
501436

502-
def raise_if_unsupported(self, profile: ModelProfile) -> None:
503-
"""Raise an error if the mode is not supported by this model."""
504-
# This already raises if image output is not supported by this model.
505-
super().raise_if_unsupported(profile)
506-
507437

508438
@dataclass(init=False)
509439
class StructuredTextOutputSchema(OutputSchema[OutputDataT], ABC):
@@ -513,25 +443,19 @@ def __init__(
513443
self, *, processor: BaseObjectOutputProcessor[OutputDataT], allows_deferred_tools: bool, allows_image: bool
514444
):
515445
super().__init__(
516-
text_processor=processor, allows_deferred_tools=allows_deferred_tools, allows_image=allows_image
446+
text_processor=processor,
447+
object_def=processor.object_def,
448+
allows_deferred_tools=allows_deferred_tools,
449+
allows_image=allows_image,
517450
)
518451
self.processor = processor
519452

520-
@property
521-
def object_def(self) -> OutputObjectDefinition:
522-
return self.processor.object_def
523-
524453

525454
class NativeOutputSchema(StructuredTextOutputSchema[OutputDataT]):
526455
@property
527456
def mode(self) -> OutputMode:
528457
return 'native'
529458

530-
def raise_if_unsupported(self, profile: ModelProfile) -> None:
531-
"""Raise an error if the mode is not supported by this model."""
532-
if not profile.supports_json_schema_output:
533-
raise UserError('Native structured output is not supported by this model.')
534-
535459

536460
@dataclass(init=False)
537461
class PromptedOutputSchema(StructuredTextOutputSchema[OutputDataT]):
@@ -570,14 +494,11 @@ def build_instructions(cls, template: str, object_def: OutputObjectDefinition) -
570494

571495
return template.format(schema=json.dumps(schema))
572496

573-
def raise_if_unsupported(self, profile: ModelProfile) -> None:
574-
"""Raise an error if the mode is not supported by this model."""
575-
super().raise_if_unsupported(profile)
576-
577-
def instructions(self, default_template: str) -> str:
497+
def instructions(self, default_template: str) -> str: # pragma: no cover
578498
"""Get instructions to tell model to output JSON matching the schema."""
579499
template = self.template or default_template
580500
object_def = self.object_def
501+
assert object_def is not None
581502
return self.build_instructions(template, object_def)
582503

583504

@@ -602,12 +523,6 @@ def __init__(
602523
def mode(self) -> OutputMode:
603524
return 'tool'
604525

605-
def raise_if_unsupported(self, profile: ModelProfile) -> None:
606-
"""Raise an error if the mode is not supported by this model."""
607-
super().raise_if_unsupported(profile)
608-
if not profile.supports_tools:
609-
raise UserError('Tool output is not supported by this model.')
610-
611526

612527
class BaseOutputProcessor(ABC, Generic[OutputDataT]):
613528
@abstractmethod

0 commit comments

Comments
 (0)