pydantic-ai-slim 0.2.2__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pydantic-ai-slim might be problematic. Click here for more details.

pydantic_ai/_a2a.py ADDED
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations, annotations as _annotations
2
+
3
+ from collections.abc import AsyncIterator, Sequence
4
+ from contextlib import asynccontextmanager
5
+ from dataclasses import dataclass
6
+ from functools import partial
7
+ from typing import Any, Generic
8
+
9
+ from typing_extensions import assert_never
10
+
11
+ from pydantic_ai.messages import (
12
+ AudioUrl,
13
+ BinaryContent,
14
+ DocumentUrl,
15
+ ImageUrl,
16
+ ModelMessage,
17
+ ModelRequest,
18
+ ModelRequestPart,
19
+ ModelResponse,
20
+ ModelResponsePart,
21
+ TextPart,
22
+ UserPromptPart,
23
+ VideoUrl,
24
+ )
25
+
26
+ from .agent import Agent, AgentDepsT, OutputDataT
27
+
28
+ try:
29
+ from starlette.middleware import Middleware
30
+ from starlette.routing import Route
31
+ from starlette.types import ExceptionHandler, Lifespan
32
+
33
+ from fasta2a.applications import FastA2A
34
+ from fasta2a.broker import Broker, InMemoryBroker
35
+ from fasta2a.schema import (
36
+ Artifact,
37
+ Message,
38
+ Part,
39
+ Provider,
40
+ Skill,
41
+ TaskIdParams,
42
+ TaskSendParams,
43
+ TextPart as A2ATextPart,
44
+ )
45
+ from fasta2a.storage import InMemoryStorage, Storage
46
+ from fasta2a.worker import Worker
47
+ except ImportError as _import_error:
48
+ raise ImportError(
49
+ 'Please install the `fasta2a` package to use `Agent.to_a2a()` method, '
50
+ 'you can use the `a2a` optional group — `pip install "pydantic-ai-slim[a2a]"`'
51
+ ) from _import_error
52
+
53
+
54
+ @asynccontextmanager
55
+ async def worker_lifespan(app: FastA2A, worker: Worker) -> AsyncIterator[None]:
56
+ """Custom lifespan that runs the worker during application startup.
57
+
58
+ This ensures the worker is started and ready to process tasks as soon as the application starts.
59
+ """
60
+ async with app.task_manager:
61
+ async with worker.run():
62
+ yield
63
+
64
+
65
+ def agent_to_a2a(
66
+ agent: Agent[AgentDepsT, OutputDataT],
67
+ *,
68
+ storage: Storage | None = None,
69
+ broker: Broker | None = None,
70
+ # Agent card
71
+ name: str | None = None,
72
+ url: str = 'http://localhost:8000',
73
+ version: str = '1.0.0',
74
+ description: str | None = None,
75
+ provider: Provider | None = None,
76
+ skills: list[Skill] | None = None,
77
+ # Starlette
78
+ debug: bool = False,
79
+ routes: Sequence[Route] | None = None,
80
+ middleware: Sequence[Middleware] | None = None,
81
+ exception_handlers: dict[Any, ExceptionHandler] | None = None,
82
+ lifespan: Lifespan[FastA2A] | None = None,
83
+ ) -> FastA2A:
84
+ """Create a FastA2A server from an agent."""
85
+ storage = storage or InMemoryStorage()
86
+ broker = broker or InMemoryBroker()
87
+ worker = AgentWorker(agent=agent, broker=broker, storage=storage)
88
+
89
+ lifespan = lifespan or partial(worker_lifespan, worker=worker)
90
+
91
+ return FastA2A(
92
+ storage=storage,
93
+ broker=broker,
94
+ name=name or agent.name,
95
+ url=url,
96
+ version=version,
97
+ description=description,
98
+ provider=provider,
99
+ skills=skills,
100
+ debug=debug,
101
+ routes=routes,
102
+ middleware=middleware,
103
+ exception_handlers=exception_handlers,
104
+ lifespan=lifespan,
105
+ )
106
+
107
+
108
+ @dataclass
109
+ class AgentWorker(Worker, Generic[AgentDepsT, OutputDataT]):
110
+ """A worker that uses an agent to execute tasks."""
111
+
112
+ agent: Agent[AgentDepsT, OutputDataT]
113
+
114
+ async def run_task(self, params: TaskSendParams) -> None:
115
+ task = await self.storage.load_task(params['id'], history_length=params.get('history_length'))
116
+ assert task is not None, f'Task {params["id"]} not found'
117
+ assert 'session_id' in task, 'Task must have a session_id'
118
+
119
+ await self.storage.update_task(task['id'], state='working')
120
+
121
+ # TODO(Marcelo): We need to have a way to communicate when the task is set to `input-required`. Maybe
122
+ # a custom `output_type` with a `more_info_required` field, or something like that.
123
+
124
+ task_history = task.get('history', [])
125
+ message_history = self.build_message_history(task_history=task_history)
126
+
127
+ # TODO(Marcelo): We need to make this more customizable e.g. pass deps.
128
+ result = await self.agent.run(message_history=message_history) # type: ignore
129
+
130
+ artifacts = self.build_artifacts(result.output)
131
+ await self.storage.update_task(task['id'], state='completed', artifacts=artifacts)
132
+
133
+ async def cancel_task(self, params: TaskIdParams) -> None:
134
+ pass
135
+
136
+ def build_artifacts(self, result: Any) -> list[Artifact]:
137
+ # TODO(Marcelo): We need to send the json schema of the result on the metadata of the message.
138
+ return [Artifact(name='result', index=0, parts=[A2ATextPart(type='text', text=str(result))])]
139
+
140
+ def build_message_history(self, task_history: list[Message]) -> list[ModelMessage]:
141
+ model_messages: list[ModelMessage] = []
142
+ for message in task_history:
143
+ if message['role'] == 'user':
144
+ model_messages.append(ModelRequest(parts=self._map_request_parts(message['parts'])))
145
+ else:
146
+ model_messages.append(ModelResponse(parts=self._map_response_parts(message['parts'])))
147
+ return model_messages
148
+
149
+ def _map_request_parts(self, parts: list[Part]) -> list[ModelRequestPart]:
150
+ model_parts: list[ModelRequestPart] = []
151
+ for part in parts:
152
+ if part['type'] == 'text':
153
+ model_parts.append(UserPromptPart(content=part['text']))
154
+ elif part['type'] == 'file':
155
+ file = part['file']
156
+ if 'data' in file:
157
+ data = file['data'].encode('utf-8')
158
+ content = BinaryContent(data=data, media_type=file['mime_type'])
159
+ model_parts.append(UserPromptPart(content=[content]))
160
+ else:
161
+ url = file['url']
162
+ for url_cls in (DocumentUrl, AudioUrl, ImageUrl, VideoUrl):
163
+ content = url_cls(url=url)
164
+ try:
165
+ content.media_type
166
+ except ValueError: # pragma: no cover
167
+ continue
168
+ else:
169
+ break
170
+ else:
171
+ raise ValueError(f'Unknown file type: {file["mime_type"]}') # pragma: no cover
172
+ model_parts.append(UserPromptPart(content=[content]))
173
+ elif part['type'] == 'data':
174
+ # TODO(Marcelo): Maybe we should use this for `ToolReturnPart`, and `RetryPromptPart`.
175
+ raise NotImplementedError('Data parts are not supported yet.')
176
+ else:
177
+ assert_never(part)
178
+ return model_parts
179
+
180
+ def _map_response_parts(self, parts: list[Part]) -> list[ModelResponsePart]:
181
+ model_parts: list[ModelResponsePart] = []
182
+ for part in parts:
183
+ if part['type'] == 'text':
184
+ model_parts.append(TextPart(content=part['text']))
185
+ elif part['type'] == 'file': # pragma: no cover
186
+ raise NotImplementedError('File parts are not supported yet.')
187
+ elif part['type'] == 'data': # pragma: no cover
188
+ raise NotImplementedError('Data parts are not supported yet.')
189
+ else: # pragma: no cover
190
+ assert_never(part)
191
+ return model_parts
pydantic_ai/_cli.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations as _annotations
3
3
  import argparse
4
4
  import asyncio
5
5
  import importlib
6
+ import os
6
7
  import sys
7
8
  from asyncio import CancelledError
8
9
  from collections.abc import Sequence
@@ -167,6 +168,9 @@ Special prompts:
167
168
  agent: Agent[None, str] = cli_agent
168
169
  if args.agent:
169
170
  try:
171
+ current_path = os.getcwd()
172
+ sys.path.append(current_path)
173
+
170
174
  module_path, variable_name = args.agent.split(':')
171
175
  module = importlib.import_module(module_path)
172
176
  agent = getattr(module, variable_name)
@@ -199,6 +203,10 @@ Special prompts:
199
203
  pass
200
204
  return 0
201
205
 
206
+ # Ensure the history directory and file exist
207
+ PROMPT_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
208
+ PROMPT_HISTORY_PATH.touch(exist_ok=True)
209
+
202
210
  # doing this instead of `PromptSession[Any](history=` allows mocking of PromptSession in tests
203
211
  session: PromptSession[Any] = PromptSession(history=FileHistory(str(PROMPT_HISTORY_PATH)))
204
212
  try:
pydantic_ai/agent.py CHANGED
@@ -28,7 +28,7 @@ from . import (
28
28
  result,
29
29
  usage as _usage,
30
30
  )
31
- from .models.instrumented import InstrumentationSettings, InstrumentedModel
31
+ from .models.instrumented import InstrumentationSettings, InstrumentedModel, instrument_model
32
32
  from .result import FinalResult, OutputDataT, StreamedRunResult, ToolOutput
33
33
  from .settings import ModelSettings, merge_model_settings
34
34
  from .tools import (
@@ -52,6 +52,14 @@ ModelRequestNode = _agent_graph.ModelRequestNode
52
52
  UserPromptNode = _agent_graph.UserPromptNode
53
53
 
54
54
  if TYPE_CHECKING:
55
+ from starlette.middleware import Middleware
56
+ from starlette.routing import Route
57
+ from starlette.types import ExceptionHandler, Lifespan
58
+
59
+ from fasta2a.applications import FastA2A
60
+ from fasta2a.broker import Broker
61
+ from fasta2a.schema import Provider, Skill
62
+ from fasta2a.storage import Storage
55
63
  from pydantic_ai.mcp import MCPServer
56
64
 
57
65
 
@@ -100,7 +108,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
100
108
  model: models.Model | models.KnownModelName | str | None
101
109
  """The default model configured for this agent.
102
110
 
103
- We allow str here since the actual list of allowed models changes frequently.
111
+ We allow `str` here since the actual list of allowed models changes frequently.
104
112
  """
105
113
 
106
114
  name: str | None
@@ -225,7 +233,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
225
233
 
226
234
  Args:
227
235
  model: The default model to use for this agent, if not provide,
228
- you must provide the model when calling it. We allow str here since the actual list of allowed models changes frequently.
236
+ you must provide the model when calling it. We allow `str` here since the actual list of allowed models changes frequently.
229
237
  output_type: The type of the output data, used to validate the data returned by the model,
230
238
  defaults to `str`.
231
239
  instructions: Instructions to use for this agent, you can also register instructions via a function with
@@ -1574,13 +1582,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
1574
1582
  if instrument is None:
1575
1583
  instrument = self._instrument_default
1576
1584
 
1577
- if instrument and not isinstance(model_, InstrumentedModel):
1578
- if instrument is True:
1579
- instrument = InstrumentationSettings()
1580
-
1581
- model_ = InstrumentedModel(model_, instrument)
1582
-
1583
- return model_
1585
+ return instrument_model(model_, instrument)
1584
1586
 
1585
1587
  def _get_deps(self: Agent[T, OutputDataT], deps: T) -> T:
1586
1588
  """Get deps for a run.
@@ -1688,6 +1690,62 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
1688
1690
  finally:
1689
1691
  await exit_stack.aclose()
1690
1692
 
1693
+ def to_a2a(
1694
+ self,
1695
+ *,
1696
+ storage: Storage | None = None,
1697
+ broker: Broker | None = None,
1698
+ # Agent card
1699
+ name: str | None = None,
1700
+ url: str = 'http://localhost:8000',
1701
+ version: str = '1.0.0',
1702
+ description: str | None = None,
1703
+ provider: Provider | None = None,
1704
+ skills: list[Skill] | None = None,
1705
+ # Starlette
1706
+ debug: bool = False,
1707
+ routes: Sequence[Route] | None = None,
1708
+ middleware: Sequence[Middleware] | None = None,
1709
+ exception_handlers: dict[Any, ExceptionHandler] | None = None,
1710
+ lifespan: Lifespan[FastA2A] | None = None,
1711
+ ) -> FastA2A:
1712
+ """Convert the agent to a FastA2A application.
1713
+
1714
+ Example:
1715
+ ```python
1716
+ from pydantic_ai import Agent
1717
+
1718
+ agent = Agent('openai:gpt-4o')
1719
+ app = agent.to_a2a()
1720
+ ```
1721
+
1722
+ The `app` is an ASGI application that can be used with any ASGI server.
1723
+
1724
+ To run the application, you can use the following command:
1725
+
1726
+ ```bash
1727
+ uvicorn app:app --host 0.0.0.0 --port 8000
1728
+ ```
1729
+ """
1730
+ from ._a2a import agent_to_a2a
1731
+
1732
+ return agent_to_a2a(
1733
+ self,
1734
+ storage=storage,
1735
+ broker=broker,
1736
+ name=name,
1737
+ url=url,
1738
+ version=version,
1739
+ description=description,
1740
+ provider=provider,
1741
+ skills=skills,
1742
+ debug=debug,
1743
+ routes=routes,
1744
+ middleware=middleware,
1745
+ exception_handlers=exception_handlers,
1746
+ lifespan=lifespan,
1747
+ )
1748
+
1691
1749
  async def to_cli(self: Self, deps: AgentDepsT = None) -> None:
1692
1750
  """Run the agent in a CLI chat interface.
1693
1751
 
pydantic_ai/direct.py ADDED
@@ -0,0 +1,215 @@
1
+ """Methods for making imperative requests to language models with minimal abstraction.
2
+
3
+ These methods allow you to make requests to LLMs where the only abstraction is input and output schema
4
+ translation so you can use all models with the same API.
5
+
6
+ These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
7
+ """
8
+
9
+ from __future__ import annotations as _annotations
10
+
11
+ from contextlib import AbstractAsyncContextManager
12
+
13
+ from pydantic_graph._utils import get_event_loop as _get_event_loop
14
+
15
+ from . import agent, messages, models, settings
16
+ from .models import instrumented as instrumented_models
17
+
18
+ __all__ = 'model_request', 'model_request_sync', 'model_request_stream'
19
+
20
+
21
+ async def model_request(
22
+ model: models.Model | models.KnownModelName | str,
23
+ messages: list[messages.ModelMessage],
24
+ *,
25
+ model_settings: settings.ModelSettings | None = None,
26
+ model_request_parameters: models.ModelRequestParameters | None = None,
27
+ instrument: instrumented_models.InstrumentationSettings | bool | None = None,
28
+ ) -> messages.ModelResponse:
29
+ """Make a non-streamed request to a model.
30
+
31
+ ```py title="model_request_example.py"
32
+ from pydantic_ai.direct import model_request
33
+ from pydantic_ai.messages import ModelRequest
34
+
35
+
36
+ async def main():
37
+ model_response = await model_request(
38
+ 'anthropic:claude-3-5-haiku-latest',
39
+ [ModelRequest.user_text_prompt('What is the capital of France?')] # (1)!
40
+ )
41
+ print(model_response)
42
+ '''
43
+ ModelResponse(
44
+ parts=[TextPart(content='Paris', part_kind='text')],
45
+ usage=Usage(
46
+ requests=1,
47
+ request_tokens=56,
48
+ response_tokens=1,
49
+ total_tokens=57,
50
+ details=None,
51
+ ),
52
+ model_name='claude-3-5-haiku-latest',
53
+ timestamp=datetime.datetime(...),
54
+ kind='response',
55
+ )
56
+ '''
57
+ ```
58
+
59
+ 1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
60
+
61
+ Args:
62
+ model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
63
+ messages: Messages to send to the model
64
+ model_settings: optional model settings
65
+ model_request_parameters: optional model request parameters
66
+ instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
67
+ [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
68
+
69
+ Returns:
70
+ The model response and token usage associated with the request.
71
+ """
72
+ model_instance = _prepare_model(model, instrument)
73
+ return await model_instance.request(
74
+ messages,
75
+ model_settings,
76
+ model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
77
+ )
78
+
79
+
80
+ def model_request_sync(
81
+ model: models.Model | models.KnownModelName | str,
82
+ messages: list[messages.ModelMessage],
83
+ *,
84
+ model_settings: settings.ModelSettings | None = None,
85
+ model_request_parameters: models.ModelRequestParameters | None = None,
86
+ instrument: instrumented_models.InstrumentationSettings | bool | None = None,
87
+ ) -> messages.ModelResponse:
88
+ """Make a Synchronous, non-streamed request to a model.
89
+
90
+ This is a convenience method that wraps [`model_request`][pydantic_ai.direct.model_request] with
91
+ `loop.run_until_complete(...)`. You therefore can't use this method inside async code or if there's an active event loop.
92
+
93
+ ```py title="model_request_sync_example.py"
94
+ from pydantic_ai.direct import model_request_sync
95
+ from pydantic_ai.messages import ModelRequest
96
+
97
+ model_response = model_request_sync(
98
+ 'anthropic:claude-3-5-haiku-latest',
99
+ [ModelRequest.user_text_prompt('What is the capital of France?')] # (1)!
100
+ )
101
+ print(model_response)
102
+ '''
103
+ ModelResponse(
104
+ parts=[TextPart(content='Paris', part_kind='text')],
105
+ usage=Usage(
106
+ requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None
107
+ ),
108
+ model_name='claude-3-5-haiku-latest',
109
+ timestamp=datetime.datetime(...),
110
+ kind='response',
111
+ )
112
+ '''
113
+ ```
114
+
115
+ 1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
116
+
117
+ Args:
118
+ model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
119
+ messages: Messages to send to the model
120
+ model_settings: optional model settings
121
+ model_request_parameters: optional model request parameters
122
+ instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
123
+ [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
124
+
125
+ Returns:
126
+ The model response and token usage associated with the request.
127
+ """
128
+ return _get_event_loop().run_until_complete(
129
+ model_request(
130
+ model,
131
+ messages,
132
+ model_settings=model_settings,
133
+ model_request_parameters=model_request_parameters,
134
+ instrument=instrument,
135
+ )
136
+ )
137
+
138
+
139
+ def model_request_stream(
140
+ model: models.Model | models.KnownModelName | str,
141
+ messages: list[messages.ModelMessage],
142
+ *,
143
+ model_settings: settings.ModelSettings | None = None,
144
+ model_request_parameters: models.ModelRequestParameters | None = None,
145
+ instrument: instrumented_models.InstrumentationSettings | bool | None = None,
146
+ ) -> AbstractAsyncContextManager[models.StreamedResponse]:
147
+ """Make a streamed async request to a model.
148
+
149
+ ```py {title="model_request_stream_example.py"}
150
+
151
+ from pydantic_ai.direct import model_request_stream
152
+ from pydantic_ai.messages import ModelRequest
153
+
154
+
155
+ async def main():
156
+ messages = [ModelRequest.user_text_prompt('Who was Albert Einstein?')] # (1)!
157
+ async with model_request_stream( 'openai:gpt-4.1-mini', messages) as stream:
158
+ chunks = []
159
+ async for chunk in stream:
160
+ chunks.append(chunk)
161
+ print(chunks)
162
+ '''
163
+ [
164
+ PartStartEvent(
165
+ index=0,
166
+ part=TextPart(content='Albert Einstein was ', part_kind='text'),
167
+ event_kind='part_start',
168
+ ),
169
+ PartDeltaEvent(
170
+ index=0,
171
+ delta=TextPartDelta(
172
+ content_delta='a German-born theoretical ', part_delta_kind='text'
173
+ ),
174
+ event_kind='part_delta',
175
+ ),
176
+ PartDeltaEvent(
177
+ index=0,
178
+ delta=TextPartDelta(content_delta='physicist.', part_delta_kind='text'),
179
+ event_kind='part_delta',
180
+ ),
181
+ ]
182
+ '''
183
+ ```
184
+
185
+ 1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
186
+
187
+ Args:
188
+ model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
189
+ messages: Messages to send to the model
190
+ model_settings: optional model settings
191
+ model_request_parameters: optional model request parameters
192
+ instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
193
+ [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
194
+
195
+ Returns:
196
+ A [stream response][pydantic_ai.models.StreamedResponse] async context manager.
197
+ """
198
+ model_instance = _prepare_model(model, instrument)
199
+ return model_instance.request_stream(
200
+ messages,
201
+ model_settings,
202
+ model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
203
+ )
204
+
205
+
206
+ def _prepare_model(
207
+ model: models.Model | models.KnownModelName | str,
208
+ instrument: instrumented_models.InstrumentationSettings | bool | None,
209
+ ) -> models.Model:
210
+ model_instance = models.infer_model(model)
211
+
212
+ if instrument is None:
213
+ instrument = agent.Agent._instrument_default # pyright: ignore[reportPrivateUsage]
214
+
215
+ return instrumented_models.instrument_model(model_instance, instrument)
pydantic_ai/messages.py CHANGED
@@ -83,7 +83,7 @@ class VideoUrl:
83
83
  """Type identifier, this is available on all parts as a discriminator."""
84
84
 
85
85
  @property
86
- def media_type(self) -> VideoMediaType: # pragma: lax no cover
86
+ def media_type(self) -> VideoMediaType:
87
87
  """Return the media type of the video, based on the url."""
88
88
  if self.url.endswith('.mkv'):
89
89
  return 'video/x-matroska'
@@ -110,7 +110,7 @@ class VideoUrl:
110
110
 
111
111
  The choice of supported formats were based on the Bedrock Converse API. Other APIs don't require to use a format.
112
112
  """
113
- return _video_format(self.media_type)
113
+ return _video_format_lookup[self.media_type]
114
114
 
115
115
 
116
116
  @dataclass
@@ -133,6 +133,11 @@ class AudioUrl:
133
133
  else:
134
134
  raise ValueError(f'Unknown audio file extension: {self.url}')
135
135
 
136
+ @property
137
+ def format(self) -> AudioFormat:
138
+ """The file format of the audio file."""
139
+ return _audio_format_lookup[self.media_type]
140
+
136
141
 
137
142
  @dataclass
138
143
  class ImageUrl:
@@ -164,7 +169,7 @@ class ImageUrl:
164
169
 
165
170
  The choice of supported formats were based on the Bedrock Converse API. Other APIs don't require to use a format.
166
171
  """
167
- return _image_format(self.media_type)
172
+ return _image_format_lookup[self.media_type]
168
173
 
169
174
 
170
175
  @dataclass
@@ -182,7 +187,7 @@ class DocumentUrl:
182
187
  """Return the media type of the document, based on the url."""
183
188
  type_, _ = guess_type(self.url)
184
189
  if type_ is None:
185
- raise RuntimeError(f'Unknown document file extension: {self.url}')
190
+ raise ValueError(f'Unknown document file extension: {self.url}')
186
191
  return type_
187
192
 
188
193
  @property
@@ -191,7 +196,11 @@ class DocumentUrl:
191
196
 
192
197
  The choice of supported formats were based on the Bedrock Converse API. Other APIs don't require to use a format.
193
198
  """
194
- return _document_format(self.media_type)
199
+ media_type = self.media_type
200
+ try:
201
+ return _document_format_lookup[media_type]
202
+ except KeyError as e:
203
+ raise ValueError(f'Unknown document media type: {media_type}') from e
195
204
 
196
205
 
197
206
  @dataclass
@@ -225,93 +234,58 @@ class BinaryContent:
225
234
  @property
226
235
  def is_document(self) -> bool:
227
236
  """Return `True` if the media type is a document type."""
228
- return self.media_type in {
229
- 'application/pdf',
230
- 'text/plain',
231
- 'text/csv',
232
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
233
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
234
- 'text/html',
235
- 'text/markdown',
236
- 'application/vnd.ms-excel',
237
- }
237
+ return self.media_type in _document_format_lookup
238
238
 
239
239
  @property
240
240
  def format(self) -> str:
241
241
  """The file format of the binary content."""
242
- if self.is_audio:
243
- if self.media_type == 'audio/mpeg':
244
- return 'mp3'
245
- elif self.media_type == 'audio/wav':
246
- return 'wav'
247
- elif self.is_image:
248
- return _image_format(self.media_type)
249
- elif self.is_document:
250
- return _document_format(self.media_type)
251
- elif self.is_video:
252
- return _video_format(self.media_type)
253
- raise ValueError(f'Unknown media type: {self.media_type}')
242
+ try:
243
+ if self.is_audio:
244
+ return _audio_format_lookup[self.media_type]
245
+ elif self.is_image:
246
+ return _image_format_lookup[self.media_type]
247
+ elif self.is_video:
248
+ return _video_format_lookup[self.media_type]
249
+ else:
250
+ return _document_format_lookup[self.media_type]
251
+ except KeyError as e:
252
+ raise ValueError(f'Unknown media type: {self.media_type}') from e
254
253
 
255
254
 
256
255
  UserContent: TypeAlias = 'str | ImageUrl | AudioUrl | DocumentUrl | VideoUrl | BinaryContent'
257
256
 
258
257
  # Ideally this would be a Union of types, but Python 3.9 requires it to be a string, and strings don't work with `isinstance``.
259
258
  MultiModalContentTypes = (ImageUrl, AudioUrl, DocumentUrl, VideoUrl, BinaryContent)
260
-
261
-
262
- def _document_format(media_type: str) -> DocumentFormat:
263
- if media_type == 'application/pdf':
264
- return 'pdf'
265
- elif media_type == 'text/plain':
266
- return 'txt'
267
- elif media_type == 'text/csv':
268
- return 'csv'
269
- elif media_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
270
- return 'docx'
271
- elif media_type == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
272
- return 'xlsx'
273
- elif media_type == 'text/html':
274
- return 'html'
275
- elif media_type == 'text/markdown':
276
- return 'md'
277
- elif media_type == 'application/vnd.ms-excel':
278
- return 'xls'
279
- else:
280
- raise ValueError(f'Unknown document media type: {media_type}')
281
-
282
-
283
- def _image_format(media_type: str) -> ImageFormat:
284
- if media_type == 'image/jpeg':
285
- return 'jpeg'
286
- elif media_type == 'image/png':
287
- return 'png'
288
- elif media_type == 'image/gif':
289
- return 'gif'
290
- elif media_type == 'image/webp':
291
- return 'webp'
292
- else:
293
- raise ValueError(f'Unknown image media type: {media_type}')
294
-
295
-
296
- def _video_format(media_type: str) -> VideoFormat:
297
- if media_type == 'video/x-matroska':
298
- return 'mkv'
299
- elif media_type == 'video/quicktime':
300
- return 'mov'
301
- elif media_type == 'video/mp4':
302
- return 'mp4'
303
- elif media_type == 'video/webm':
304
- return 'webm'
305
- elif media_type == 'video/x-flv':
306
- return 'flv'
307
- elif media_type == 'video/mpeg':
308
- return 'mpeg'
309
- elif media_type == 'video/x-ms-wmv':
310
- return 'wmv'
311
- elif media_type == 'video/3gpp':
312
- return 'three_gp'
313
- else: # pragma: no cover
314
- raise ValueError(f'Unknown video media type: {media_type}')
259
+ _document_format_lookup: dict[str, DocumentFormat] = {
260
+ 'application/pdf': 'pdf',
261
+ 'text/plain': 'txt',
262
+ 'text/csv': 'csv',
263
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
264
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
265
+ 'text/html': 'html',
266
+ 'text/markdown': 'md',
267
+ 'application/vnd.ms-excel': 'xls',
268
+ }
269
+ _audio_format_lookup: dict[str, AudioFormat] = {
270
+ 'audio/mpeg': 'mp3',
271
+ 'audio/wav': 'wav',
272
+ }
273
+ _image_format_lookup: dict[str, ImageFormat] = {
274
+ 'image/jpeg': 'jpeg',
275
+ 'image/png': 'png',
276
+ 'image/gif': 'gif',
277
+ 'image/webp': 'webp',
278
+ }
279
+ _video_format_lookup: dict[str, VideoFormat] = {
280
+ 'video/x-matroska': 'mkv',
281
+ 'video/quicktime': 'mov',
282
+ 'video/mp4': 'mp4',
283
+ 'video/webm': 'webm',
284
+ 'video/x-flv': 'flv',
285
+ 'video/mpeg': 'mpeg',
286
+ 'video/x-ms-wmv': 'wmv',
287
+ 'video/3gpp': 'three_gp',
288
+ }
315
289
 
316
290
 
317
291
  @dataclass
@@ -478,6 +452,11 @@ class ModelRequest:
478
452
  kind: Literal['request'] = 'request'
479
453
  """Message type identifier, this is available on all parts as a discriminator."""
480
454
 
455
+ @classmethod
456
+ def user_text_prompt(cls, user_prompt: str, *, instructions: str | None = None) -> ModelRequest:
457
+ """Create a `ModelRequest` with a single user prompt as text."""
458
+ return cls(parts=[UserPromptPart(user_prompt)], instructions=instructions)
459
+
481
460
 
482
461
  @dataclass
483
462
  class TextPart:
@@ -260,9 +260,9 @@ KnownModelName = TypeAliasType(
260
260
  class ModelRequestParameters:
261
261
  """Configuration for an agent's request to a model, specifically related to tools and output handling."""
262
262
 
263
- function_tools: list[ToolDefinition]
264
- allow_text_output: bool
265
- output_tools: list[ToolDefinition]
263
+ function_tools: list[ToolDefinition] = field(default_factory=list)
264
+ allow_text_output: bool = True
265
+ output_tools: list[ToolDefinition] = field(default_factory=list)
266
266
 
267
267
 
268
268
  class Model(ABC):
@@ -26,6 +26,8 @@ from ..settings import ModelSettings
26
26
  from . import KnownModelName, Model, ModelRequestParameters, StreamedResponse
27
27
  from .wrapper import WrapperModel
28
28
 
29
+ __all__ = 'instrument_model', 'InstrumentationSettings', 'InstrumentedModel'
30
+
29
31
  MODEL_SETTING_ATTRIBUTES: tuple[
30
32
  Literal[
31
33
  'max_tokens',
@@ -48,6 +50,17 @@ MODEL_SETTING_ATTRIBUTES: tuple[
48
50
  ANY_ADAPTER = TypeAdapter[Any](Any)
49
51
 
50
52
 
53
+ def instrument_model(model: Model, instrument: InstrumentationSettings | bool) -> Model:
54
+ """Instrument a model with OpenTelemetry/logfire."""
55
+ if instrument and not isinstance(model, InstrumentedModel):
56
+ if instrument is True:
57
+ instrument = InstrumentationSettings()
58
+
59
+ model = InstrumentedModel(model, instrument)
60
+
61
+ return model
62
+
63
+
51
64
  @dataclass(init=False)
52
65
  class InstrumentationSettings:
53
66
  """Options for instrumenting models and agents with OpenTelemetry.
@@ -150,7 +150,7 @@ class TestModel(Model):
150
150
  elif model_request_parameters.output_tools:
151
151
  return _WrappedToolOutput(None)
152
152
  else:
153
- return _WrappedTextOutput(None) # pragma: no cover
153
+ return _WrappedTextOutput(None)
154
154
 
155
155
  def _request(
156
156
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydantic-ai-slim
3
- Version: 0.2.2
3
+ Version: 0.2.3
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
@@ -26,12 +26,15 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
26
  Requires-Python: >=3.9
27
27
  Requires-Dist: eval-type-backport>=0.2.0
28
28
  Requires-Dist: exceptiongroup; python_version < '3.11'
29
+ Requires-Dist: fasta2a==0.2.3
29
30
  Requires-Dist: griffe>=1.3.2
30
31
  Requires-Dist: httpx>=0.27
31
32
  Requires-Dist: opentelemetry-api>=1.28.0
32
- Requires-Dist: pydantic-graph==0.2.2
33
+ Requires-Dist: pydantic-graph==0.2.3
33
34
  Requires-Dist: pydantic>=2.10
34
35
  Requires-Dist: typing-inspection>=0.4.0
36
+ Provides-Extra: a2a
37
+ Requires-Dist: fasta2a==0.2.3; extra == 'a2a'
35
38
  Provides-Extra: anthropic
36
39
  Requires-Dist: anthropic>=0.49.0; extra == 'anthropic'
37
40
  Provides-Extra: bedrock
@@ -45,7 +48,7 @@ Requires-Dist: cohere>=5.13.11; (platform_system != 'Emscripten') and extra == '
45
48
  Provides-Extra: duckduckgo
46
49
  Requires-Dist: duckduckgo-search>=7.0.0; extra == 'duckduckgo'
47
50
  Provides-Extra: evals
48
- Requires-Dist: pydantic-evals==0.2.2; extra == 'evals'
51
+ Requires-Dist: pydantic-evals==0.2.3; extra == 'evals'
49
52
  Provides-Extra: groq
50
53
  Requires-Dist: groq>=0.15.0; extra == 'groq'
51
54
  Provides-Extra: logfire
@@ -1,19 +1,21 @@
1
1
  pydantic_ai/__init__.py,sha256=5flxyMQJVrHRMQ3MYaZf1el2ctNs0JmPClKbw2Q-Lsk,1160
2
2
  pydantic_ai/__main__.py,sha256=Q_zJU15DUA01YtlJ2mnaLCoId2YmgmreVEERGuQT-Y0,132
3
+ pydantic_ai/_a2a.py,sha256=8nNtx6GENDt2Ej3f1ui9L-FuNQBYVELpJFfwz-y7fUw,7234
3
4
  pydantic_ai/_agent_graph.py,sha256=28JXHfSU78tBWZJr3ZES6gWB5wZoevyk8rMlbHDHfFc,35145
4
- pydantic_ai/_cli.py,sha256=tCUKc3wDOdH4uFb2XIoeKFIPZD7_MM1Mb-GNiEQKtoM,12266
5
+ pydantic_ai/_cli.py,sha256=Y-oKnWFmCzeKhDQjDbv6Lx77kbn6dHrccSObhsTWPr8,12520
5
6
  pydantic_ai/_griffe.py,sha256=Sf_DisE9k2TA0VFeVIK2nf1oOct5MygW86PBCACJkFA,5244
6
7
  pydantic_ai/_output.py,sha256=w_kBc5Lx5AmI0APbohxxYgpFd5VAwh6K0IjP7QIOu9U,11209
7
8
  pydantic_ai/_parts_manager.py,sha256=kG4xynxXHAr9uGFwCVqqhsGCac5a_UjFdRBucoTCXEE,12189
8
9
  pydantic_ai/_pydantic.py,sha256=1EO1tv-ULj3l_L1qMcC7gIOKTL2e2a-xTbUD_kqKiOg,8921
9
10
  pydantic_ai/_system_prompt.py,sha256=602c2jyle2R_SesOrITBDETZqsLk4BZ8Cbo8yEhmx04,1120
10
11
  pydantic_ai/_utils.py,sha256=Vlww1AMQMTvFfGRlFKAyvl4VrE24Lk1MH28EwVTWy8c,10122
11
- pydantic_ai/agent.py,sha256=fzoQAvgUa6RmeQo4YFidkdiSt04e5ckp6Hxa_m5a0Rg,91934
12
+ pydantic_ai/agent.py,sha256=C645hycMhltv5WkmGKwpaqN5E7312mb6vDXtyP1G7JU,93692
13
+ pydantic_ai/direct.py,sha256=x2uooElhReT_kCk2uEgQDPN5--x1VTZ4gqC7n7DMxoA,8389
12
14
  pydantic_ai/exceptions.py,sha256=1ujJeB3jDDQ-pH5ydBYrgStvR35-GlEW0bYGTGEr4ME,3127
13
15
  pydantic_ai/format_as_xml.py,sha256=IINfh1evWDphGahqHNLBArB5dQ4NIqS3S-kru35ztGg,372
14
16
  pydantic_ai/format_prompt.py,sha256=qdKep95Sjlr7u1-qag4JwPbjoURbG0GbeU_l5ODTNw4,4466
15
17
  pydantic_ai/mcp.py,sha256=UsiBsg2ZuFh0OTMc-tvvxzfyW9YiPSIe6h8_KGloxqI,11312
16
- pydantic_ai/messages.py,sha256=d6r41kHUF13ctpF5rFG11BK3Idj6ecH9JbKiQvDdgnA,30827
18
+ pydantic_ai/messages.py,sha256=ruZW63ZJcr9VGewM4G4dkTPaL36IvNAfdGiH9FZ5q6g,30256
17
19
  pydantic_ai/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
20
  pydantic_ai/result.py,sha256=DgoUd0LqNd9DWPab6iwculKYvZ5JZHuGvToj0kkibvs,27625
19
21
  pydantic_ai/settings.py,sha256=U2XzZ9y1fIi_L6yCGTugZRxfk7_01rk5GKSgFqySHL4,3520
@@ -22,7 +24,7 @@ pydantic_ai/usage.py,sha256=IRWuLZdEtxldVJXdJcVOuXQMAZ3wiRFYltMDmDclsoU,5427
22
24
  pydantic_ai/common_tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
25
  pydantic_ai/common_tools/duckduckgo.py,sha256=Ty9tu1rCwMfGKgz1JAaC2q_4esmL6QvpkHQUN8F0Ecc,2152
24
26
  pydantic_ai/common_tools/tavily.py,sha256=Q1xxSF5HtXAaZ10Pp-OaDOHXwJf2mco9wScGEQXD7E4,2495
25
- pydantic_ai/models/__init__.py,sha256=JJTqwoqTUIfDS4GoA5F82ccjBCunwnX-GwQ_KODhHB4,19949
27
+ pydantic_ai/models/__init__.py,sha256=lg4OmJrNMksHgtZcQOPscB_4Nos9yWQlrcvL1mY9jyY,20016
26
28
  pydantic_ai/models/_json_schema.py,sha256=RD0cIU9mOGIdRuhkjLtPdlwfmF8XDOP1kLevIOLudaE,6540
27
29
  pydantic_ai/models/anthropic.py,sha256=0w2-XbyCTJquq8tcQzQdSiYMW3IOaWUgUiMKa0ycjhI,20952
28
30
  pydantic_ai/models/bedrock.py,sha256=BZcgFzhM1MzQLgzkDrtIJXTZzmcfCruDlGPOi-f1vfA,26098
@@ -31,10 +33,10 @@ pydantic_ai/models/fallback.py,sha256=AFLfpLIne56O3sjhMQANL3-RfTBli10g42D6fkpjE-
31
33
  pydantic_ai/models/function.py,sha256=sVmTymKCeZcmVSDDL2nzAG9aW1UP46i1EqH59cyB2pA,11437
32
34
  pydantic_ai/models/gemini.py,sha256=NzgRuittq3kin7jC0IW0ElO8er52a4RxILdC_kts3_8,36906
33
35
  pydantic_ai/models/groq.py,sha256=vEWFyg_2GhEokoBpmQ8AHoSLoe5KsBduwhfC4qumAHg,17294
34
- pydantic_ai/models/instrumented.py,sha256=4CDxyLmTeK0DXAg75ZVKsvNzyFXoo4h8TlGAk-t9DXo,11417
36
+ pydantic_ai/models/instrumented.py,sha256=3brXPUATuOpjtkttPF2V7GCqyjh8fccRtw7hAVPKBe8,11861
35
37
  pydantic_ai/models/mistral.py,sha256=ElYUZC4gtgoLgcaFtqXJ5XV131VWzwmn9PCfOknj93g,29061
36
38
  pydantic_ai/models/openai.py,sha256=06ONhqMX96ZIIrMutZPdOiqnolUYArTAByLHzlqeS3E,48350
37
- pydantic_ai/models/test.py,sha256=_zW9gDlVngldQBin1J8eIrQXQ3yx7U3WgCMFEa4Wfnk,17009
39
+ pydantic_ai/models/test.py,sha256=cupj6jiDg7DDJxEFjauDm5DgVjRoadRayplPQsGbwV0,16989
38
40
  pydantic_ai/models/wrapper.py,sha256=_NUXpLFkJw90Ngq-hSS23vfEzWTPNCiJdeA5HTfLlyY,1670
39
41
  pydantic_ai/providers/__init__.py,sha256=5NQ-LxVNGYXXcq1QaWua2Y8_QuA2GfiV852dtNRBMps,2572
40
42
  pydantic_ai/providers/anthropic.py,sha256=0WzWEDseBaJ5eyEatvnDXBtDZKA9-od4BuPZn9NoTPw,2812
@@ -47,7 +49,7 @@ pydantic_ai/providers/google_vertex.py,sha256=WAwPxKTARVzs8DFs2veEUOJSur0krDOo9-
47
49
  pydantic_ai/providers/groq.py,sha256=DoY6qkfhuemuKB5JXhUkqG-3t1HQkxwSXoE_kHQIAK0,2788
48
50
  pydantic_ai/providers/mistral.py,sha256=FAS7yKn26yWy7LTmEiBSvqe0HpTXi8_nIf824vE6RFQ,2892
49
51
  pydantic_ai/providers/openai.py,sha256=ePF-QWwLkGkSE5w245gTTDVR3VoTIUqFoIhQ0TAoUiA,2866
50
- pydantic_ai_slim-0.2.2.dist-info/METADATA,sha256=xG01EN4b9lpWtfNjclQQCv_sZzOLSVj9NgtKS3hIFQU,3680
51
- pydantic_ai_slim-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
52
- pydantic_ai_slim-0.2.2.dist-info/entry_points.txt,sha256=kbKxe2VtDCYS06hsI7P3uZGxcVC08-FPt1rxeiMpIps,50
53
- pydantic_ai_slim-0.2.2.dist-info/RECORD,,
52
+ pydantic_ai_slim-0.2.3.dist-info/METADATA,sha256=MB6ePxAOWkEBJQceXGJqkmsIUtPM6EzmwREcESHHqCk,3776
53
+ pydantic_ai_slim-0.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
54
+ pydantic_ai_slim-0.2.3.dist-info/entry_points.txt,sha256=kbKxe2VtDCYS06hsI7P3uZGxcVC08-FPt1rxeiMpIps,50
55
+ pydantic_ai_slim-0.2.3.dist-info/RECORD,,