pydantic-ai-slim 1.0.14__py3-none-any.whl → 1.0.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pydantic-ai-slim might be problematic. Click here for more details.
- pydantic_ai/__init__.py +19 -1
- pydantic_ai/_agent_graph.py +116 -93
- pydantic_ai/_cli.py +4 -7
- pydantic_ai/_output.py +236 -192
- pydantic_ai/_parts_manager.py +8 -42
- pydantic_ai/_tool_manager.py +9 -16
- pydantic_ai/agent/abstract.py +169 -1
- pydantic_ai/builtin_tools.py +82 -0
- pydantic_ai/direct.py +7 -0
- pydantic_ai/durable_exec/dbos/_agent.py +106 -3
- pydantic_ai/durable_exec/temporal/_agent.py +123 -6
- pydantic_ai/durable_exec/temporal/_model.py +8 -0
- pydantic_ai/format_prompt.py +4 -3
- pydantic_ai/mcp.py +20 -10
- pydantic_ai/messages.py +149 -3
- pydantic_ai/models/__init__.py +15 -1
- pydantic_ai/models/anthropic.py +7 -3
- pydantic_ai/models/cohere.py +4 -0
- pydantic_ai/models/function.py +7 -4
- pydantic_ai/models/gemini.py +8 -0
- pydantic_ai/models/google.py +56 -23
- pydantic_ai/models/groq.py +11 -5
- pydantic_ai/models/huggingface.py +5 -3
- pydantic_ai/models/mistral.py +6 -8
- pydantic_ai/models/openai.py +197 -57
- pydantic_ai/models/test.py +4 -0
- pydantic_ai/output.py +5 -2
- pydantic_ai/profiles/__init__.py +2 -0
- pydantic_ai/profiles/google.py +5 -2
- pydantic_ai/profiles/openai.py +2 -1
- pydantic_ai/result.py +46 -30
- pydantic_ai/run.py +35 -7
- pydantic_ai/usage.py +5 -4
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.15.dist-info}/METADATA +3 -3
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.15.dist-info}/RECORD +38 -38
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.15.dist-info}/WHEEL +0 -0
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.15.dist-info}/entry_points.txt +0 -0
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -17,6 +17,7 @@ from typing_extensions import Never
|
|
|
17
17
|
|
|
18
18
|
from pydantic_ai import (
|
|
19
19
|
AbstractToolset,
|
|
20
|
+
AgentRunResultEvent,
|
|
20
21
|
_utils,
|
|
21
22
|
messages as _messages,
|
|
22
23
|
models,
|
|
@@ -558,9 +559,8 @@ class TemporalAgent(WrapperAgent[AgentDepsT, OutputDataT]):
|
|
|
558
559
|
"""
|
|
559
560
|
if workflow.in_workflow():
|
|
560
561
|
raise UserError(
|
|
561
|
-
'`agent.run_stream()` cannot
|
|
562
|
-
'Set an `event_stream_handler` on the agent and use `agent.run()` instead.
|
|
563
|
-
'Please file an issue if this is not sufficient for your use case.'
|
|
562
|
+
'`agent.run_stream()` cannot be used inside a Temporal workflow. '
|
|
563
|
+
'Set an `event_stream_handler` on the agent and use `agent.run()` instead.'
|
|
564
564
|
)
|
|
565
565
|
|
|
566
566
|
async with super().run_stream(
|
|
@@ -580,6 +580,124 @@ class TemporalAgent(WrapperAgent[AgentDepsT, OutputDataT]):
|
|
|
580
580
|
) as result:
|
|
581
581
|
yield result
|
|
582
582
|
|
|
583
|
+
@overload
|
|
584
|
+
def run_stream_events(
|
|
585
|
+
self,
|
|
586
|
+
user_prompt: str | Sequence[_messages.UserContent] | None = None,
|
|
587
|
+
*,
|
|
588
|
+
output_type: None = None,
|
|
589
|
+
message_history: list[_messages.ModelMessage] | None = None,
|
|
590
|
+
deferred_tool_results: DeferredToolResults | None = None,
|
|
591
|
+
model: models.Model | models.KnownModelName | str | None = None,
|
|
592
|
+
deps: AgentDepsT = None,
|
|
593
|
+
model_settings: ModelSettings | None = None,
|
|
594
|
+
usage_limits: _usage.UsageLimits | None = None,
|
|
595
|
+
usage: _usage.RunUsage | None = None,
|
|
596
|
+
infer_name: bool = True,
|
|
597
|
+
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
|
|
598
|
+
) -> AsyncIterator[_messages.AgentStreamEvent | AgentRunResultEvent[OutputDataT]]: ...
|
|
599
|
+
|
|
600
|
+
@overload
|
|
601
|
+
def run_stream_events(
|
|
602
|
+
self,
|
|
603
|
+
user_prompt: str | Sequence[_messages.UserContent] | None = None,
|
|
604
|
+
*,
|
|
605
|
+
output_type: OutputSpec[RunOutputDataT],
|
|
606
|
+
message_history: list[_messages.ModelMessage] | None = None,
|
|
607
|
+
deferred_tool_results: DeferredToolResults | None = None,
|
|
608
|
+
model: models.Model | models.KnownModelName | str | None = None,
|
|
609
|
+
deps: AgentDepsT = None,
|
|
610
|
+
model_settings: ModelSettings | None = None,
|
|
611
|
+
usage_limits: _usage.UsageLimits | None = None,
|
|
612
|
+
usage: _usage.RunUsage | None = None,
|
|
613
|
+
infer_name: bool = True,
|
|
614
|
+
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
|
|
615
|
+
) -> AsyncIterator[_messages.AgentStreamEvent | AgentRunResultEvent[RunOutputDataT]]: ...
|
|
616
|
+
|
|
617
|
+
def run_stream_events(
|
|
618
|
+
self,
|
|
619
|
+
user_prompt: str | Sequence[_messages.UserContent] | None = None,
|
|
620
|
+
*,
|
|
621
|
+
output_type: OutputSpec[RunOutputDataT] | None = None,
|
|
622
|
+
message_history: list[_messages.ModelMessage] | None = None,
|
|
623
|
+
deferred_tool_results: DeferredToolResults | None = None,
|
|
624
|
+
model: models.Model | models.KnownModelName | str | None = None,
|
|
625
|
+
deps: AgentDepsT = None,
|
|
626
|
+
model_settings: ModelSettings | None = None,
|
|
627
|
+
usage_limits: _usage.UsageLimits | None = None,
|
|
628
|
+
usage: _usage.RunUsage | None = None,
|
|
629
|
+
infer_name: bool = True,
|
|
630
|
+
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
|
|
631
|
+
) -> AsyncIterator[_messages.AgentStreamEvent | AgentRunResultEvent[Any]]:
|
|
632
|
+
"""Run the agent with a user prompt in async mode and stream events from the run.
|
|
633
|
+
|
|
634
|
+
This is a convenience method that wraps [`self.run`][pydantic_ai.agent.AbstractAgent.run] and
|
|
635
|
+
uses the `event_stream_handler` kwarg to get a stream of events from the run.
|
|
636
|
+
|
|
637
|
+
Example:
|
|
638
|
+
```python
|
|
639
|
+
from pydantic_ai import Agent, AgentRunResultEvent, AgentStreamEvent
|
|
640
|
+
|
|
641
|
+
agent = Agent('openai:gpt-4o')
|
|
642
|
+
|
|
643
|
+
async def main():
|
|
644
|
+
events: list[AgentStreamEvent | AgentRunResultEvent] = []
|
|
645
|
+
async for event in agent.run_stream_events('What is the capital of France?'):
|
|
646
|
+
events.append(event)
|
|
647
|
+
print(events)
|
|
648
|
+
'''
|
|
649
|
+
[
|
|
650
|
+
PartStartEvent(index=0, part=TextPart(content='The capital of ')),
|
|
651
|
+
FinalResultEvent(tool_name=None, tool_call_id=None),
|
|
652
|
+
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='France is Paris. ')),
|
|
653
|
+
AgentRunResultEvent(
|
|
654
|
+
result=AgentRunResult(output='The capital of France is Paris. ')
|
|
655
|
+
),
|
|
656
|
+
]
|
|
657
|
+
'''
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Arguments are the same as for [`self.run`][pydantic_ai.agent.AbstractAgent.run],
|
|
661
|
+
except that `event_stream_handler` is now allowed.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
user_prompt: User input to start/continue the conversation.
|
|
665
|
+
output_type: Custom output type to use for this run, `output_type` may only be used if the agent has no
|
|
666
|
+
output validators since output validators would expect an argument that matches the agent's output type.
|
|
667
|
+
message_history: History of the conversation so far.
|
|
668
|
+
deferred_tool_results: Optional results for deferred tool calls in the message history.
|
|
669
|
+
model: Optional model to use for this run, required if `model` was not set when creating the agent.
|
|
670
|
+
deps: Optional dependencies to use for this run.
|
|
671
|
+
model_settings: Optional settings to use for this model's request.
|
|
672
|
+
usage_limits: Optional limits on model request count or token usage.
|
|
673
|
+
usage: Optional usage to start with, useful for resuming a conversation or agents used in tools.
|
|
674
|
+
infer_name: Whether to try to infer the agent name from the call frame if it's not set.
|
|
675
|
+
toolsets: Optional additional toolsets for this run.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
An async iterable of stream events `AgentStreamEvent` and finally a `AgentRunResultEvent` with the final
|
|
679
|
+
run result.
|
|
680
|
+
"""
|
|
681
|
+
if workflow.in_workflow():
|
|
682
|
+
raise UserError(
|
|
683
|
+
'`agent.run_stream_events()` cannot be used inside a Temporal workflow. '
|
|
684
|
+
'Set an `event_stream_handler` on the agent and use `agent.run()` instead.'
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
return super().run_stream_events(
|
|
688
|
+
user_prompt,
|
|
689
|
+
output_type=output_type,
|
|
690
|
+
message_history=message_history,
|
|
691
|
+
deferred_tool_results=deferred_tool_results,
|
|
692
|
+
model=model,
|
|
693
|
+
deps=deps,
|
|
694
|
+
model_settings=model_settings,
|
|
695
|
+
usage_limits=usage_limits,
|
|
696
|
+
usage=usage,
|
|
697
|
+
infer_name=infer_name,
|
|
698
|
+
toolsets=toolsets,
|
|
699
|
+
)
|
|
700
|
+
|
|
583
701
|
@overload
|
|
584
702
|
def iter(
|
|
585
703
|
self,
|
|
@@ -711,9 +829,8 @@ class TemporalAgent(WrapperAgent[AgentDepsT, OutputDataT]):
|
|
|
711
829
|
if workflow.in_workflow():
|
|
712
830
|
if not self._temporal_overrides_active.get():
|
|
713
831
|
raise UserError(
|
|
714
|
-
'`agent.iter()` cannot
|
|
715
|
-
'Set an `event_stream_handler` on the agent and use `agent.run()` instead.
|
|
716
|
-
'Please file an issue if this is not sufficient for your use case.'
|
|
832
|
+
'`agent.iter()` cannot be used inside a Temporal workflow. '
|
|
833
|
+
'Set an `event_stream_handler` on the agent and use `agent.run()` instead.'
|
|
717
834
|
)
|
|
718
835
|
|
|
719
836
|
if model is not None:
|
|
@@ -128,6 +128,8 @@ class TemporalModel(WrapperModel):
|
|
|
128
128
|
if not workflow.in_workflow():
|
|
129
129
|
return await super().request(messages, model_settings, model_request_parameters)
|
|
130
130
|
|
|
131
|
+
self._validate_model_request_parameters(model_request_parameters)
|
|
132
|
+
|
|
131
133
|
return await workflow.execute_activity( # pyright: ignore[reportUnknownMemberType]
|
|
132
134
|
activity=self.request_activity,
|
|
133
135
|
arg=_RequestParams(
|
|
@@ -163,6 +165,8 @@ class TemporalModel(WrapperModel):
|
|
|
163
165
|
# and that only calls `request_stream` if `event_stream_handler` is set.
|
|
164
166
|
assert self.event_stream_handler is not None
|
|
165
167
|
|
|
168
|
+
self._validate_model_request_parameters(model_request_parameters)
|
|
169
|
+
|
|
166
170
|
serialized_run_context = self.run_context_type.serialize_run_context(run_context)
|
|
167
171
|
response = await workflow.execute_activity( # pyright: ignore[reportUnknownMemberType]
|
|
168
172
|
activity=self.request_stream_activity,
|
|
@@ -178,3 +182,7 @@ class TemporalModel(WrapperModel):
|
|
|
178
182
|
**self.activity_config,
|
|
179
183
|
)
|
|
180
184
|
yield TemporalStreamedResponse(model_request_parameters, response)
|
|
185
|
+
|
|
186
|
+
def _validate_model_request_parameters(self, model_request_parameters: ModelRequestParameters) -> None:
|
|
187
|
+
if model_request_parameters.allow_image_output:
|
|
188
|
+
raise UserError('Image output is not supported with Temporal because of the 2MB payload size limit.')
|
pydantic_ai/format_prompt.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations as _annotations
|
|
|
3
3
|
from collections.abc import Iterable, Iterator, Mapping
|
|
4
4
|
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
|
5
5
|
from datetime import date
|
|
6
|
+
from enum import Enum
|
|
6
7
|
from typing import Any, Literal
|
|
7
8
|
from xml.etree import ElementTree
|
|
8
9
|
|
|
@@ -26,8 +27,8 @@ def format_as_xml(
|
|
|
26
27
|
This is useful since LLMs often find it easier to read semi-structured data (e.g. examples) as XML,
|
|
27
28
|
rather than JSON etc.
|
|
28
29
|
|
|
29
|
-
Supports: `str`, `bytes`, `bytearray`, `bool`, `int`, `float`, `date`, `datetime`, `
|
|
30
|
-
`Iterable`, `dataclass`, and `BaseModel`.
|
|
30
|
+
Supports: `str`, `bytes`, `bytearray`, `bool`, `int`, `float`, `date`, `datetime`, `Enum`,
|
|
31
|
+
`Mapping`, `Iterable`, `dataclass`, and `BaseModel`.
|
|
31
32
|
|
|
32
33
|
Args:
|
|
33
34
|
obj: Python Object to serialize to XML.
|
|
@@ -101,7 +102,7 @@ class _ToXml:
|
|
|
101
102
|
element.text = value
|
|
102
103
|
elif isinstance(value, bytes | bytearray):
|
|
103
104
|
element.text = value.decode(errors='ignore')
|
|
104
|
-
elif isinstance(value, bool | int | float):
|
|
105
|
+
elif isinstance(value, bool | int | float | Enum):
|
|
105
106
|
element.text = str(value)
|
|
106
107
|
elif isinstance(value, date):
|
|
107
108
|
element.text = value.isoformat()
|
pydantic_ai/mcp.py
CHANGED
|
@@ -167,6 +167,10 @@ class MCPServer(AbstractToolset[Any], ABC):
|
|
|
167
167
|
def id(self) -> str | None:
|
|
168
168
|
return self._id
|
|
169
169
|
|
|
170
|
+
@id.setter
|
|
171
|
+
def id(self, value: str | None):
|
|
172
|
+
self._id = value
|
|
173
|
+
|
|
170
174
|
@property
|
|
171
175
|
def label(self) -> str:
|
|
172
176
|
if self.id:
|
|
@@ -414,6 +418,9 @@ class MCPServer(AbstractToolset[Any], ABC):
|
|
|
414
418
|
else:
|
|
415
419
|
assert_never(resource)
|
|
416
420
|
|
|
421
|
+
def __eq__(self, value: object, /) -> bool:
|
|
422
|
+
return isinstance(value, MCPServer) and self.id == value.id and self.tool_prefix == value.tool_prefix
|
|
423
|
+
|
|
417
424
|
|
|
418
425
|
class MCPServerStdio(MCPServer):
|
|
419
426
|
"""Runs an MCP server in a subprocess and communicates with it over stdin/stdout.
|
|
@@ -568,10 +575,10 @@ class MCPServerStdio(MCPServer):
|
|
|
568
575
|
return f'{self.__class__.__name__}({", ".join(repr_args)})'
|
|
569
576
|
|
|
570
577
|
def __eq__(self, value: object, /) -> bool:
|
|
571
|
-
if not isinstance(value, MCPServerStdio):
|
|
572
|
-
return False # pragma: no cover
|
|
573
578
|
return (
|
|
574
|
-
|
|
579
|
+
super().__eq__(value)
|
|
580
|
+
and isinstance(value, MCPServerStdio)
|
|
581
|
+
and self.command == value.command
|
|
575
582
|
and self.args == value.args
|
|
576
583
|
and self.env == value.env
|
|
577
584
|
and self.cwd == value.cwd
|
|
@@ -809,9 +816,7 @@ class MCPServerSSE(_MCPServerHTTP):
|
|
|
809
816
|
return sse_client # pragma: no cover
|
|
810
817
|
|
|
811
818
|
def __eq__(self, value: object, /) -> bool:
|
|
812
|
-
|
|
813
|
-
return False # pragma: no cover
|
|
814
|
-
return self.url == value.url
|
|
819
|
+
return super().__eq__(value) and isinstance(value, MCPServerSSE) and self.url == value.url
|
|
815
820
|
|
|
816
821
|
|
|
817
822
|
@deprecated('The `MCPServerHTTP` class is deprecated, use `MCPServerSSE` instead.')
|
|
@@ -885,9 +890,7 @@ class MCPServerStreamableHTTP(_MCPServerHTTP):
|
|
|
885
890
|
return streamablehttp_client # pragma: no cover
|
|
886
891
|
|
|
887
892
|
def __eq__(self, value: object, /) -> bool:
|
|
888
|
-
|
|
889
|
-
return False # pragma: no cover
|
|
890
|
-
return self.url == value.url
|
|
893
|
+
return super().__eq__(value) and isinstance(value, MCPServerStreamableHTTP) and self.url == value.url
|
|
891
894
|
|
|
892
895
|
|
|
893
896
|
ToolResult = (
|
|
@@ -964,4 +967,11 @@ def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServer
|
|
|
964
967
|
raise FileNotFoundError(f'Config file {config_path} not found')
|
|
965
968
|
|
|
966
969
|
config = MCPServerConfig.model_validate_json(config_path.read_bytes())
|
|
967
|
-
|
|
970
|
+
|
|
971
|
+
servers: list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE] = []
|
|
972
|
+
for name, server in config.mcp_servers.items():
|
|
973
|
+
server.id = name
|
|
974
|
+
server.tool_prefix = name
|
|
975
|
+
servers.append(server)
|
|
976
|
+
|
|
977
|
+
return servers
|
pydantic_ai/messages.py
CHANGED
|
@@ -13,7 +13,7 @@ import pydantic
|
|
|
13
13
|
import pydantic_core
|
|
14
14
|
from genai_prices import calc_price, types as genai_types
|
|
15
15
|
from opentelemetry._events import Event # pyright: ignore[reportPrivateImportUsage]
|
|
16
|
-
from typing_extensions import deprecated
|
|
16
|
+
from typing_extensions import Self, deprecated
|
|
17
17
|
|
|
18
18
|
from . import _otel_messages, _utils
|
|
19
19
|
from ._utils import generate_tool_call_id as _generate_tool_call_id, now_utc as _now_utc
|
|
@@ -461,7 +461,7 @@ class BinaryContent:
|
|
|
461
461
|
"""The media type of the binary data."""
|
|
462
462
|
|
|
463
463
|
identifier: str
|
|
464
|
-
"""Identifier for the binary content, such as a unique ID.
|
|
464
|
+
"""Identifier for the binary content, such as a unique ID.
|
|
465
465
|
This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument,
|
|
466
466
|
and the tool can look up the file in question by iterating over the message history and finding the matching `BinaryContent`.
|
|
467
467
|
|
|
@@ -496,6 +496,33 @@ class BinaryContent:
|
|
|
496
496
|
self.vendor_metadata = vendor_metadata
|
|
497
497
|
self.kind = kind
|
|
498
498
|
|
|
499
|
+
@staticmethod
|
|
500
|
+
def narrow_type(bc: BinaryContent) -> BinaryContent | BinaryImage:
|
|
501
|
+
"""Narrow the type of the `BinaryContent` to `BinaryImage` if it's an image."""
|
|
502
|
+
if bc.is_image:
|
|
503
|
+
return BinaryImage(
|
|
504
|
+
data=bc.data,
|
|
505
|
+
media_type=bc.media_type,
|
|
506
|
+
identifier=bc.identifier,
|
|
507
|
+
vendor_metadata=bc.vendor_metadata,
|
|
508
|
+
)
|
|
509
|
+
else:
|
|
510
|
+
return bc # pragma: no cover
|
|
511
|
+
|
|
512
|
+
@classmethod
|
|
513
|
+
def from_data_uri(cls, data_uri: str) -> Self:
|
|
514
|
+
"""Create a `BinaryContent` from a data URI."""
|
|
515
|
+
prefix = 'data:'
|
|
516
|
+
if not data_uri.startswith(prefix):
|
|
517
|
+
raise ValueError('Data URI must start with "data:"') # pragma: no cover
|
|
518
|
+
media_type, data = data_uri[len(prefix) :].split(';base64,', 1)
|
|
519
|
+
return cls(data=base64.b64decode(data), media_type=media_type)
|
|
520
|
+
|
|
521
|
+
@property
|
|
522
|
+
def data_uri(self) -> str:
|
|
523
|
+
"""Convert the `BinaryContent` to a data URI."""
|
|
524
|
+
return f'data:{self.media_type};base64,{base64.b64encode(self.data).decode()}'
|
|
525
|
+
|
|
499
526
|
@property
|
|
500
527
|
def is_audio(self) -> bool:
|
|
501
528
|
"""Return `True` if the media type is an audio type."""
|
|
@@ -534,6 +561,24 @@ class BinaryContent:
|
|
|
534
561
|
__repr__ = _utils.dataclasses_no_defaults_repr
|
|
535
562
|
|
|
536
563
|
|
|
564
|
+
class BinaryImage(BinaryContent):
|
|
565
|
+
"""Binary content that's guaranteed to be an image."""
|
|
566
|
+
|
|
567
|
+
def __init__(
|
|
568
|
+
self,
|
|
569
|
+
data: bytes,
|
|
570
|
+
*,
|
|
571
|
+
media_type: str,
|
|
572
|
+
identifier: str | None = None,
|
|
573
|
+
vendor_metadata: dict[str, Any] | None = None,
|
|
574
|
+
kind: Literal['binary'] = 'binary',
|
|
575
|
+
):
|
|
576
|
+
super().__init__(data=data, media_type=media_type, identifier=identifier, vendor_metadata=vendor_metadata)
|
|
577
|
+
|
|
578
|
+
if not self.is_image:
|
|
579
|
+
raise ValueError('`BinaryImage` must be have a media type that starts with "image/"') # pragma: no cover
|
|
580
|
+
|
|
581
|
+
|
|
537
582
|
MultiModalContent = ImageUrl | AudioUrl | DocumentUrl | VideoUrl | BinaryContent
|
|
538
583
|
UserContent: TypeAlias = str | MultiModalContent
|
|
539
584
|
|
|
@@ -934,6 +979,32 @@ class ThinkingPart:
|
|
|
934
979
|
__repr__ = _utils.dataclasses_no_defaults_repr
|
|
935
980
|
|
|
936
981
|
|
|
982
|
+
@dataclass(repr=False)
|
|
983
|
+
class FilePart:
|
|
984
|
+
"""A file response from a model."""
|
|
985
|
+
|
|
986
|
+
content: Annotated[BinaryContent, pydantic.AfterValidator(BinaryImage.narrow_type)]
|
|
987
|
+
"""The file content of the response."""
|
|
988
|
+
|
|
989
|
+
_: KW_ONLY
|
|
990
|
+
|
|
991
|
+
id: str | None = None
|
|
992
|
+
"""The identifier of the file part."""
|
|
993
|
+
|
|
994
|
+
provider_name: str | None = None
|
|
995
|
+
"""The name of the provider that generated the response.
|
|
996
|
+
"""
|
|
997
|
+
|
|
998
|
+
part_kind: Literal['file'] = 'file'
|
|
999
|
+
"""Part type identifier, this is available on all parts as a discriminator."""
|
|
1000
|
+
|
|
1001
|
+
def has_content(self) -> bool:
|
|
1002
|
+
"""Return `True` if the file content is non-empty."""
|
|
1003
|
+
return bool(self.content) # pragma: no cover
|
|
1004
|
+
|
|
1005
|
+
__repr__ = _utils.dataclasses_no_defaults_repr
|
|
1006
|
+
|
|
1007
|
+
|
|
937
1008
|
@dataclass(repr=False)
|
|
938
1009
|
class BaseToolCallPart:
|
|
939
1010
|
"""A tool call from a model."""
|
|
@@ -1016,7 +1087,7 @@ class BuiltinToolCallPart(BaseToolCallPart):
|
|
|
1016
1087
|
|
|
1017
1088
|
|
|
1018
1089
|
ModelResponsePart = Annotated[
|
|
1019
|
-
TextPart | ToolCallPart | BuiltinToolCallPart | BuiltinToolReturnPart | ThinkingPart,
|
|
1090
|
+
TextPart | ToolCallPart | BuiltinToolCallPart | BuiltinToolReturnPart | ThinkingPart | FilePart,
|
|
1020
1091
|
pydantic.Discriminator('part_kind'),
|
|
1021
1092
|
]
|
|
1022
1093
|
"""A message part returned by a model."""
|
|
@@ -1073,6 +1144,61 @@ class ModelResponse:
|
|
|
1073
1144
|
finish_reason: FinishReason | None = None
|
|
1074
1145
|
"""Reason the model finished generating the response, normalized to OpenTelemetry values."""
|
|
1075
1146
|
|
|
1147
|
+
@property
|
|
1148
|
+
def text(self) -> str | None:
|
|
1149
|
+
"""Get the text in the response."""
|
|
1150
|
+
texts: list[str] = []
|
|
1151
|
+
last_part: ModelResponsePart | None = None
|
|
1152
|
+
for part in self.parts:
|
|
1153
|
+
if isinstance(part, TextPart):
|
|
1154
|
+
# Adjacent text parts should be joined together, but if there are parts in between
|
|
1155
|
+
# (like built-in tool calls) they should have newlines between them
|
|
1156
|
+
if isinstance(last_part, TextPart):
|
|
1157
|
+
texts[-1] += part.content
|
|
1158
|
+
else:
|
|
1159
|
+
texts.append(part.content)
|
|
1160
|
+
last_part = part
|
|
1161
|
+
if not texts:
|
|
1162
|
+
return None
|
|
1163
|
+
|
|
1164
|
+
return '\n\n'.join(texts)
|
|
1165
|
+
|
|
1166
|
+
@property
|
|
1167
|
+
def thinking(self) -> str | None:
|
|
1168
|
+
"""Get the thinking in the response."""
|
|
1169
|
+
thinking_parts = [part.content for part in self.parts if isinstance(part, ThinkingPart)]
|
|
1170
|
+
if not thinking_parts:
|
|
1171
|
+
return None
|
|
1172
|
+
return '\n\n'.join(thinking_parts)
|
|
1173
|
+
|
|
1174
|
+
@property
|
|
1175
|
+
def files(self) -> list[BinaryContent]:
|
|
1176
|
+
"""Get the files in the response."""
|
|
1177
|
+
return [part.content for part in self.parts if isinstance(part, FilePart)]
|
|
1178
|
+
|
|
1179
|
+
@property
|
|
1180
|
+
def images(self) -> list[BinaryImage]:
|
|
1181
|
+
"""Get the images in the response."""
|
|
1182
|
+
return [file for file in self.files if isinstance(file, BinaryImage)]
|
|
1183
|
+
|
|
1184
|
+
@property
|
|
1185
|
+
def tool_calls(self) -> list[ToolCallPart]:
|
|
1186
|
+
"""Get the tool calls in the response."""
|
|
1187
|
+
return [part for part in self.parts if isinstance(part, ToolCallPart)]
|
|
1188
|
+
|
|
1189
|
+
@property
|
|
1190
|
+
def builtin_tool_calls(self) -> list[tuple[BuiltinToolCallPart, BuiltinToolReturnPart]]:
|
|
1191
|
+
"""Get the builtin tool calls and results in the response."""
|
|
1192
|
+
calls = [part for part in self.parts if isinstance(part, BuiltinToolCallPart)]
|
|
1193
|
+
if not calls:
|
|
1194
|
+
return []
|
|
1195
|
+
returns_by_id = {part.tool_call_id: part for part in self.parts if isinstance(part, BuiltinToolReturnPart)}
|
|
1196
|
+
return [
|
|
1197
|
+
(call_part, returns_by_id[call_part.tool_call_id])
|
|
1198
|
+
for call_part in calls
|
|
1199
|
+
if call_part.tool_call_id in returns_by_id
|
|
1200
|
+
]
|
|
1201
|
+
|
|
1076
1202
|
@deprecated('`price` is deprecated, use `cost` instead')
|
|
1077
1203
|
def price(self) -> genai_types.PriceCalculation: # pragma: no cover
|
|
1078
1204
|
return self.cost()
|
|
@@ -1118,6 +1244,18 @@ class ModelResponse:
|
|
|
1118
1244
|
body.setdefault('content', []).append(
|
|
1119
1245
|
{'kind': kind, **({'text': part.content} if settings.include_content else {})}
|
|
1120
1246
|
)
|
|
1247
|
+
elif isinstance(part, FilePart):
|
|
1248
|
+
body.setdefault('content', []).append(
|
|
1249
|
+
{
|
|
1250
|
+
'kind': 'binary',
|
|
1251
|
+
'media_type': part.content.media_type,
|
|
1252
|
+
**(
|
|
1253
|
+
{'binary_content': base64.b64encode(part.content.data).decode()}
|
|
1254
|
+
if settings.include_content and settings.include_binary_content
|
|
1255
|
+
else {}
|
|
1256
|
+
),
|
|
1257
|
+
}
|
|
1258
|
+
)
|
|
1121
1259
|
|
|
1122
1260
|
if content := body.get('content'):
|
|
1123
1261
|
text_content = content[0].get('text')
|
|
@@ -1143,6 +1281,11 @@ class ModelResponse:
|
|
|
1143
1281
|
**({'content': part.content} if settings.include_content else {}),
|
|
1144
1282
|
)
|
|
1145
1283
|
)
|
|
1284
|
+
elif isinstance(part, FilePart):
|
|
1285
|
+
converted_part = _otel_messages.BinaryDataPart(type='binary', media_type=part.content.media_type)
|
|
1286
|
+
if settings.include_content and settings.include_binary_content:
|
|
1287
|
+
converted_part['content'] = base64.b64encode(part.content.data).decode()
|
|
1288
|
+
parts.append(converted_part)
|
|
1146
1289
|
elif isinstance(part, BaseToolCallPart):
|
|
1147
1290
|
call_part = _otel_messages.ToolCallPart(type='tool_call', id=part.tool_call_id, name=part.tool_name)
|
|
1148
1291
|
if isinstance(part, BuiltinToolCallPart):
|
|
@@ -1511,6 +1654,9 @@ class FunctionToolResultEvent:
|
|
|
1511
1654
|
|
|
1512
1655
|
_: KW_ONLY
|
|
1513
1656
|
|
|
1657
|
+
content: str | Sequence[UserContent] | None = None
|
|
1658
|
+
"""The content that will be sent to the model as a UserPromptPart following the result."""
|
|
1659
|
+
|
|
1514
1660
|
event_kind: Literal['function_tool_result'] = 'function_tool_result'
|
|
1515
1661
|
"""Event type identifier, used as a discriminator."""
|
|
1516
1662
|
|
pydantic_ai/models/__init__.py
CHANGED
|
@@ -27,6 +27,8 @@ from .._run_context import RunContext
|
|
|
27
27
|
from ..builtin_tools import AbstractBuiltinTool
|
|
28
28
|
from ..exceptions import UserError
|
|
29
29
|
from ..messages import (
|
|
30
|
+
BinaryImage,
|
|
31
|
+
FilePart,
|
|
30
32
|
FileUrl,
|
|
31
33
|
FinalResultEvent,
|
|
32
34
|
FinishReason,
|
|
@@ -141,12 +143,20 @@ KnownModelName = TypeAliasType(
|
|
|
141
143
|
'google-gla:gemini-2.0-flash',
|
|
142
144
|
'google-gla:gemini-2.0-flash-lite',
|
|
143
145
|
'google-gla:gemini-2.5-flash',
|
|
146
|
+
'google-gla:gemini-2.5-flash-preview-09-2025',
|
|
147
|
+
'google-gla:gemini-flash-latest',
|
|
144
148
|
'google-gla:gemini-2.5-flash-lite',
|
|
149
|
+
'google-gla:gemini-2.5-flash-lite-preview-09-2025',
|
|
150
|
+
'google-gla:gemini-flash-lite-latest',
|
|
145
151
|
'google-gla:gemini-2.5-pro',
|
|
146
152
|
'google-vertex:gemini-2.0-flash',
|
|
147
153
|
'google-vertex:gemini-2.0-flash-lite',
|
|
148
154
|
'google-vertex:gemini-2.5-flash',
|
|
155
|
+
'google-vertex:gemini-2.5-flash-preview-09-2025',
|
|
156
|
+
'google-vertex:gemini-flash-latest',
|
|
149
157
|
'google-vertex:gemini-2.5-flash-lite',
|
|
158
|
+
'google-vertex:gemini-2.5-flash-lite-preview-09-2025',
|
|
159
|
+
'google-vertex:gemini-flash-lite-latest',
|
|
150
160
|
'google-vertex:gemini-2.5-pro',
|
|
151
161
|
'grok:grok-4',
|
|
152
162
|
'grok:grok-4-0709',
|
|
@@ -300,6 +310,7 @@ class ModelRequestParameters:
|
|
|
300
310
|
output_object: OutputObjectDefinition | None = None
|
|
301
311
|
output_tools: list[ToolDefinition] = field(default_factory=list)
|
|
302
312
|
allow_text_output: bool = True
|
|
313
|
+
allow_image_output: bool = False
|
|
303
314
|
|
|
304
315
|
@cached_property
|
|
305
316
|
def tool_defs(self) -> dict[str, ToolDefinition]:
|
|
@@ -557,6 +568,7 @@ class StreamedResponse(ABC):
|
|
|
557
568
|
finish_reason=self.finish_reason,
|
|
558
569
|
)
|
|
559
570
|
|
|
571
|
+
# TODO (v2): Make this a property
|
|
560
572
|
def usage(self) -> RequestUsage:
|
|
561
573
|
"""Get the usage of the response so far. This will not be the final usage until the stream is exhausted."""
|
|
562
574
|
return self._usage
|
|
@@ -865,7 +877,9 @@ def _get_final_result_event(e: ModelResponseStreamEvent, params: ModelRequestPar
|
|
|
865
877
|
"""Return an appropriate FinalResultEvent if `e` corresponds to a part that will produce a final result."""
|
|
866
878
|
if isinstance(e, PartStartEvent):
|
|
867
879
|
new_part = e.part
|
|
868
|
-
if isinstance(new_part, TextPart) and params.allow_text_output
|
|
880
|
+
if (isinstance(new_part, TextPart) and params.allow_text_output) or (
|
|
881
|
+
isinstance(new_part, FilePart) and params.allow_image_output and isinstance(new_part.content, BinaryImage)
|
|
882
|
+
):
|
|
869
883
|
return FinalResultEvent(tool_name=None, tool_call_id=None)
|
|
870
884
|
elif isinstance(new_part, ToolCallPart) and (tool_def := params.tool_defs.get(new_part.tool_name)):
|
|
871
885
|
if tool_def.kind == 'output':
|
pydantic_ai/models/anthropic.py
CHANGED
|
@@ -20,6 +20,7 @@ from ..messages import (
|
|
|
20
20
|
BuiltinToolCallPart,
|
|
21
21
|
BuiltinToolReturnPart,
|
|
22
22
|
DocumentUrl,
|
|
23
|
+
FilePart,
|
|
23
24
|
FinishReason,
|
|
24
25
|
ImageUrl,
|
|
25
26
|
ModelMessage,
|
|
@@ -545,6 +546,9 @@ class AnthropicModel(Model):
|
|
|
545
546
|
),
|
|
546
547
|
)
|
|
547
548
|
)
|
|
549
|
+
elif isinstance(response_part, FilePart): # pragma: no cover
|
|
550
|
+
# Files generated by models are not sent back to models that don't themselves generate files.
|
|
551
|
+
pass
|
|
548
552
|
else:
|
|
549
553
|
assert_never(response_part)
|
|
550
554
|
if len(assistant_content_params) > 0:
|
|
@@ -693,17 +697,17 @@ class AnthropicStreamedResponse(StreamedResponse):
|
|
|
693
697
|
if maybe_event is not None: # pragma: no branch
|
|
694
698
|
yield maybe_event
|
|
695
699
|
elif isinstance(current_block, BetaServerToolUseBlock):
|
|
696
|
-
yield self._parts_manager.
|
|
700
|
+
yield self._parts_manager.handle_part(
|
|
697
701
|
vendor_part_id=event.index,
|
|
698
702
|
part=_map_server_tool_use_block(current_block, self.provider_name),
|
|
699
703
|
)
|
|
700
704
|
elif isinstance(current_block, BetaWebSearchToolResultBlock):
|
|
701
|
-
yield self._parts_manager.
|
|
705
|
+
yield self._parts_manager.handle_part(
|
|
702
706
|
vendor_part_id=event.index,
|
|
703
707
|
part=_map_web_search_tool_result_block(current_block, self.provider_name),
|
|
704
708
|
)
|
|
705
709
|
elif isinstance(current_block, BetaCodeExecutionToolResultBlock):
|
|
706
|
-
yield self._parts_manager.
|
|
710
|
+
yield self._parts_manager.handle_part(
|
|
707
711
|
vendor_part_id=event.index,
|
|
708
712
|
part=_map_code_execution_tool_result_block(current_block, self.provider_name),
|
|
709
713
|
)
|
pydantic_ai/models/cohere.py
CHANGED
|
@@ -13,6 +13,7 @@ from .._utils import generate_tool_call_id as _generate_tool_call_id, guard_tool
|
|
|
13
13
|
from ..messages import (
|
|
14
14
|
BuiltinToolCallPart,
|
|
15
15
|
BuiltinToolReturnPart,
|
|
16
|
+
FilePart,
|
|
16
17
|
FinishReason,
|
|
17
18
|
ModelMessage,
|
|
18
19
|
ModelRequest,
|
|
@@ -255,6 +256,9 @@ class CohereModel(Model):
|
|
|
255
256
|
elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover
|
|
256
257
|
# This is currently never returned from cohere
|
|
257
258
|
pass
|
|
259
|
+
elif isinstance(item, FilePart): # pragma: no cover
|
|
260
|
+
# Files generated by models are not sent back to models that don't themselves generate files.
|
|
261
|
+
pass
|
|
258
262
|
else:
|
|
259
263
|
assert_never(item)
|
|
260
264
|
|
pydantic_ai/models/function.py
CHANGED
|
@@ -18,6 +18,7 @@ from ..messages import (
|
|
|
18
18
|
BinaryContent,
|
|
19
19
|
BuiltinToolCallPart,
|
|
20
20
|
BuiltinToolReturnPart,
|
|
21
|
+
FilePart,
|
|
21
22
|
ModelMessage,
|
|
22
23
|
ModelRequest,
|
|
23
24
|
ModelResponse,
|
|
@@ -319,12 +320,12 @@ class FunctionStreamedResponse(StreamedResponse):
|
|
|
319
320
|
if content := delta.args_as_json_str(): # pragma: no branch
|
|
320
321
|
response_tokens = _estimate_string_tokens(content)
|
|
321
322
|
self._usage += usage.RequestUsage(output_tokens=response_tokens)
|
|
322
|
-
yield self._parts_manager.
|
|
323
|
+
yield self._parts_manager.handle_part(vendor_part_id=dtc_index, part=delta)
|
|
323
324
|
elif isinstance(delta, BuiltinToolReturnPart):
|
|
324
325
|
if content := delta.model_response_str(): # pragma: no branch
|
|
325
326
|
response_tokens = _estimate_string_tokens(content)
|
|
326
327
|
self._usage += usage.RequestUsage(output_tokens=response_tokens)
|
|
327
|
-
yield self._parts_manager.
|
|
328
|
+
yield self._parts_manager.handle_part(vendor_part_id=dtc_index, part=delta)
|
|
328
329
|
else:
|
|
329
330
|
assert_never(delta)
|
|
330
331
|
|
|
@@ -371,10 +372,12 @@ def _estimate_usage(messages: Iterable[ModelMessage]) -> usage.RequestUsage:
|
|
|
371
372
|
response_tokens += _estimate_string_tokens(part.content)
|
|
372
373
|
elif isinstance(part, ToolCallPart):
|
|
373
374
|
response_tokens += 1 + _estimate_string_tokens(part.args_as_json_str())
|
|
374
|
-
elif isinstance(part, BuiltinToolCallPart):
|
|
375
|
+
elif isinstance(part, BuiltinToolCallPart):
|
|
375
376
|
response_tokens += 1 + _estimate_string_tokens(part.args_as_json_str())
|
|
376
|
-
elif isinstance(part, BuiltinToolReturnPart):
|
|
377
|
+
elif isinstance(part, BuiltinToolReturnPart):
|
|
377
378
|
response_tokens += _estimate_string_tokens(part.model_response_str())
|
|
379
|
+
elif isinstance(part, FilePart):
|
|
380
|
+
response_tokens += _estimate_string_tokens([part.content])
|
|
378
381
|
else:
|
|
379
382
|
assert_never(part)
|
|
380
383
|
else:
|