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.

Files changed (40) hide show
  1. pydantic_ai/__init__.py +19 -1
  2. pydantic_ai/_agent_graph.py +129 -105
  3. pydantic_ai/_cli.py +7 -10
  4. pydantic_ai/_output.py +236 -192
  5. pydantic_ai/_parts_manager.py +8 -42
  6. pydantic_ai/_tool_manager.py +9 -16
  7. pydantic_ai/agent/__init__.py +18 -7
  8. pydantic_ai/agent/abstract.py +192 -23
  9. pydantic_ai/agent/wrapper.py +7 -4
  10. pydantic_ai/builtin_tools.py +82 -0
  11. pydantic_ai/direct.py +16 -9
  12. pydantic_ai/durable_exec/dbos/_agent.py +124 -18
  13. pydantic_ai/durable_exec/temporal/_agent.py +139 -19
  14. pydantic_ai/durable_exec/temporal/_model.py +8 -0
  15. pydantic_ai/format_prompt.py +9 -6
  16. pydantic_ai/mcp.py +20 -10
  17. pydantic_ai/messages.py +214 -44
  18. pydantic_ai/models/__init__.py +15 -1
  19. pydantic_ai/models/anthropic.py +27 -22
  20. pydantic_ai/models/cohere.py +4 -0
  21. pydantic_ai/models/function.py +7 -4
  22. pydantic_ai/models/gemini.py +8 -0
  23. pydantic_ai/models/google.py +56 -23
  24. pydantic_ai/models/groq.py +11 -5
  25. pydantic_ai/models/huggingface.py +5 -3
  26. pydantic_ai/models/mistral.py +6 -8
  27. pydantic_ai/models/openai.py +206 -58
  28. pydantic_ai/models/test.py +4 -0
  29. pydantic_ai/output.py +5 -2
  30. pydantic_ai/profiles/__init__.py +2 -0
  31. pydantic_ai/profiles/google.py +5 -2
  32. pydantic_ai/profiles/openai.py +2 -1
  33. pydantic_ai/result.py +51 -35
  34. pydantic_ai/run.py +35 -7
  35. pydantic_ai/usage.py +40 -5
  36. {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/METADATA +4 -4
  37. {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/RECORD +40 -40
  38. {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/WHEEL +0 -0
  39. {pydantic_ai_slim-1.0.14.dist-info → pydantic_ai_slim-1.0.16.dist-info}/entry_points.txt +0 -0
  40. {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
- """If the model supports it:
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.identifier = identifier or _multi_modal_content_identifier(data)
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
 
@@ -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: # pragma: no branch
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':
@@ -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(message: BetaMessage | BetaRawMessageStartEvent | BetaRawMessageDeltaEvent) -> usage.RequestUsage:
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
- # Store all integer-typed usage values in the details, except 'output_tokens' which is represented exactly by
626
- # `response_tokens`
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
- # Usage coming from the RawMessageDeltaEvent doesn't have input token data, hence using `get`
632
- # Tokens are only counted once between input_tokens, cache_creation_input_tokens, and cache_read_input_tokens
633
- # This approach maintains request_tokens as the count of all input tokens, with cached counts as details
634
- cache_write_tokens = details.get('cache_creation_input_tokens', 0)
635
- cache_read_tokens = details.get('cache_read_input_tokens', 0)
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.handle_builtin_tool_call_part(
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.handle_builtin_tool_return_part(
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.handle_builtin_tool_return_part(
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)
@@ -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
 
@@ -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.handle_builtin_tool_call_part(vendor_part_id=dtc_index, part=delta)
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.handle_builtin_tool_return_part(vendor_part_id=dtc_index, part=delta)
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): # pragma: no cover
375
+ elif isinstance(part, BuiltinToolCallPart):
375
376
  response_tokens += 1 + _estimate_string_tokens(part.args_as_json_str())
376
- elif isinstance(part, BuiltinToolReturnPart): # pragma: no cover
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: