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.
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/PKG-INFO +5 -5
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/mcp.py +57 -15
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/messages.py +43 -13
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/__init__.py +89 -2
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/anthropic.py +3 -11
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/bedrock.py +23 -15
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/gemini.py +15 -13
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/google.py +11 -11
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/instrumented.py +98 -32
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/openai.py +49 -35
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/openai.py +9 -2
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pyproject.toml +1 -1
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/.gitignore +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/LICENSE +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/README.md +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/__init__.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/__main__.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_a2a.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_agent_graph.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_cli.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_function_schema.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_griffe.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_output.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_parts_manager.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_system_prompt.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/_utils.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/agent.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/common_tools/__init__.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/common_tools/duckduckgo.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/common_tools/tavily.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/direct.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/exceptions.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/ext/__init__.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/ext/langchain.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/format_as_xml.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/format_prompt.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/cohere.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/fallback.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/function.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/groq.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/mistral.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/test.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/models/wrapper.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/__init__.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/_json_schema.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/amazon.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/anthropic.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/cohere.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/deepseek.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/google.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/grok.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/meta.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/mistral.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/profiles/qwen.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/__init__.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/anthropic.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/azure.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/bedrock.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/cohere.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/deepseek.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/fireworks.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/google.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/google_gla.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/google_vertex.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/grok.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/groq.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/heroku.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/mistral.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/openai.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/openrouter.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/providers/together.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/py.typed +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/result.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/settings.py +0 -0
- {pydantic_ai_slim-0.2.16 → pydantic_ai_slim-0.2.17}/pydantic_ai/tools.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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[
|
|
60
|
-
_write_stream: MemoryObjectSendStream[
|
|
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[
|
|
70
|
-
MemoryObjectSendStream[
|
|
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[
|
|
270
|
-
MemoryObjectSendStream[
|
|
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
|
-
|
|
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
|
-
)
|
|
376
|
-
|
|
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
|
|
84
|
-
"""
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
511
|
-
|
|
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':
|
|
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
|
-
|
|
521
|
-
|
|
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 (
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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,
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
inline_data=
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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,
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
109
|
-
self.
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
audio = InputAudio(data=
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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=
|
|
672
|
-
top_p=
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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=
|
|
828
|
-
filename=f'filename.{
|
|
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
|
-
|
|
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.
|
|
78
|
+
mcp = ["mcp>=1.9.2; python_version >= '3.10'"]
|
|
79
79
|
# Evals
|
|
80
80
|
evals = ["pydantic-evals=={{ version }}"]
|
|
81
81
|
# A2A
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|