pydantic-ai-slim 0.2.16__tar.gz → 0.2.17__tar.gz

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 (76) hide show
  1. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/PKG-INFO +5 -5
  2. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/mcp.py +57 -15
  3. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/messages.py +43 -13
  4. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/__init__.py +89 -2
  5. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/anthropic.py +3 -11
  6. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/bedrock.py +23 -15
  7. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/gemini.py +15 -13
  8. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/google.py +11 -11
  9. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/instrumented.py +98 -32
  10. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/openai.py +49 -35
  11. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/openai.py +9 -2
  12. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pyproject.toml +1 -1
  13. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/.gitignore +0 -0
  14. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/LICENSE +0 -0
  15. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/README.md +0 -0
  16. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/__init__.py +0 -0
  17. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/__main__.py +0 -0
  18. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_a2a.py +0 -0
  19. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_agent_graph.py +0 -0
  20. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_cli.py +0 -0
  21. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_function_schema.py +0 -0
  22. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_griffe.py +0 -0
  23. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_output.py +0 -0
  24. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_parts_manager.py +0 -0
  25. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_system_prompt.py +0 -0
  26. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_utils.py +0 -0
  27. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/agent.py +0 -0
  28. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/common_tools/__init__.py +0 -0
  29. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/common_tools/duckduckgo.py +0 -0
  30. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/common_tools/tavily.py +0 -0
  31. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/direct.py +0 -0
  32. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/exceptions.py +0 -0
  33. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/ext/__init__.py +0 -0
  34. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/ext/langchain.py +0 -0
  35. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/format_as_xml.py +0 -0
  36. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/format_prompt.py +0 -0
  37. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/cohere.py +0 -0
  38. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/fallback.py +0 -0
  39. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/function.py +0 -0
  40. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/groq.py +0 -0
  41. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/mistral.py +0 -0
  42. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/test.py +0 -0
  43. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/wrapper.py +0 -0
  44. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/__init__.py +0 -0
  45. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/_json_schema.py +0 -0
  46. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/amazon.py +0 -0
  47. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/anthropic.py +0 -0
  48. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/cohere.py +0 -0
  49. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/deepseek.py +0 -0
  50. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/google.py +0 -0
  51. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/grok.py +0 -0
  52. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/meta.py +0 -0
  53. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/mistral.py +0 -0
  54. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/qwen.py +0 -0
  55. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/__init__.py +0 -0
  56. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/anthropic.py +0 -0
  57. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/azure.py +0 -0
  58. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/bedrock.py +0 -0
  59. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/cohere.py +0 -0
  60. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/deepseek.py +0 -0
  61. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/fireworks.py +0 -0
  62. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/google.py +0 -0
  63. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/google_gla.py +0 -0
  64. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/google_vertex.py +0 -0
  65. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/grok.py +0 -0
  66. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/groq.py +0 -0
  67. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/heroku.py +0 -0
  68. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/mistral.py +0 -0
  69. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/openai.py +0 -0
  70. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/openrouter.py +0 -0
  71. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/together.py +0 -0
  72. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/py.typed +0 -0
  73. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/result.py +0 -0
  74. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/settings.py +0 -0
  75. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/tools.py +0 -0
  76. {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydantic-ai-slim
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: Agent Framework / shim to use Pydantic with LLMs, slim package
5
5
  Author-email: Samuel Colvin <samuel@pydantic.dev>, Marcelo Trylesinski <marcelotryle@gmail.com>, David Montague <david@pydantic.dev>, Alex Hall <alex@pydantic.dev>
6
6
  License-Expression: MIT
@@ -30,11 +30,11 @@ Requires-Dist: exceptiongroup; python_version < '3.11'
30
30
  Requires-Dist: griffe>=1.3.2
31
31
  Requires-Dist: httpx>=0.27
32
32
  Requires-Dist: opentelemetry-api>=1.28.0
33
- Requires-Dist: pydantic-graph==0.2.16
33
+ Requires-Dist: pydantic-graph==0.2.17
34
34
  Requires-Dist: pydantic>=2.10
35
35
  Requires-Dist: typing-inspection>=0.4.0
36
36
  Provides-Extra: a2a
37
- Requires-Dist: fasta2a==0.2.16; extra == 'a2a'
37
+ Requires-Dist: fasta2a==0.2.17; extra == 'a2a'
38
38
  Provides-Extra: anthropic
39
39
  Requires-Dist: anthropic>=0.52.0; extra == 'anthropic'
40
40
  Provides-Extra: bedrock
@@ -48,7 +48,7 @@ Requires-Dist: cohere>=5.13.11; (platform_system != 'Emscripten') and extra == '
48
48
  Provides-Extra: duckduckgo
49
49
  Requires-Dist: duckduckgo-search>=7.0.0; extra == 'duckduckgo'
50
50
  Provides-Extra: evals
51
- Requires-Dist: pydantic-evals==0.2.16; extra == 'evals'
51
+ Requires-Dist: pydantic-evals==0.2.17; extra == 'evals'
52
52
  Provides-Extra: google
53
53
  Requires-Dist: google-genai>=1.15.0; extra == 'google'
54
54
  Provides-Extra: groq
@@ -56,7 +56,7 @@ Requires-Dist: groq>=0.15.0; extra == 'groq'
56
56
  Provides-Extra: logfire
57
57
  Requires-Dist: logfire>=3.11.0; extra == 'logfire'
58
58
  Provides-Extra: mcp
59
- Requires-Dist: mcp>=1.9.0; (python_version >= '3.10') and extra == 'mcp'
59
+ Requires-Dist: mcp>=1.9.2; (python_version >= '3.10') and extra == 'mcp'
60
60
  Provides-Extra: mistral
61
61
  Requires-Dist: mistralai>=1.2.5; extra == 'mistral'
62
62
  Provides-Extra: openai
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import base64
4
+ import functools
4
5
  import json
5
6
  from abc import ABC, abstractmethod
6
7
  from collections.abc import AsyncIterator, Sequence
@@ -11,12 +12,13 @@ from types import TracebackType
11
12
  from typing import Any
12
13
 
13
14
  import anyio
15
+ import httpx
14
16
  from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
17
+ from mcp.shared.message import SessionMessage
15
18
  from mcp.types import (
16
19
  BlobResourceContents,
17
20
  EmbeddedResource,
18
21
  ImageContent,
19
- JSONRPCMessage,
20
22
  LoggingLevel,
21
23
  TextContent,
22
24
  TextResourceContents,
@@ -56,8 +58,8 @@ class MCPServer(ABC):
56
58
  """
57
59
 
58
60
  _client: ClientSession
59
- _read_stream: MemoryObjectReceiveStream[JSONRPCMessage | Exception]
60
- _write_stream: MemoryObjectSendStream[JSONRPCMessage]
61
+ _read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
62
+ _write_stream: MemoryObjectSendStream[SessionMessage]
61
63
  _exit_stack: AsyncExitStack
62
64
 
63
65
  @abstractmethod
@@ -66,8 +68,8 @@ class MCPServer(ABC):
66
68
  self,
67
69
  ) -> AsyncIterator[
68
70
  tuple[
69
- MemoryObjectReceiveStream[JSONRPCMessage | Exception],
70
- MemoryObjectSendStream[JSONRPCMessage],
71
+ MemoryObjectReceiveStream[SessionMessage | Exception],
72
+ MemoryObjectSendStream[SessionMessage],
71
73
  ]
72
74
  ]:
73
75
  """Create the streams for the MCP server."""
@@ -266,8 +268,8 @@ class MCPServerStdio(MCPServer):
266
268
  self,
267
269
  ) -> AsyncIterator[
268
270
  tuple[
269
- MemoryObjectReceiveStream[JSONRPCMessage | Exception],
270
- MemoryObjectSendStream[JSONRPCMessage],
271
+ MemoryObjectReceiveStream[SessionMessage | Exception],
272
+ MemoryObjectSendStream[SessionMessage],
271
273
  ]
272
274
  ]:
273
275
  server = StdioServerParameters(command=self.command, args=list(self.args), env=self.env, cwd=self.cwd)
@@ -326,6 +328,31 @@ class MCPServerHTTP(MCPServer):
326
328
 
327
329
  These headers will be passed directly to the underlying `httpx.AsyncClient`.
328
330
  Useful for authentication, custom headers, or other HTTP-specific configurations.
331
+
332
+ !!! note
333
+ You can either pass `headers` or `http_client`, but not both.
334
+
335
+ See [`MCPServerHTTP.http_client`][pydantic_ai.mcp.MCPServerHTTP.http_client] for more information.
336
+ """
337
+
338
+ http_client: httpx.AsyncClient | None = None
339
+ """An `httpx.AsyncClient` to use with the SSE endpoint.
340
+
341
+ This client may be configured to use customized connection parameters like self-signed certificates.
342
+
343
+ !!! note
344
+ You can either pass `headers` or `http_client`, but not both.
345
+
346
+ If you want to use both, you can pass the headers to the `http_client` instead:
347
+
348
+ ```python {py="3.10"}
349
+ import httpx
350
+
351
+ from pydantic_ai.mcp import MCPServerHTTP
352
+
353
+ http_client = httpx.AsyncClient(headers={'Authorization': 'Bearer ...'})
354
+ server = MCPServerHTTP('http://localhost:3001/sse', http_client=http_client)
355
+ ```
329
356
  """
330
357
 
331
358
  timeout: float = 5
@@ -362,18 +389,33 @@ class MCPServerHTTP(MCPServer):
362
389
  async def client_streams(
363
390
  self,
364
391
  ) -> AsyncIterator[
365
- tuple[
366
- MemoryObjectReceiveStream[JSONRPCMessage | Exception],
367
- MemoryObjectSendStream[JSONRPCMessage],
368
- ]
392
+ tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
369
393
  ]: # pragma: no cover
370
- async with sse_client(
394
+ if self.http_client and self.headers:
395
+ raise ValueError('`http_client` is mutually exclusive with `headers`.')
396
+
397
+ sse_client_partial = functools.partial(
398
+ sse_client,
371
399
  url=self.url,
372
- headers=self.headers,
373
400
  timeout=self.timeout,
374
401
  sse_read_timeout=self.sse_read_timeout,
375
- ) as (read_stream, write_stream):
376
- yield read_stream, write_stream
402
+ )
403
+
404
+ if self.http_client is not None:
405
+
406
+ def httpx_client_factory(
407
+ headers: dict[str, str] | None = None,
408
+ timeout: httpx.Timeout | None = None,
409
+ auth: httpx.Auth | None = None,
410
+ ) -> httpx.AsyncClient:
411
+ assert self.http_client is not None
412
+ return self.http_client
413
+
414
+ async with sse_client_partial(httpx_client_factory=httpx_client_factory) as (read_stream, write_stream):
415
+ yield read_stream, write_stream
416
+ else:
417
+ async with sse_client_partial(headers=self.headers) as (read_stream, write_stream):
418
+ yield read_stream, write_stream
377
419
 
378
420
  def _get_log_level(self) -> LoggingLevel | None:
379
421
  return self.log_level
@@ -2,6 +2,7 @@ from __future__ import annotations as _annotations
2
2
 
3
3
  import base64
4
4
  import uuid
5
+ from abc import ABC, abstractmethod
5
6
  from collections.abc import Sequence
6
7
  from dataclasses import dataclass, field, replace
7
8
  from datetime import datetime
@@ -80,8 +81,35 @@ class SystemPromptPart:
80
81
 
81
82
 
82
83
  @dataclass(repr=False)
83
- class VideoUrl:
84
- """A URL to an video."""
84
+ class FileUrl(ABC):
85
+ """Abstract base class for any URL-based file."""
86
+
87
+ url: str
88
+ """The URL of the file."""
89
+
90
+ force_download: bool = False
91
+ """If the model supports it:
92
+
93
+ * If True, the file is downloaded and the data is sent to the model as bytes.
94
+ * If False, the URL is sent directly to the model and no download is performed.
95
+ """
96
+
97
+ @property
98
+ @abstractmethod
99
+ def media_type(self) -> str:
100
+ """Return the media type of the file, based on the url."""
101
+
102
+ @property
103
+ @abstractmethod
104
+ def format(self) -> str:
105
+ """The file format."""
106
+
107
+ __repr__ = _utils.dataclasses_no_defaults_repr
108
+
109
+
110
+ @dataclass(repr=False)
111
+ class VideoUrl(FileUrl):
112
+ """A URL to a video."""
85
113
 
86
114
  url: str
87
115
  """The URL of the video."""
@@ -108,9 +136,19 @@ class VideoUrl:
108
136
  return 'video/x-ms-wmv'
109
137
  elif self.url.endswith('.three_gp'):
110
138
  return 'video/3gpp'
139
+ # Assume that YouTube videos are mp4 because there would be no extension
140
+ # to infer from. This should not be a problem, as Gemini disregards media
141
+ # type for YouTube URLs.
142
+ elif self.is_youtube:
143
+ return 'video/mp4'
111
144
  else:
112
145
  raise ValueError(f'Unknown video file extension: {self.url}')
113
146
 
147
+ @property
148
+ def is_youtube(self) -> bool:
149
+ """True if the URL has a YouTube domain."""
150
+ return self.url.startswith(('https://youtu.be/', 'https://youtube.com/', 'https://www.youtube.com/'))
151
+
114
152
  @property
115
153
  def format(self) -> VideoFormat:
116
154
  """The file format of the video.
@@ -119,11 +157,9 @@ class VideoUrl:
119
157
  """
120
158
  return _video_format_lookup[self.media_type]
121
159
 
122
- __repr__ = _utils.dataclasses_no_defaults_repr
123
-
124
160
 
125
161
  @dataclass(repr=False)
126
- class AudioUrl:
162
+ class AudioUrl(FileUrl):
127
163
  """A URL to an audio file."""
128
164
 
129
165
  url: str
@@ -147,11 +183,9 @@ class AudioUrl:
147
183
  """The file format of the audio file."""
148
184
  return _audio_format_lookup[self.media_type]
149
185
 
150
- __repr__ = _utils.dataclasses_no_defaults_repr
151
-
152
186
 
153
187
  @dataclass(repr=False)
154
- class ImageUrl:
188
+ class ImageUrl(FileUrl):
155
189
  """A URL to an image."""
156
190
 
157
191
  url: str
@@ -182,11 +216,9 @@ class ImageUrl:
182
216
  """
183
217
  return _image_format_lookup[self.media_type]
184
218
 
185
- __repr__ = _utils.dataclasses_no_defaults_repr
186
-
187
219
 
188
220
  @dataclass(repr=False)
189
- class DocumentUrl:
221
+ class DocumentUrl(FileUrl):
190
222
  """The URL of the document."""
191
223
 
192
224
  url: str
@@ -215,8 +247,6 @@ class DocumentUrl:
215
247
  except KeyError as e:
216
248
  raise ValueError(f'Unknown document media type: {media_type}') from e
217
249
 
218
- __repr__ = _utils.dataclasses_no_defaults_repr
219
-
220
250
 
221
251
  @dataclass(repr=False)
222
252
  class BinaryContent:
@@ -6,21 +6,23 @@ specific LLM being used.
6
6
 
7
7
  from __future__ import annotations as _annotations
8
8
 
9
+ import base64
9
10
  from abc import ABC, abstractmethod
10
11
  from collections.abc import AsyncIterator, Iterator
11
12
  from contextlib import asynccontextmanager, contextmanager
12
13
  from dataclasses import dataclass, field, replace
13
14
  from datetime import datetime
14
15
  from functools import cache, cached_property
16
+ from typing import Generic, TypeVar, overload
15
17
 
16
18
  import httpx
17
- from typing_extensions import Literal, TypeAliasType
19
+ from typing_extensions import Literal, TypeAliasType, TypedDict
18
20
 
19
21
  from pydantic_ai.profiles import DEFAULT_PROFILE, ModelProfile, ModelProfileSpec
20
22
 
21
23
  from .._parts_manager import ModelResponsePartsManager
22
24
  from ..exceptions import UserError
23
- from ..messages import ModelMessage, ModelRequest, ModelResponse, ModelResponseStreamEvent
25
+ from ..messages import FileUrl, ModelMessage, ModelRequest, ModelResponse, ModelResponseStreamEvent, VideoUrl
24
26
  from ..profiles._json_schema import JsonSchemaTransformer
25
27
  from ..settings import ModelSettings
26
28
  from ..tools import ToolDefinition
@@ -611,6 +613,91 @@ def _cached_async_http_transport() -> httpx.AsyncHTTPTransport:
611
613
  return httpx.AsyncHTTPTransport()
612
614
 
613
615
 
616
+ DataT = TypeVar('DataT', str, bytes)
617
+
618
+
619
+ class DownloadedItem(TypedDict, Generic[DataT]):
620
+ """The downloaded data and its type."""
621
+
622
+ data: DataT
623
+ """The downloaded data."""
624
+
625
+ data_type: str
626
+ """The type of data that was downloaded.
627
+
628
+ Extracted from header "content-type", but defaults to the media type inferred from the file URL if content-type is "application/octet-stream".
629
+ """
630
+
631
+
632
+ @overload
633
+ async def download_item(
634
+ item: FileUrl,
635
+ data_format: Literal['bytes'],
636
+ type_format: Literal['mime', 'extension'] = 'mime',
637
+ ) -> DownloadedItem[bytes]: ...
638
+
639
+
640
+ @overload
641
+ async def download_item(
642
+ item: FileUrl,
643
+ data_format: Literal['base64', 'base64_uri', 'text'],
644
+ type_format: Literal['mime', 'extension'] = 'mime',
645
+ ) -> DownloadedItem[str]: ...
646
+
647
+
648
+ async def download_item(
649
+ item: FileUrl,
650
+ data_format: Literal['bytes', 'base64', 'base64_uri', 'text'] = 'bytes',
651
+ type_format: Literal['mime', 'extension'] = 'mime',
652
+ ) -> DownloadedItem[str] | DownloadedItem[bytes]:
653
+ """Download an item by URL and return the content as a bytes object or a (base64-encoded) string.
654
+
655
+ Args:
656
+ item: The item to download.
657
+ data_format: The format to return the content in:
658
+ - `bytes`: The raw bytes of the content.
659
+ - `base64`: The base64-encoded content.
660
+ - `base64_uri`: The base64-encoded content as a data URI.
661
+ - `text`: The content as a string.
662
+ type_format: The format to return the media type in:
663
+ - `mime`: The media type as a MIME type.
664
+ - `extension`: The media type as an extension.
665
+
666
+ Raises:
667
+ UserError: If the URL points to a YouTube video or its protocol is gs://.
668
+ """
669
+ if item.url.startswith('gs://'):
670
+ raise UserError('Downloading from protocol "gs://" is not supported.')
671
+ elif isinstance(item, VideoUrl) and item.is_youtube:
672
+ raise UserError('Downloading YouTube videos is not supported.')
673
+
674
+ client = cached_async_http_client()
675
+ response = await client.get(item.url, follow_redirects=True)
676
+ response.raise_for_status()
677
+
678
+ if content_type := response.headers.get('content-type'):
679
+ content_type = content_type.split(';')[0]
680
+ if content_type == 'application/octet-stream':
681
+ content_type = None
682
+
683
+ media_type = content_type or item.media_type
684
+
685
+ data_type = media_type
686
+ if type_format == 'extension':
687
+ data_type = data_type.split('/')[1]
688
+
689
+ data = response.content
690
+ if data_format in ('base64', 'base64_uri'):
691
+ data = base64.b64encode(data).decode('utf-8')
692
+ if data_format == 'base64_uri':
693
+ data = f'data:{media_type};base64,{data}'
694
+ return DownloadedItem[str](data=data, data_type=data_type)
695
+ elif data_format == 'text':
696
+ return DownloadedItem[str](data=data.decode('utf-8'), data_type=data_type)
697
+ else:
698
+ return DownloadedItem[bytes](data=data, data_type=data_type)
699
+
700
+
614
701
  @cache
615
702
  def get_user_agent() -> str:
616
703
  """Get the user agent string for the HTTP client."""
@@ -31,14 +31,7 @@ from ..profiles import ModelProfileSpec
31
31
  from ..providers import Provider, infer_provider
32
32
  from ..settings import ModelSettings
33
33
  from ..tools import ToolDefinition
34
- from . import (
35
- Model,
36
- ModelRequestParameters,
37
- StreamedResponse,
38
- cached_async_http_client,
39
- check_allow_model_requests,
40
- get_user_agent,
41
- )
34
+ from . import Model, ModelRequestParameters, StreamedResponse, check_allow_model_requests, download_item, get_user_agent
42
35
 
43
36
  try:
44
37
  from anthropic import NOT_GIVEN, APIStatusError, AsyncAnthropic, AsyncStream
@@ -372,11 +365,10 @@ class AnthropicModel(Model):
372
365
  if item.media_type == 'application/pdf':
373
366
  yield BetaBase64PDFBlockParam(source={'url': item.url, 'type': 'url'}, type='document')
374
367
  elif item.media_type == 'text/plain':
375
- response = await cached_async_http_client().get(item.url)
376
- response.raise_for_status()
368
+ downloaded_item = await download_item(item, data_format='text')
377
369
  yield BetaBase64PDFBlockParam(
378
370
  source=BetaPlainTextSourceParam(
379
- data=response.text, media_type=item.media_type, type='text'
371
+ data=downloaded_item['data'], media_type=item.media_type, type='text'
380
372
  ),
381
373
  type='document',
382
374
  )
@@ -32,12 +32,7 @@ from pydantic_ai.messages import (
32
32
  UserPromptPart,
33
33
  VideoUrl,
34
34
  )
35
- from pydantic_ai.models import (
36
- Model,
37
- ModelRequestParameters,
38
- StreamedResponse,
39
- cached_async_http_client,
40
- )
35
+ from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, download_item
41
36
  from pydantic_ai.profiles import ModelProfileSpec
42
37
  from pydantic_ai.providers import Provider, infer_provider
43
38
  from pydantic_ai.providers.bedrock import BedrockModelProfile
@@ -55,6 +50,7 @@ if TYPE_CHECKING:
55
50
  ConverseResponseTypeDef,
56
51
  ConverseStreamMetadataEventTypeDef,
57
52
  ConverseStreamOutputTypeDef,
53
+ DocumentBlockTypeDef,
58
54
  GuardrailConfigurationTypeDef,
59
55
  ImageBlockTypeDef,
60
56
  InferenceConfigurationTypeDef,
@@ -507,25 +503,37 @@ class BedrockConverseModel(Model):
507
503
  else:
508
504
  raise NotImplementedError('Binary content is not supported yet.')
509
505
  elif isinstance(item, (ImageUrl, DocumentUrl, VideoUrl)):
510
- response = await cached_async_http_client().get(item.url)
511
- response.raise_for_status()
506
+ downloaded_item = await download_item(item, data_format='bytes', type_format='extension')
507
+ format = downloaded_item['data_type']
512
508
  if item.kind == 'image-url':
513
509
  format = item.media_type.split('/')[1]
514
510
  assert format in ('jpeg', 'png', 'gif', 'webp'), f'Unsupported image format: {format}'
515
- image: ImageBlockTypeDef = {'format': format, 'source': {'bytes': response.content}}
511
+ image: ImageBlockTypeDef = {'format': format, 'source': {'bytes': downloaded_item['data']}}
516
512
  content.append({'image': image})
517
513
 
518
514
  elif item.kind == 'document-url':
519
515
  name = f'Document {next(document_count)}'
520
- data = response.content
521
- content.append({'document': {'name': name, 'format': item.format, 'source': {'bytes': data}}})
516
+ document: DocumentBlockTypeDef = {
517
+ 'name': name,
518
+ 'format': item.format,
519
+ 'source': {'bytes': downloaded_item['data']},
520
+ }
521
+ content.append({'document': document})
522
522
 
523
523
  elif item.kind == 'video-url': # pragma: no branch
524
524
  format = item.media_type.split('/')[1]
525
- assert format in ('mkv', 'mov', 'mp4', 'webm', 'flv', 'mpeg', 'mpg', 'wmv', 'three_gp'), (
526
- f'Unsupported video format: {format}'
527
- )
528
- video: VideoBlockTypeDef = {'format': format, 'source': {'bytes': response.content}}
525
+ assert format in (
526
+ 'mkv',
527
+ 'mov',
528
+ 'mp4',
529
+ 'webm',
530
+ 'flv',
531
+ 'mpeg',
532
+ 'mpg',
533
+ 'wmv',
534
+ 'three_gp',
535
+ ), f'Unsupported video format: {format}'
536
+ video: VideoBlockTypeDef = {'format': format, 'source': {'bytes': downloaded_item['data']}}
529
537
  content.append({'video': video})
530
538
  elif isinstance(item, AudioUrl): # pragma: no cover
531
539
  raise NotImplementedError('Audio is not supported yet.')
@@ -17,10 +17,8 @@ from pydantic_ai.providers import Provider, infer_provider
17
17
 
18
18
  from .. import ModelHTTPError, UnexpectedModelBehavior, _utils, usage
19
19
  from ..messages import (
20
- AudioUrl,
21
20
  BinaryContent,
22
- DocumentUrl,
23
- ImageUrl,
21
+ FileUrl,
24
22
  ModelMessage,
25
23
  ModelRequest,
26
24
  ModelResponse,
@@ -41,8 +39,8 @@ from . import (
41
39
  Model,
42
40
  ModelRequestParameters,
43
41
  StreamedResponse,
44
- cached_async_http_client,
45
42
  check_allow_model_requests,
43
+ download_item,
46
44
  get_user_agent,
47
45
  )
48
46
 
@@ -348,15 +346,19 @@ class GeminiModel(Model):
348
346
  content.append(
349
347
  _GeminiInlineDataPart(inline_data={'data': base64_encoded, 'mime_type': item.media_type})
350
348
  )
351
- elif isinstance(item, (AudioUrl, ImageUrl, DocumentUrl, VideoUrl)):
352
- client = cached_async_http_client()
353
- response = await client.get(item.url, follow_redirects=True)
354
- response.raise_for_status()
355
- mime_type = response.headers['Content-Type'].split(';')[0]
356
- inline_data = _GeminiInlineDataPart(
357
- inline_data={'data': base64.b64encode(response.content).decode('utf-8'), 'mime_type': mime_type}
358
- )
359
- content.append(inline_data)
349
+ elif isinstance(item, VideoUrl) and item.is_youtube:
350
+ file_data = _GeminiFileDataPart(file_data={'file_uri': item.url, 'mime_type': item.media_type})
351
+ content.append(file_data)
352
+ elif isinstance(item, FileUrl):
353
+ if self.system == 'google-gla' or item.force_download:
354
+ downloaded_item = await download_item(item, data_format='base64')
355
+ inline_data = _GeminiInlineDataPart(
356
+ inline_data={'data': downloaded_item['data'], 'mime_type': downloaded_item['data_type']}
357
+ )
358
+ content.append(inline_data)
359
+ else:
360
+ file_data = _GeminiFileDataPart(file_data={'file_uri': item.url, 'mime_type': item.media_type})
361
+ content.append(file_data)
360
362
  else:
361
363
  assert_never(item)
362
364
  return content
@@ -14,10 +14,8 @@ from pydantic_ai.providers import Provider
14
14
 
15
15
  from .. import UnexpectedModelBehavior, _utils, usage
16
16
  from ..messages import (
17
- AudioUrl,
18
17
  BinaryContent,
19
- DocumentUrl,
20
- ImageUrl,
18
+ FileUrl,
21
19
  ModelMessage,
22
20
  ModelRequest,
23
21
  ModelResponse,
@@ -38,8 +36,8 @@ from . import (
38
36
  Model,
39
37
  ModelRequestParameters,
40
38
  StreamedResponse,
41
- cached_async_http_client,
42
39
  check_allow_model_requests,
40
+ download_item,
43
41
  get_user_agent,
44
42
  )
45
43
 
@@ -372,13 +370,15 @@ class GoogleModel(Model):
372
370
  # NOTE: The type from Google GenAI is incorrect, it should be `str`, not `bytes`.
373
371
  base64_encoded = base64.b64encode(item.data).decode('utf-8')
374
372
  content.append({'inline_data': {'data': base64_encoded, 'mime_type': item.media_type}}) # type: ignore
375
- elif isinstance(item, (AudioUrl, ImageUrl, DocumentUrl, VideoUrl)):
376
- client = cached_async_http_client()
377
- response = await client.get(item.url, follow_redirects=True)
378
- response.raise_for_status()
379
- # NOTE: The type from Google GenAI is incorrect, it should be `str`, not `bytes`.
380
- base64_encoded = base64.b64encode(response.content).decode('utf-8')
381
- content.append({'inline_data': {'data': base64_encoded, 'mime_type': item.media_type}}) # type: ignore
373
+ elif isinstance(item, VideoUrl) and item.is_youtube:
374
+ content.append({'file_data': {'file_uri': item.url, 'mime_type': item.media_type}})
375
+ elif isinstance(item, FileUrl):
376
+ if self.system == 'google-gla' or item.force_download:
377
+ downloaded_item = await download_item(item, data_format='base64')
378
+ inline_data = {'data': downloaded_item['data'], 'mime_type': downloaded_item['data_type']}
379
+ content.append({'inline_data': inline_data}) # type: ignore
380
+ else:
381
+ content.append({'file_data': {'file_uri': item.url, 'mime_type': item.media_type}})
382
382
  else:
383
383
  assert_never(item)
384
384
  return content
@@ -13,6 +13,7 @@ from opentelemetry._events import (
13
13
  EventLoggerProvider, # pyright: ignore[reportPrivateImportUsage]
14
14
  get_event_logger_provider, # pyright: ignore[reportPrivateImportUsage]
15
15
  )
16
+ from opentelemetry.metrics import MeterProvider, get_meter_provider
16
17
  from opentelemetry.trace import Span, Tracer, TracerProvider, get_tracer_provider
17
18
  from opentelemetry.util.types import AttributeValue
18
19
  from pydantic import TypeAdapter
@@ -49,6 +50,10 @@ MODEL_SETTING_ATTRIBUTES: tuple[
49
50
 
50
51
  ANY_ADAPTER = TypeAdapter[Any](Any)
51
52
 
53
+ # These are in the spec:
54
+ # https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage
55
+ TOKEN_HISTOGRAM_BOUNDARIES = (1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864)
56
+
52
57
 
53
58
  def instrument_model(model: Model, instrument: InstrumentationSettings | bool) -> Model:
54
59
  """Instrument a model with OpenTelemetry/logfire."""
@@ -84,6 +89,7 @@ class InstrumentationSettings:
84
89
  *,
85
90
  event_mode: Literal['attributes', 'logs'] = 'attributes',
86
91
  tracer_provider: TracerProvider | None = None,
92
+ meter_provider: MeterProvider | None = None,
87
93
  event_logger_provider: EventLoggerProvider | None = None,
88
94
  include_binary_content: bool = True,
89
95
  ):
@@ -95,6 +101,9 @@ class InstrumentationSettings:
95
101
  tracer_provider: The OpenTelemetry tracer provider to use.
96
102
  If not provided, the global tracer provider is used.
97
103
  Calling `logfire.configure()` sets the global tracer provider, so most users don't need this.
104
+ meter_provider: The OpenTelemetry meter provider to use.
105
+ If not provided, the global meter provider is used.
106
+ Calling `logfire.configure()` sets the global meter provider, so most users don't need this.
98
107
  event_logger_provider: The OpenTelemetry event logger provider to use.
99
108
  If not provided, the global event logger provider is used.
100
109
  Calling `logfire.configure()` sets the global event logger provider, so most users don't need this.
@@ -104,12 +113,33 @@ class InstrumentationSettings:
104
113
  from pydantic_ai import __version__
105
114
 
106
115
  tracer_provider = tracer_provider or get_tracer_provider()
116
+ meter_provider = meter_provider or get_meter_provider()
107
117
  event_logger_provider = event_logger_provider or get_event_logger_provider()
108
- self.tracer = tracer_provider.get_tracer('pydantic-ai', __version__)
109
- self.event_logger = event_logger_provider.get_event_logger('pydantic-ai', __version__)
118
+ scope_name = 'pydantic-ai'
119
+ self.tracer = tracer_provider.get_tracer(scope_name, __version__)
120
+ self.meter = meter_provider.get_meter(scope_name, __version__)
121
+ self.event_logger = event_logger_provider.get_event_logger(scope_name, __version__)
110
122
  self.event_mode = event_mode
111
123
  self.include_binary_content = include_binary_content
112
124
 
125
+ # As specified in the OpenTelemetry GenAI metrics spec:
126
+ # https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage
127
+ tokens_histogram_kwargs = dict(
128
+ name='gen_ai.client.token.usage',
129
+ unit='{token}',
130
+ description='Measures number of input and output tokens used',
131
+ )
132
+ try:
133
+ self.tokens_histogram = self.meter.create_histogram(
134
+ **tokens_histogram_kwargs,
135
+ explicit_bucket_boundaries_advisory=TOKEN_HISTOGRAM_BOUNDARIES,
136
+ )
137
+ except TypeError:
138
+ # Older OTel/logfire versions don't support explicit_bucket_boundaries_advisory
139
+ self.tokens_histogram = self.meter.create_histogram(
140
+ **tokens_histogram_kwargs, # pyright: ignore
141
+ )
142
+
113
143
  def messages_to_otel_events(self, messages: list[ModelMessage]) -> list[Event]:
114
144
  """Convert a list of model messages to OpenTelemetry events.
115
145
 
@@ -224,38 +254,74 @@ class InstrumentedModel(WrapperModel):
224
254
  if isinstance(value := model_settings.get(key), (float, int)):
225
255
  attributes[f'gen_ai.request.{key}'] = value
226
256
 
227
- with self.settings.tracer.start_as_current_span(span_name, attributes=attributes) as span:
228
-
229
- def finish(response: ModelResponse):
230
- if not span.is_recording():
231
- return
232
-
233
- events = self.settings.messages_to_otel_events(messages)
234
- for event in self.settings.messages_to_otel_events([response]):
235
- events.append(
236
- Event(
237
- 'gen_ai.choice',
238
- body={
239
- # TODO finish_reason
240
- 'index': 0,
241
- 'message': event.body,
242
- },
257
+ record_metrics: Callable[[], None] | None = None
258
+ try:
259
+ with self.settings.tracer.start_as_current_span(span_name, attributes=attributes) as span:
260
+
261
+ def finish(response: ModelResponse):
262
+ # FallbackModel updates these span attributes.
263
+ attributes.update(getattr(span, 'attributes', {}))
264
+ request_model = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]
265
+ system = attributes[GEN_AI_SYSTEM_ATTRIBUTE]
266
+
267
+ response_model = response.model_name or request_model
268
+
269
+ def _record_metrics():
270
+ metric_attributes = {
271
+ GEN_AI_SYSTEM_ATTRIBUTE: system,
272
+ 'gen_ai.operation.name': operation,
273
+ 'gen_ai.request.model': request_model,
274
+ 'gen_ai.response.model': response_model,
275
+ }
276
+ if response.usage.request_tokens: # pragma: no branch
277
+ self.settings.tokens_histogram.record(
278
+ response.usage.request_tokens,
279
+ {**metric_attributes, 'gen_ai.token.type': 'input'},
280
+ )
281
+ if response.usage.response_tokens: # pragma: no branch
282
+ self.settings.tokens_histogram.record(
283
+ response.usage.response_tokens,
284
+ {**metric_attributes, 'gen_ai.token.type': 'output'},
285
+ )
286
+
287
+ nonlocal record_metrics
288
+ record_metrics = _record_metrics
289
+
290
+ if not span.is_recording():
291
+ return
292
+
293
+ events = self.settings.messages_to_otel_events(messages)
294
+ for event in self.settings.messages_to_otel_events([response]):
295
+ events.append(
296
+ Event(
297
+ 'gen_ai.choice',
298
+ body={
299
+ # TODO finish_reason
300
+ 'index': 0,
301
+ 'message': event.body,
302
+ },
303
+ )
243
304
  )
305
+ span.set_attributes(
306
+ {
307
+ **response.usage.opentelemetry_attributes(),
308
+ 'gen_ai.response.model': response_model,
309
+ }
244
310
  )
245
- new_attributes: dict[str, AttributeValue] = response.usage.opentelemetry_attributes() # pyright: ignore[reportAssignmentType]
246
- attributes.update(getattr(span, 'attributes', {}))
247
- request_model = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]
248
- new_attributes['gen_ai.response.model'] = response.model_name or request_model
249
- span.set_attributes(new_attributes)
250
- span.update_name(f'{operation} {request_model}')
251
- for event in events:
252
- event.attributes = {
253
- GEN_AI_SYSTEM_ATTRIBUTE: attributes[GEN_AI_SYSTEM_ATTRIBUTE],
254
- **(event.attributes or {}),
255
- }
256
- self._emit_events(span, events)
257
-
258
- yield finish
311
+ span.update_name(f'{operation} {request_model}')
312
+ for event in events:
313
+ event.attributes = {
314
+ GEN_AI_SYSTEM_ATTRIBUTE: system,
315
+ **(event.attributes or {}),
316
+ }
317
+ self._emit_events(span, events)
318
+
319
+ yield finish
320
+ finally:
321
+ if record_metrics:
322
+ # We only want to record metrics after the span is finished,
323
+ # to prevent them from being redundantly recorded in the span itself by logfire.
324
+ record_metrics()
259
325
 
260
326
  def _emit_events(self, span: Span, events: list[Event]) -> None:
261
327
  if self.settings.event_mode == 'logs':
@@ -40,8 +40,8 @@ from . import (
40
40
  Model,
41
41
  ModelRequestParameters,
42
42
  StreamedResponse,
43
- cached_async_http_client,
44
43
  check_allow_model_requests,
44
+ download_item,
45
45
  get_user_agent,
46
46
  )
47
47
 
@@ -116,6 +116,13 @@ class OpenAIModelSettings(ModelSettings, total=False):
116
116
  See [OpenAI's safety best practices](https://platform.openai.com/docs/guides/safety-best-practices#end-user-ids) for more details.
117
117
  """
118
118
 
119
+ openai_service_tier: Literal['auto', 'default', 'flex']
120
+ """The service tier to use for the model request.
121
+
122
+ Currently supported values are `auto`, `default`, and `flex`.
123
+ For more information, see [OpenAI's service tiers documentation](https://platform.openai.com/docs/api-reference/chat/object#chat/object-service_tier).
124
+ """
125
+
119
126
 
120
127
  class OpenAIResponsesModelSettings(OpenAIModelSettings, total=False):
121
128
  """Settings used for an OpenAI Responses model request.
@@ -274,6 +281,12 @@ class OpenAIModel(Model):
274
281
 
275
282
  openai_messages = await self._map_messages(messages)
276
283
 
284
+ sampling_settings = (
285
+ model_settings
286
+ if OpenAIModelProfile.from_profile(self.profile).openai_supports_sampling_settings
287
+ else OpenAIModelSettings()
288
+ )
289
+
277
290
  try:
278
291
  extra_headers = model_settings.get('extra_headers', {})
279
292
  extra_headers.setdefault('User-Agent', get_user_agent())
@@ -287,17 +300,18 @@ class OpenAIModel(Model):
287
300
  stream_options={'include_usage': True} if stream else NOT_GIVEN,
288
301
  stop=model_settings.get('stop_sequences', NOT_GIVEN),
289
302
  max_completion_tokens=model_settings.get('max_tokens', NOT_GIVEN),
290
- temperature=model_settings.get('temperature', NOT_GIVEN),
291
- top_p=model_settings.get('top_p', NOT_GIVEN),
292
303
  timeout=model_settings.get('timeout', NOT_GIVEN),
293
304
  seed=model_settings.get('seed', NOT_GIVEN),
294
- presence_penalty=model_settings.get('presence_penalty', NOT_GIVEN),
295
- frequency_penalty=model_settings.get('frequency_penalty', NOT_GIVEN),
296
- logit_bias=model_settings.get('logit_bias', NOT_GIVEN),
297
305
  reasoning_effort=model_settings.get('openai_reasoning_effort', NOT_GIVEN),
298
- logprobs=model_settings.get('openai_logprobs', NOT_GIVEN),
299
- top_logprobs=model_settings.get('openai_top_logprobs', NOT_GIVEN),
300
306
  user=model_settings.get('openai_user', NOT_GIVEN),
307
+ service_tier=model_settings.get('openai_service_tier', NOT_GIVEN),
308
+ temperature=sampling_settings.get('temperature', NOT_GIVEN),
309
+ top_p=sampling_settings.get('top_p', NOT_GIVEN),
310
+ presence_penalty=sampling_settings.get('presence_penalty', NOT_GIVEN),
311
+ frequency_penalty=sampling_settings.get('frequency_penalty', NOT_GIVEN),
312
+ logit_bias=sampling_settings.get('logit_bias', NOT_GIVEN),
313
+ logprobs=sampling_settings.get('openai_logprobs', NOT_GIVEN),
314
+ top_logprobs=sampling_settings.get('openai_top_logprobs', NOT_GIVEN),
301
315
  extra_headers=extra_headers,
302
316
  extra_body=model_settings.get('extra_body'),
303
317
  )
@@ -485,21 +499,21 @@ class OpenAIModel(Model):
485
499
  else: # pragma: no cover
486
500
  raise RuntimeError(f'Unsupported binary content type: {item.media_type}')
487
501
  elif isinstance(item, AudioUrl):
488
- client = cached_async_http_client()
489
- response = await client.get(item.url)
490
- response.raise_for_status()
491
- base64_encoded = base64.b64encode(response.content).decode('utf-8')
492
- audio_format: Any = response.headers['content-type'].removeprefix('audio/')
493
- audio = InputAudio(data=base64_encoded, format=audio_format)
502
+ downloaded_item = await download_item(item, data_format='base64', type_format='extension')
503
+ assert downloaded_item['data_type'] in (
504
+ 'wav',
505
+ 'mp3',
506
+ ), f'Unsupported audio format: {downloaded_item["data_type"]}'
507
+ audio = InputAudio(data=downloaded_item['data'], format=downloaded_item['data_type'])
494
508
  content.append(ChatCompletionContentPartInputAudioParam(input_audio=audio, type='input_audio'))
495
509
  elif isinstance(item, DocumentUrl):
496
- client = cached_async_http_client()
497
- response = await client.get(item.url)
498
- response.raise_for_status()
499
- base64_encoded = base64.b64encode(response.content).decode('utf-8')
500
- media_type = response.headers.get('content-type').split(';')[0]
501
- file_data = f'data:{media_type};base64,{base64_encoded}'
502
- file = File(file=FileFile(file_data=file_data, filename=f'filename.{item.format}'), type='file')
510
+ downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
511
+ file = File(
512
+ file=FileFile(
513
+ file_data=downloaded_item['data'], filename=f'filename.{downloaded_item["data_type"]}'
514
+ ),
515
+ type='file',
516
+ )
503
517
  content.append(file)
504
518
  elif isinstance(item, VideoUrl): # pragma: no cover
505
519
  raise NotImplementedError('VideoUrl is not supported for OpenAI')
@@ -656,6 +670,12 @@ class OpenAIResponsesModel(Model):
656
670
  instructions, openai_messages = await self._map_messages(messages)
657
671
  reasoning = self._get_reasoning(model_settings)
658
672
 
673
+ sampling_settings = (
674
+ model_settings
675
+ if OpenAIModelProfile.from_profile(self.profile).openai_supports_sampling_settings
676
+ else OpenAIResponsesModelSettings()
677
+ )
678
+
659
679
  try:
660
680
  extra_headers = model_settings.get('extra_headers', {})
661
681
  extra_headers.setdefault('User-Agent', get_user_agent())
@@ -668,8 +688,8 @@ class OpenAIResponsesModel(Model):
668
688
  tool_choice=tool_choice or NOT_GIVEN,
669
689
  max_output_tokens=model_settings.get('max_tokens', NOT_GIVEN),
670
690
  stream=stream,
671
- temperature=model_settings.get('temperature', NOT_GIVEN),
672
- top_p=model_settings.get('top_p', NOT_GIVEN),
691
+ temperature=sampling_settings.get('temperature', NOT_GIVEN),
692
+ top_p=sampling_settings.get('top_p', NOT_GIVEN),
673
693
  truncation=model_settings.get('openai_truncation', NOT_GIVEN),
674
694
  timeout=model_settings.get('timeout', NOT_GIVEN),
675
695
  reasoning=reasoning,
@@ -805,27 +825,21 @@ class OpenAIResponsesModel(Model):
805
825
  responses.ResponseInputImageParam(image_url=item.url, type='input_image', detail='auto')
806
826
  )
807
827
  elif isinstance(item, AudioUrl): # pragma: no cover
808
- client = cached_async_http_client()
809
- response = await client.get(item.url)
810
- response.raise_for_status()
811
- base64_encoded = base64.b64encode(response.content).decode('utf-8')
828
+ downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
812
829
  content.append(
813
830
  responses.ResponseInputFileParam(
814
831
  type='input_file',
815
- file_data=f'data:{item.media_type};base64,{base64_encoded}',
832
+ file_data=downloaded_item['data'],
833
+ filename=f'filename.{downloaded_item["data_type"]}',
816
834
  )
817
835
  )
818
836
  elif isinstance(item, DocumentUrl):
819
- client = cached_async_http_client()
820
- response = await client.get(item.url)
821
- response.raise_for_status()
822
- base64_encoded = base64.b64encode(response.content).decode('utf-8')
823
- media_type = response.headers.get('content-type').split(';')[0]
837
+ downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
824
838
  content.append(
825
839
  responses.ResponseInputFileParam(
826
840
  type='input_file',
827
- file_data=f'data:{media_type};base64,{base64_encoded}',
828
- filename=f'filename.{item.format}',
841
+ file_data=downloaded_item['data'],
842
+ filename=f'filename.{downloaded_item["data_type"]}',
829
843
  )
830
844
  )
831
845
  elif isinstance(item, VideoUrl): # pragma: no cover
@@ -15,13 +15,20 @@ class OpenAIModelProfile(ModelProfile):
15
15
  ALL FIELDS MUST BE `openai_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS.
16
16
  """
17
17
 
18
- # This can be set by a provider or user if the OpenAI-"compatible" API doesn't support strict tool definitions
19
18
  openai_supports_strict_tool_definition: bool = True
19
+ """This can be set by a provider or user if the OpenAI-"compatible" API doesn't support strict tool definitions."""
20
+
21
+ openai_supports_sampling_settings: bool = True
22
+ """Turn off to don't send sampling settings like `temperature` and `top_p` to models that don't support them, like OpenAI's o-series reasoning models."""
20
23
 
21
24
 
22
25
  def openai_model_profile(model_name: str) -> ModelProfile:
23
26
  """Get the model profile for an OpenAI model."""
24
- return OpenAIModelProfile(json_schema_transformer=OpenAIJsonSchemaTransformer)
27
+ is_reasoning_model = model_name.startswith('o')
28
+ return OpenAIModelProfile(
29
+ json_schema_transformer=OpenAIJsonSchemaTransformer,
30
+ openai_supports_sampling_settings=not is_reasoning_model,
31
+ )
25
32
 
26
33
 
27
34
  _STRICT_INCOMPATIBLE_KEYS = [
@@ -75,7 +75,7 @@ tavily = ["tavily-python>=0.5.0"]
75
75
  # CLI
76
76
  cli = ["rich>=13", "prompt-toolkit>=3", "argcomplete>=3.5.0"]
77
77
  # MCP
78
- mcp = ["mcp>=1.9.0; python_version >= '3.10'"]
78
+ mcp = ["mcp>=1.9.2; python_version >= '3.10'"]
79
79
  # Evals
80
80
  evals = ["pydantic-evals=={{ version }}"]
81
81
  # A2A