pydantic-ai-slim 1.0.14__py3-none-any.whl → 1.0.16__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 +129 -105
- pydantic_ai/_cli.py +7 -10
- pydantic_ai/_output.py +236 -192
- pydantic_ai/_parts_manager.py +8 -42
- pydantic_ai/_tool_manager.py +9 -16
- pydantic_ai/agent/__init__.py +18 -7
- pydantic_ai/agent/abstract.py +192 -23
- pydantic_ai/agent/wrapper.py +7 -4
- pydantic_ai/builtin_tools.py +82 -0
- pydantic_ai/direct.py +16 -9
- pydantic_ai/durable_exec/dbos/_agent.py +124 -18
- pydantic_ai/durable_exec/temporal/_agent.py +139 -19
- pydantic_ai/durable_exec/temporal/_model.py +8 -0
- pydantic_ai/format_prompt.py +9 -6
- pydantic_ai/mcp.py +20 -10
- pydantic_ai/messages.py +214 -44
- pydantic_ai/models/__init__.py +15 -1
- pydantic_ai/models/anthropic.py +27 -22
- 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 +206 -58
- 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 +51 -35
- pydantic_ai/run.py +35 -7
- pydantic_ai/usage.py +40 -5
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/METADATA +4 -4
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/RECORD +40 -40
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/WHEEL +0 -0
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/entry_points.txt +0 -0
- {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/licenses/LICENSE +0 -0
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
|
|
@@ -114,22 +114,8 @@ class FileUrl(ABC):
|
|
|
114
114
|
|
|
115
115
|
_: KW_ONLY
|
|
116
116
|
|
|
117
|
-
identifier: str
|
|
118
|
-
"""The identifier of the file, such as a unique ID. generating one from the url if not explicitly set.
|
|
119
|
-
|
|
120
|
-
This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument,
|
|
121
|
-
and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`.
|
|
122
|
-
|
|
123
|
-
This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool.
|
|
124
|
-
If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier,
|
|
125
|
-
e.g. "This is file <identifier>:" preceding the `FileUrl`.
|
|
126
|
-
|
|
127
|
-
It's also included in inline-text delimiters for providers that require inlining text documents, so the model can
|
|
128
|
-
distinguish multiple files.
|
|
129
|
-
"""
|
|
130
|
-
|
|
131
117
|
force_download: bool = False
|
|
132
|
-
"""
|
|
118
|
+
"""For OpenAI and Google APIs it:
|
|
133
119
|
|
|
134
120
|
* If True, the file is downloaded and the data is sent to the model as bytes.
|
|
135
121
|
* If False, the URL is sent directly to the model and no download is performed.
|
|
@@ -147,20 +133,24 @@ class FileUrl(ABC):
|
|
|
147
133
|
compare=False, default=None
|
|
148
134
|
)
|
|
149
135
|
|
|
136
|
+
_identifier: Annotated[str | None, pydantic.Field(alias='identifier', default=None, exclude=True)] = field(
|
|
137
|
+
compare=False, default=None
|
|
138
|
+
)
|
|
139
|
+
|
|
150
140
|
def __init__(
|
|
151
141
|
self,
|
|
152
142
|
url: str,
|
|
153
143
|
*,
|
|
154
|
-
force_download: bool = False,
|
|
155
|
-
vendor_metadata: dict[str, Any] | None = None,
|
|
156
144
|
media_type: str | None = None,
|
|
157
145
|
identifier: str | None = None,
|
|
146
|
+
force_download: bool = False,
|
|
147
|
+
vendor_metadata: dict[str, Any] | None = None,
|
|
158
148
|
) -> None:
|
|
159
149
|
self.url = url
|
|
150
|
+
self._media_type = media_type
|
|
151
|
+
self._identifier = identifier
|
|
160
152
|
self.force_download = force_download
|
|
161
153
|
self.vendor_metadata = vendor_metadata
|
|
162
|
-
self._media_type = media_type
|
|
163
|
-
self.identifier = identifier or _multi_modal_content_identifier(url)
|
|
164
154
|
|
|
165
155
|
@pydantic.computed_field
|
|
166
156
|
@property
|
|
@@ -168,6 +158,23 @@ class FileUrl(ABC):
|
|
|
168
158
|
"""Return the media type of the file, based on the URL or the provided `media_type`."""
|
|
169
159
|
return self._media_type or self._infer_media_type()
|
|
170
160
|
|
|
161
|
+
@pydantic.computed_field
|
|
162
|
+
@property
|
|
163
|
+
def identifier(self) -> str:
|
|
164
|
+
"""The identifier of the file, such as a unique ID.
|
|
165
|
+
|
|
166
|
+
This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument,
|
|
167
|
+
and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`.
|
|
168
|
+
|
|
169
|
+
This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool.
|
|
170
|
+
If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier,
|
|
171
|
+
e.g. "This is file <identifier>:" preceding the `FileUrl`.
|
|
172
|
+
|
|
173
|
+
It's also included in inline-text delimiters for providers that require inlining text documents, so the model can
|
|
174
|
+
distinguish multiple files.
|
|
175
|
+
"""
|
|
176
|
+
return self._identifier or _multi_modal_content_identifier(self.url)
|
|
177
|
+
|
|
171
178
|
@abstractmethod
|
|
172
179
|
def _infer_media_type(self) -> str:
|
|
173
180
|
"""Infer the media type of the file based on the URL."""
|
|
@@ -198,20 +205,21 @@ class VideoUrl(FileUrl):
|
|
|
198
205
|
self,
|
|
199
206
|
url: str,
|
|
200
207
|
*,
|
|
208
|
+
media_type: str | None = None,
|
|
209
|
+
identifier: str | None = None,
|
|
201
210
|
force_download: bool = False,
|
|
202
211
|
vendor_metadata: dict[str, Any] | None = None,
|
|
203
|
-
media_type: str | None = None,
|
|
204
212
|
kind: Literal['video-url'] = 'video-url',
|
|
205
|
-
identifier: str | None = None,
|
|
206
213
|
# Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs.
|
|
207
214
|
_media_type: str | None = None,
|
|
215
|
+
_identifier: str | None = None,
|
|
208
216
|
) -> None:
|
|
209
217
|
super().__init__(
|
|
210
218
|
url=url,
|
|
211
219
|
force_download=force_download,
|
|
212
220
|
vendor_metadata=vendor_metadata,
|
|
213
221
|
media_type=media_type or _media_type,
|
|
214
|
-
identifier=identifier,
|
|
222
|
+
identifier=identifier or _identifier,
|
|
215
223
|
)
|
|
216
224
|
self.kind = kind
|
|
217
225
|
|
|
@@ -273,20 +281,21 @@ class AudioUrl(FileUrl):
|
|
|
273
281
|
self,
|
|
274
282
|
url: str,
|
|
275
283
|
*,
|
|
284
|
+
media_type: str | None = None,
|
|
285
|
+
identifier: str | None = None,
|
|
276
286
|
force_download: bool = False,
|
|
277
287
|
vendor_metadata: dict[str, Any] | None = None,
|
|
278
|
-
media_type: str | None = None,
|
|
279
288
|
kind: Literal['audio-url'] = 'audio-url',
|
|
280
|
-
identifier: str | None = None,
|
|
281
289
|
# Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs.
|
|
282
290
|
_media_type: str | None = None,
|
|
291
|
+
_identifier: str | None = None,
|
|
283
292
|
) -> None:
|
|
284
293
|
super().__init__(
|
|
285
294
|
url=url,
|
|
286
295
|
force_download=force_download,
|
|
287
296
|
vendor_metadata=vendor_metadata,
|
|
288
297
|
media_type=media_type or _media_type,
|
|
289
|
-
identifier=identifier,
|
|
298
|
+
identifier=identifier or _identifier,
|
|
290
299
|
)
|
|
291
300
|
self.kind = kind
|
|
292
301
|
|
|
@@ -335,20 +344,21 @@ class ImageUrl(FileUrl):
|
|
|
335
344
|
self,
|
|
336
345
|
url: str,
|
|
337
346
|
*,
|
|
347
|
+
media_type: str | None = None,
|
|
348
|
+
identifier: str | None = None,
|
|
338
349
|
force_download: bool = False,
|
|
339
350
|
vendor_metadata: dict[str, Any] | None = None,
|
|
340
|
-
media_type: str | None = None,
|
|
341
351
|
kind: Literal['image-url'] = 'image-url',
|
|
342
|
-
identifier: str | None = None,
|
|
343
352
|
# Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs.
|
|
344
353
|
_media_type: str | None = None,
|
|
354
|
+
_identifier: str | None = None,
|
|
345
355
|
) -> None:
|
|
346
356
|
super().__init__(
|
|
347
357
|
url=url,
|
|
348
358
|
force_download=force_download,
|
|
349
359
|
vendor_metadata=vendor_metadata,
|
|
350
360
|
media_type=media_type or _media_type,
|
|
351
|
-
identifier=identifier,
|
|
361
|
+
identifier=identifier or _identifier,
|
|
352
362
|
)
|
|
353
363
|
self.kind = kind
|
|
354
364
|
|
|
@@ -392,20 +402,21 @@ class DocumentUrl(FileUrl):
|
|
|
392
402
|
self,
|
|
393
403
|
url: str,
|
|
394
404
|
*,
|
|
405
|
+
media_type: str | None = None,
|
|
406
|
+
identifier: str | None = None,
|
|
395
407
|
force_download: bool = False,
|
|
396
408
|
vendor_metadata: dict[str, Any] | None = None,
|
|
397
|
-
media_type: str | None = None,
|
|
398
409
|
kind: Literal['document-url'] = 'document-url',
|
|
399
|
-
identifier: str | None = None,
|
|
400
410
|
# Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs.
|
|
401
411
|
_media_type: str | None = None,
|
|
412
|
+
_identifier: str | None = None,
|
|
402
413
|
) -> None:
|
|
403
414
|
super().__init__(
|
|
404
415
|
url=url,
|
|
405
416
|
force_download=force_download,
|
|
406
417
|
vendor_metadata=vendor_metadata,
|
|
407
418
|
media_type=media_type or _media_type,
|
|
408
|
-
identifier=identifier,
|
|
419
|
+
identifier=identifier or _identifier,
|
|
409
420
|
)
|
|
410
421
|
self.kind = kind
|
|
411
422
|
|
|
@@ -460,16 +471,6 @@ class BinaryContent:
|
|
|
460
471
|
media_type: AudioMediaType | ImageMediaType | DocumentMediaType | str
|
|
461
472
|
"""The media type of the binary data."""
|
|
462
473
|
|
|
463
|
-
identifier: str
|
|
464
|
-
"""Identifier for the binary content, such as a unique ID. generating one from the data if not explicitly set
|
|
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
|
-
and the tool can look up the file in question by iterating over the message history and finding the matching `BinaryContent`.
|
|
467
|
-
|
|
468
|
-
This identifier is only automatically passed to the model when the `BinaryContent` is returned by a tool.
|
|
469
|
-
If you're passing the `BinaryContent` as a user message, it's up to you to include a separate text part with the identifier,
|
|
470
|
-
e.g. "This is file <identifier>:" preceding the `BinaryContent`.
|
|
471
|
-
"""
|
|
472
|
-
|
|
473
474
|
vendor_metadata: dict[str, Any] | None = None
|
|
474
475
|
"""Vendor-specific metadata for the file.
|
|
475
476
|
|
|
@@ -478,6 +479,10 @@ class BinaryContent:
|
|
|
478
479
|
- `OpenAIChatModel`, `OpenAIResponsesModel`: `BinaryContent.vendor_metadata['detail']` is used as `detail` setting for images
|
|
479
480
|
"""
|
|
480
481
|
|
|
482
|
+
_identifier: Annotated[str | None, pydantic.Field(alias='identifier', default=None, exclude=True)] = field(
|
|
483
|
+
compare=False, default=None
|
|
484
|
+
)
|
|
485
|
+
|
|
481
486
|
kind: Literal['binary'] = 'binary'
|
|
482
487
|
"""Type identifier, this is available on all parts as a discriminator."""
|
|
483
488
|
|
|
@@ -489,13 +494,59 @@ class BinaryContent:
|
|
|
489
494
|
identifier: str | None = None,
|
|
490
495
|
vendor_metadata: dict[str, Any] | None = None,
|
|
491
496
|
kind: Literal['binary'] = 'binary',
|
|
497
|
+
# Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs.
|
|
498
|
+
_identifier: str | None = None,
|
|
492
499
|
) -> None:
|
|
493
500
|
self.data = data
|
|
494
501
|
self.media_type = media_type
|
|
495
|
-
self.
|
|
502
|
+
self._identifier = identifier or _identifier
|
|
496
503
|
self.vendor_metadata = vendor_metadata
|
|
497
504
|
self.kind = kind
|
|
498
505
|
|
|
506
|
+
@staticmethod
|
|
507
|
+
def narrow_type(bc: BinaryContent) -> BinaryContent | BinaryImage:
|
|
508
|
+
"""Narrow the type of the `BinaryContent` to `BinaryImage` if it's an image."""
|
|
509
|
+
if bc.is_image:
|
|
510
|
+
return BinaryImage(
|
|
511
|
+
data=bc.data,
|
|
512
|
+
media_type=bc.media_type,
|
|
513
|
+
identifier=bc.identifier,
|
|
514
|
+
vendor_metadata=bc.vendor_metadata,
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
return bc # pragma: no cover
|
|
518
|
+
|
|
519
|
+
@classmethod
|
|
520
|
+
def from_data_uri(cls, data_uri: str) -> Self:
|
|
521
|
+
"""Create a `BinaryContent` from a data URI."""
|
|
522
|
+
prefix = 'data:'
|
|
523
|
+
if not data_uri.startswith(prefix):
|
|
524
|
+
raise ValueError('Data URI must start with "data:"') # pragma: no cover
|
|
525
|
+
media_type, data = data_uri[len(prefix) :].split(';base64,', 1)
|
|
526
|
+
return cls(data=base64.b64decode(data), media_type=media_type)
|
|
527
|
+
|
|
528
|
+
@pydantic.computed_field
|
|
529
|
+
@property
|
|
530
|
+
def identifier(self) -> str:
|
|
531
|
+
"""Identifier for the binary content, such as a unique ID.
|
|
532
|
+
|
|
533
|
+
This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument,
|
|
534
|
+
and the tool can look up the file in question by iterating over the message history and finding the matching `BinaryContent`.
|
|
535
|
+
|
|
536
|
+
This identifier is only automatically passed to the model when the `BinaryContent` is returned by a tool.
|
|
537
|
+
If you're passing the `BinaryContent` as a user message, it's up to you to include a separate text part with the identifier,
|
|
538
|
+
e.g. "This is file <identifier>:" preceding the `BinaryContent`.
|
|
539
|
+
|
|
540
|
+
It's also included in inline-text delimiters for providers that require inlining text documents, so the model can
|
|
541
|
+
distinguish multiple files.
|
|
542
|
+
"""
|
|
543
|
+
return self._identifier or _multi_modal_content_identifier(self.data)
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def data_uri(self) -> str:
|
|
547
|
+
"""Convert the `BinaryContent` to a data URI."""
|
|
548
|
+
return f'data:{self.media_type};base64,{base64.b64encode(self.data).decode()}'
|
|
549
|
+
|
|
499
550
|
@property
|
|
500
551
|
def is_audio(self) -> bool:
|
|
501
552
|
"""Return `True` if the media type is an audio type."""
|
|
@@ -534,6 +585,24 @@ class BinaryContent:
|
|
|
534
585
|
__repr__ = _utils.dataclasses_no_defaults_repr
|
|
535
586
|
|
|
536
587
|
|
|
588
|
+
class BinaryImage(BinaryContent):
|
|
589
|
+
"""Binary content that's guaranteed to be an image."""
|
|
590
|
+
|
|
591
|
+
def __init__(
|
|
592
|
+
self,
|
|
593
|
+
data: bytes,
|
|
594
|
+
*,
|
|
595
|
+
media_type: str,
|
|
596
|
+
identifier: str | None = None,
|
|
597
|
+
vendor_metadata: dict[str, Any] | None = None,
|
|
598
|
+
kind: Literal['binary'] = 'binary',
|
|
599
|
+
):
|
|
600
|
+
super().__init__(data=data, media_type=media_type, identifier=identifier, vendor_metadata=vendor_metadata)
|
|
601
|
+
|
|
602
|
+
if not self.is_image:
|
|
603
|
+
raise ValueError('`BinaryImage` must be have a media type that starts with "image/"') # pragma: no cover
|
|
604
|
+
|
|
605
|
+
|
|
537
606
|
MultiModalContent = ImageUrl | AudioUrl | DocumentUrl | VideoUrl | BinaryContent
|
|
538
607
|
UserContent: TypeAlias = str | MultiModalContent
|
|
539
608
|
|
|
@@ -934,6 +1003,32 @@ class ThinkingPart:
|
|
|
934
1003
|
__repr__ = _utils.dataclasses_no_defaults_repr
|
|
935
1004
|
|
|
936
1005
|
|
|
1006
|
+
@dataclass(repr=False)
|
|
1007
|
+
class FilePart:
|
|
1008
|
+
"""A file response from a model."""
|
|
1009
|
+
|
|
1010
|
+
content: Annotated[BinaryContent, pydantic.AfterValidator(BinaryImage.narrow_type)]
|
|
1011
|
+
"""The file content of the response."""
|
|
1012
|
+
|
|
1013
|
+
_: KW_ONLY
|
|
1014
|
+
|
|
1015
|
+
id: str | None = None
|
|
1016
|
+
"""The identifier of the file part."""
|
|
1017
|
+
|
|
1018
|
+
provider_name: str | None = None
|
|
1019
|
+
"""The name of the provider that generated the response.
|
|
1020
|
+
"""
|
|
1021
|
+
|
|
1022
|
+
part_kind: Literal['file'] = 'file'
|
|
1023
|
+
"""Part type identifier, this is available on all parts as a discriminator."""
|
|
1024
|
+
|
|
1025
|
+
def has_content(self) -> bool:
|
|
1026
|
+
"""Return `True` if the file content is non-empty."""
|
|
1027
|
+
return bool(self.content) # pragma: no cover
|
|
1028
|
+
|
|
1029
|
+
__repr__ = _utils.dataclasses_no_defaults_repr
|
|
1030
|
+
|
|
1031
|
+
|
|
937
1032
|
@dataclass(repr=False)
|
|
938
1033
|
class BaseToolCallPart:
|
|
939
1034
|
"""A tool call from a model."""
|
|
@@ -1016,7 +1111,7 @@ class BuiltinToolCallPart(BaseToolCallPart):
|
|
|
1016
1111
|
|
|
1017
1112
|
|
|
1018
1113
|
ModelResponsePart = Annotated[
|
|
1019
|
-
TextPart | ToolCallPart | BuiltinToolCallPart | BuiltinToolReturnPart | ThinkingPart,
|
|
1114
|
+
TextPart | ToolCallPart | BuiltinToolCallPart | BuiltinToolReturnPart | ThinkingPart | FilePart,
|
|
1020
1115
|
pydantic.Discriminator('part_kind'),
|
|
1021
1116
|
]
|
|
1022
1117
|
"""A message part returned by a model."""
|
|
@@ -1073,6 +1168,61 @@ class ModelResponse:
|
|
|
1073
1168
|
finish_reason: FinishReason | None = None
|
|
1074
1169
|
"""Reason the model finished generating the response, normalized to OpenTelemetry values."""
|
|
1075
1170
|
|
|
1171
|
+
@property
|
|
1172
|
+
def text(self) -> str | None:
|
|
1173
|
+
"""Get the text in the response."""
|
|
1174
|
+
texts: list[str] = []
|
|
1175
|
+
last_part: ModelResponsePart | None = None
|
|
1176
|
+
for part in self.parts:
|
|
1177
|
+
if isinstance(part, TextPart):
|
|
1178
|
+
# Adjacent text parts should be joined together, but if there are parts in between
|
|
1179
|
+
# (like built-in tool calls) they should have newlines between them
|
|
1180
|
+
if isinstance(last_part, TextPart):
|
|
1181
|
+
texts[-1] += part.content
|
|
1182
|
+
else:
|
|
1183
|
+
texts.append(part.content)
|
|
1184
|
+
last_part = part
|
|
1185
|
+
if not texts:
|
|
1186
|
+
return None
|
|
1187
|
+
|
|
1188
|
+
return '\n\n'.join(texts)
|
|
1189
|
+
|
|
1190
|
+
@property
|
|
1191
|
+
def thinking(self) -> str | None:
|
|
1192
|
+
"""Get the thinking in the response."""
|
|
1193
|
+
thinking_parts = [part.content for part in self.parts if isinstance(part, ThinkingPart)]
|
|
1194
|
+
if not thinking_parts:
|
|
1195
|
+
return None
|
|
1196
|
+
return '\n\n'.join(thinking_parts)
|
|
1197
|
+
|
|
1198
|
+
@property
|
|
1199
|
+
def files(self) -> list[BinaryContent]:
|
|
1200
|
+
"""Get the files in the response."""
|
|
1201
|
+
return [part.content for part in self.parts if isinstance(part, FilePart)]
|
|
1202
|
+
|
|
1203
|
+
@property
|
|
1204
|
+
def images(self) -> list[BinaryImage]:
|
|
1205
|
+
"""Get the images in the response."""
|
|
1206
|
+
return [file for file in self.files if isinstance(file, BinaryImage)]
|
|
1207
|
+
|
|
1208
|
+
@property
|
|
1209
|
+
def tool_calls(self) -> list[ToolCallPart]:
|
|
1210
|
+
"""Get the tool calls in the response."""
|
|
1211
|
+
return [part for part in self.parts if isinstance(part, ToolCallPart)]
|
|
1212
|
+
|
|
1213
|
+
@property
|
|
1214
|
+
def builtin_tool_calls(self) -> list[tuple[BuiltinToolCallPart, BuiltinToolReturnPart]]:
|
|
1215
|
+
"""Get the builtin tool calls and results in the response."""
|
|
1216
|
+
calls = [part for part in self.parts if isinstance(part, BuiltinToolCallPart)]
|
|
1217
|
+
if not calls:
|
|
1218
|
+
return []
|
|
1219
|
+
returns_by_id = {part.tool_call_id: part for part in self.parts if isinstance(part, BuiltinToolReturnPart)}
|
|
1220
|
+
return [
|
|
1221
|
+
(call_part, returns_by_id[call_part.tool_call_id])
|
|
1222
|
+
for call_part in calls
|
|
1223
|
+
if call_part.tool_call_id in returns_by_id
|
|
1224
|
+
]
|
|
1225
|
+
|
|
1076
1226
|
@deprecated('`price` is deprecated, use `cost` instead')
|
|
1077
1227
|
def price(self) -> genai_types.PriceCalculation: # pragma: no cover
|
|
1078
1228
|
return self.cost()
|
|
@@ -1118,6 +1268,18 @@ class ModelResponse:
|
|
|
1118
1268
|
body.setdefault('content', []).append(
|
|
1119
1269
|
{'kind': kind, **({'text': part.content} if settings.include_content else {})}
|
|
1120
1270
|
)
|
|
1271
|
+
elif isinstance(part, FilePart):
|
|
1272
|
+
body.setdefault('content', []).append(
|
|
1273
|
+
{
|
|
1274
|
+
'kind': 'binary',
|
|
1275
|
+
'media_type': part.content.media_type,
|
|
1276
|
+
**(
|
|
1277
|
+
{'binary_content': base64.b64encode(part.content.data).decode()}
|
|
1278
|
+
if settings.include_content and settings.include_binary_content
|
|
1279
|
+
else {}
|
|
1280
|
+
),
|
|
1281
|
+
}
|
|
1282
|
+
)
|
|
1121
1283
|
|
|
1122
1284
|
if content := body.get('content'):
|
|
1123
1285
|
text_content = content[0].get('text')
|
|
@@ -1143,6 +1305,11 @@ class ModelResponse:
|
|
|
1143
1305
|
**({'content': part.content} if settings.include_content else {}),
|
|
1144
1306
|
)
|
|
1145
1307
|
)
|
|
1308
|
+
elif isinstance(part, FilePart):
|
|
1309
|
+
converted_part = _otel_messages.BinaryDataPart(type='binary', media_type=part.content.media_type)
|
|
1310
|
+
if settings.include_content and settings.include_binary_content:
|
|
1311
|
+
converted_part['content'] = base64.b64encode(part.content.data).decode()
|
|
1312
|
+
parts.append(converted_part)
|
|
1146
1313
|
elif isinstance(part, BaseToolCallPart):
|
|
1147
1314
|
call_part = _otel_messages.ToolCallPart(type='tool_call', id=part.tool_call_id, name=part.tool_name)
|
|
1148
1315
|
if isinstance(part, BuiltinToolCallPart):
|
|
@@ -1511,6 +1678,9 @@ class FunctionToolResultEvent:
|
|
|
1511
1678
|
|
|
1512
1679
|
_: KW_ONLY
|
|
1513
1680
|
|
|
1681
|
+
content: str | Sequence[UserContent] | None = None
|
|
1682
|
+
"""The content that will be sent to the model as a UserPromptPart following the result."""
|
|
1683
|
+
|
|
1514
1684
|
event_kind: Literal['function_tool_result'] = 'function_tool_result'
|
|
1515
1685
|
"""Event type identifier, used as a discriminator."""
|
|
1516
1686
|
|
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,
|
|
@@ -350,7 +351,7 @@ class AnthropicModel(Model):
|
|
|
350
351
|
|
|
351
352
|
return ModelResponse(
|
|
352
353
|
parts=items,
|
|
353
|
-
usage=_map_usage(response),
|
|
354
|
+
usage=_map_usage(response, self._provider.name, self._provider.base_url, self._model_name),
|
|
354
355
|
model_name=response.model,
|
|
355
356
|
provider_response_id=response.id,
|
|
356
357
|
provider_name=self._provider.name,
|
|
@@ -374,6 +375,7 @@ class AnthropicModel(Model):
|
|
|
374
375
|
_response=peekable_response,
|
|
375
376
|
_timestamp=_utils.now_utc(),
|
|
376
377
|
_provider_name=self._provider.name,
|
|
378
|
+
_provider_url=self._provider.base_url,
|
|
377
379
|
)
|
|
378
380
|
|
|
379
381
|
def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[BetaToolUnionParam]:
|
|
@@ -545,6 +547,9 @@ class AnthropicModel(Model):
|
|
|
545
547
|
),
|
|
546
548
|
)
|
|
547
549
|
)
|
|
550
|
+
elif isinstance(response_part, FilePart): # pragma: no cover
|
|
551
|
+
# Files generated by models are not sent back to models that don't themselves generate files.
|
|
552
|
+
pass
|
|
548
553
|
else:
|
|
549
554
|
assert_never(response_part)
|
|
550
555
|
if len(assistant_content_params) > 0:
|
|
@@ -612,7 +617,13 @@ class AnthropicModel(Model):
|
|
|
612
617
|
}
|
|
613
618
|
|
|
614
619
|
|
|
615
|
-
def _map_usage(
|
|
620
|
+
def _map_usage(
|
|
621
|
+
message: BetaMessage | BetaRawMessageStartEvent | BetaRawMessageDeltaEvent,
|
|
622
|
+
provider: str,
|
|
623
|
+
provider_url: str,
|
|
624
|
+
model: str,
|
|
625
|
+
existing_usage: usage.RequestUsage | None = None,
|
|
626
|
+
) -> usage.RequestUsage:
|
|
616
627
|
if isinstance(message, BetaMessage):
|
|
617
628
|
response_usage = message.usage
|
|
618
629
|
elif isinstance(message, BetaRawMessageStartEvent):
|
|
@@ -622,24 +633,17 @@ def _map_usage(message: BetaMessage | BetaRawMessageStartEvent | BetaRawMessageD
|
|
|
622
633
|
else:
|
|
623
634
|
assert_never(message)
|
|
624
635
|
|
|
625
|
-
#
|
|
626
|
-
#
|
|
627
|
-
details: dict[str, int] = {
|
|
636
|
+
# In streaming, usage appears in different events.
|
|
637
|
+
# The values are cumulative, meaning new values should replace existing ones entirely.
|
|
638
|
+
details: dict[str, int] = (existing_usage.details if existing_usage else {}) | {
|
|
628
639
|
key: value for key, value in response_usage.model_dump().items() if isinstance(value, int)
|
|
629
640
|
}
|
|
630
641
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
request_tokens = details.get('input_tokens', 0) + cache_write_tokens + cache_read_tokens
|
|
637
|
-
|
|
638
|
-
return usage.RequestUsage(
|
|
639
|
-
input_tokens=request_tokens,
|
|
640
|
-
cache_read_tokens=cache_read_tokens,
|
|
641
|
-
cache_write_tokens=cache_write_tokens,
|
|
642
|
-
output_tokens=response_usage.output_tokens,
|
|
642
|
+
return usage.RequestUsage.extract(
|
|
643
|
+
dict(model=model, usage=details),
|
|
644
|
+
provider=provider,
|
|
645
|
+
provider_url=provider_url,
|
|
646
|
+
provider_fallback='anthropic',
|
|
643
647
|
details=details,
|
|
644
648
|
)
|
|
645
649
|
|
|
@@ -652,13 +656,14 @@ class AnthropicStreamedResponse(StreamedResponse):
|
|
|
652
656
|
_response: AsyncIterable[BetaRawMessageStreamEvent]
|
|
653
657
|
_timestamp: datetime
|
|
654
658
|
_provider_name: str
|
|
659
|
+
_provider_url: str
|
|
655
660
|
|
|
656
661
|
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: # noqa: C901
|
|
657
662
|
current_block: BetaContentBlock | None = None
|
|
658
663
|
|
|
659
664
|
async for event in self._response:
|
|
660
665
|
if isinstance(event, BetaRawMessageStartEvent):
|
|
661
|
-
self._usage = _map_usage(event)
|
|
666
|
+
self._usage = _map_usage(event, self._provider_name, self._provider_url, self._model_name)
|
|
662
667
|
self.provider_response_id = event.message.id
|
|
663
668
|
|
|
664
669
|
elif isinstance(event, BetaRawContentBlockStartEvent):
|
|
@@ -693,17 +698,17 @@ class AnthropicStreamedResponse(StreamedResponse):
|
|
|
693
698
|
if maybe_event is not None: # pragma: no branch
|
|
694
699
|
yield maybe_event
|
|
695
700
|
elif isinstance(current_block, BetaServerToolUseBlock):
|
|
696
|
-
yield self._parts_manager.
|
|
701
|
+
yield self._parts_manager.handle_part(
|
|
697
702
|
vendor_part_id=event.index,
|
|
698
703
|
part=_map_server_tool_use_block(current_block, self.provider_name),
|
|
699
704
|
)
|
|
700
705
|
elif isinstance(current_block, BetaWebSearchToolResultBlock):
|
|
701
|
-
yield self._parts_manager.
|
|
706
|
+
yield self._parts_manager.handle_part(
|
|
702
707
|
vendor_part_id=event.index,
|
|
703
708
|
part=_map_web_search_tool_result_block(current_block, self.provider_name),
|
|
704
709
|
)
|
|
705
710
|
elif isinstance(current_block, BetaCodeExecutionToolResultBlock):
|
|
706
|
-
yield self._parts_manager.
|
|
711
|
+
yield self._parts_manager.handle_part(
|
|
707
712
|
vendor_part_id=event.index,
|
|
708
713
|
part=_map_code_execution_tool_result_block(current_block, self.provider_name),
|
|
709
714
|
)
|
|
@@ -739,7 +744,7 @@ class AnthropicStreamedResponse(StreamedResponse):
|
|
|
739
744
|
pass
|
|
740
745
|
|
|
741
746
|
elif isinstance(event, BetaRawMessageDeltaEvent):
|
|
742
|
-
self._usage = _map_usage(event)
|
|
747
|
+
self._usage = _map_usage(event, self._provider_name, self._provider_url, self._model_name, self._usage)
|
|
743
748
|
if raw_finish_reason := event.delta.stop_reason: # pragma: no branch
|
|
744
749
|
self.provider_details = {'finish_reason': raw_finish_reason}
|
|
745
750
|
self.finish_reason = _FINISH_REASON_MAP.get(raw_finish_reason)
|
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:
|