pydantic-ai-slim 0.2.20__py3-none-any.whl → 0.3.0__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.

@@ -442,7 +442,7 @@ class CallToolsNode(AgentNode[DepsT, NodeRunEndT]):
442
442
  async for _event in stream:
443
443
  pass
444
444
 
445
- async def _run_stream(
445
+ async def _run_stream( # noqa: C901
446
446
  self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
447
447
  ) -> AsyncIterator[_messages.HandleResponseEvent]:
448
448
  if self._events_iterator is None:
@@ -458,6 +458,12 @@ class CallToolsNode(AgentNode[DepsT, NodeRunEndT]):
458
458
  texts.append(part.content)
459
459
  elif isinstance(part, _messages.ToolCallPart):
460
460
  tool_calls.append(part)
461
+ elif isinstance(part, _messages.ThinkingPart):
462
+ # We don't need to do anything with thinking parts in this tool-calling node.
463
+ # We need to handle text parts in case there are no tool calls and/or the desired output comes
464
+ # from the text, but thinking parts should not directly influence the execution of tools or
465
+ # determination of the next node of graph execution here.
466
+ pass
461
467
  else:
462
468
  assert_never(part)
463
469
 
@@ -25,6 +25,8 @@ from pydantic_ai.messages import (
25
25
  PartStartEvent,
26
26
  TextPart,
27
27
  TextPartDelta,
28
+ ThinkingPart,
29
+ ThinkingPartDelta,
28
30
  ToolCallPart,
29
31
  ToolCallPartDelta,
30
32
  )
@@ -86,8 +88,7 @@ class ModelResponsePartsManager:
86
88
  A `PartStartEvent` if a new part was created, or a `PartDeltaEvent` if an existing part was updated.
87
89
 
88
90
  Raises:
89
- UnexpectedModelBehavior: If attempting to apply text content to a part that is
90
- not a TextPart.
91
+ UnexpectedModelBehavior: If attempting to apply text content to a part that is not a TextPart.
91
92
  """
92
93
  existing_text_part_and_index: tuple[TextPart, int] | None = None
93
94
 
@@ -122,6 +123,77 @@ class ModelResponsePartsManager:
122
123
  self._parts[part_index] = part_delta.apply(existing_text_part)
123
124
  return PartDeltaEvent(index=part_index, delta=part_delta)
124
125
 
126
+ def handle_thinking_delta(
127
+ self,
128
+ *,
129
+ vendor_part_id: Hashable | None,
130
+ content: str | None = None,
131
+ signature: str | None = None,
132
+ ) -> ModelResponseStreamEvent:
133
+ """Handle incoming thinking content, creating or updating a ThinkingPart in the manager as appropriate.
134
+
135
+ When `vendor_part_id` is None, the latest part is updated if it exists and is a ThinkingPart;
136
+ otherwise, a new ThinkingPart is created. When a non-None ID is specified, the ThinkingPart corresponding
137
+ to that vendor ID is either created or updated.
138
+
139
+ Args:
140
+ vendor_part_id: The ID the vendor uses to identify this piece
141
+ of thinking. If None, a new part will be created unless the latest part is already
142
+ a ThinkingPart.
143
+ content: The thinking content to append to the appropriate ThinkingPart.
144
+ signature: An optional signature for the thinking content.
145
+
146
+ Returns:
147
+ A `PartStartEvent` if a new part was created, or a `PartDeltaEvent` if an existing part was updated.
148
+
149
+ Raises:
150
+ UnexpectedModelBehavior: If attempting to apply a thinking delta to a part that is not a ThinkingPart.
151
+ """
152
+ existing_thinking_part_and_index: tuple[ThinkingPart, int] | None = None
153
+
154
+ if vendor_part_id is None:
155
+ # If the vendor_part_id is None, check if the latest part is a ThinkingPart to update
156
+ if self._parts:
157
+ part_index = len(self._parts) - 1
158
+ latest_part = self._parts[part_index]
159
+ if isinstance(latest_part, ThinkingPart): # pragma: no branch
160
+ existing_thinking_part_and_index = latest_part, part_index
161
+ else:
162
+ # Otherwise, attempt to look up an existing ThinkingPart by vendor_part_id
163
+ part_index = self._vendor_id_to_part_index.get(vendor_part_id)
164
+ if part_index is not None:
165
+ existing_part = self._parts[part_index]
166
+ if not isinstance(existing_part, ThinkingPart):
167
+ raise UnexpectedModelBehavior(f'Cannot apply a thinking delta to {existing_part=}')
168
+ existing_thinking_part_and_index = existing_part, part_index
169
+
170
+ if existing_thinking_part_and_index is None:
171
+ if content is not None:
172
+ # There is no existing thinking part that should be updated, so create a new one
173
+ new_part_index = len(self._parts)
174
+ part = ThinkingPart(content=content, signature=signature)
175
+ if vendor_part_id is not None: # pragma: no branch
176
+ self._vendor_id_to_part_index[vendor_part_id] = new_part_index
177
+ self._parts.append(part)
178
+ return PartStartEvent(index=new_part_index, part=part)
179
+ else:
180
+ raise UnexpectedModelBehavior('Cannot create a ThinkingPart with no content')
181
+ else:
182
+ if content is not None:
183
+ # Update the existing ThinkingPart with the new content delta
184
+ existing_thinking_part, part_index = existing_thinking_part_and_index
185
+ part_delta = ThinkingPartDelta(content_delta=content)
186
+ self._parts[part_index] = part_delta.apply(existing_thinking_part)
187
+ return PartDeltaEvent(index=part_index, delta=part_delta)
188
+ elif signature is not None:
189
+ # Update the existing ThinkingPart with the new signature delta
190
+ existing_thinking_part, part_index = existing_thinking_part_and_index
191
+ part_delta = ThinkingPartDelta(signature_delta=signature)
192
+ self._parts[part_index] = part_delta.apply(existing_thinking_part)
193
+ return PartDeltaEvent(index=part_index, delta=part_delta)
194
+ else:
195
+ raise UnexpectedModelBehavior('Cannot update a ThinkingPart with no content or signature')
196
+
125
197
  def handle_tool_call_delta(
126
198
  self,
127
199
  *,
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations as _annotations
2
+
3
+ from pydantic_ai.messages import TextPart, ThinkingPart
4
+
5
+ START_THINK_TAG = '<think>'
6
+ END_THINK_TAG = '</think>'
7
+
8
+
9
+ def split_content_into_text_and_thinking(content: str) -> list[ThinkingPart | TextPart]:
10
+ """Split a string into text and thinking parts.
11
+
12
+ Some models don't return the thinking part as a separate part, but rather as a tag in the content.
13
+ This function splits the content into text and thinking parts.
14
+
15
+ We use the `<think>` tag because that's how Groq uses it in the `raw` format, so instead of using `<Thinking>` or
16
+ something else, we just match the tag to make it easier for other models that don't support the `ThinkingPart`.
17
+ """
18
+ parts: list[ThinkingPart | TextPart] = []
19
+
20
+ start_index = content.find(START_THINK_TAG)
21
+ while start_index >= 0:
22
+ before_think, content = content[:start_index], content[start_index + len(START_THINK_TAG) :]
23
+ if before_think:
24
+ parts.append(TextPart(content=before_think))
25
+ end_index = content.find(END_THINK_TAG)
26
+ if end_index >= 0:
27
+ think_content, content = content[:end_index], content[end_index + len(END_THINK_TAG) :]
28
+ parts.append(ThinkingPart(content=think_content))
29
+ else:
30
+ # We lose the `<think>` tag, but it shouldn't matter.
31
+ parts.append(TextPart(content=content))
32
+ content = ''
33
+ start_index = content.find(START_THINK_TAG)
34
+ if content:
35
+ parts.append(TextPart(content=content))
36
+ return parts
pydantic_ai/messages.py CHANGED
@@ -14,7 +14,10 @@ from opentelemetry._events import Event # pyright: ignore[reportPrivateImportUs
14
14
  from typing_extensions import TypeAlias
15
15
 
16
16
  from . import _utils
17
- from ._utils import generate_tool_call_id as _generate_tool_call_id, now_utc as _now_utc
17
+ from ._utils import (
18
+ generate_tool_call_id as _generate_tool_call_id,
19
+ now_utc as _now_utc,
20
+ )
18
21
  from .exceptions import UnexpectedModelBehavior
19
22
  from .usage import Usage
20
23
 
@@ -531,6 +534,32 @@ class TextPart:
531
534
  __repr__ = _utils.dataclasses_no_defaults_repr
532
535
 
533
536
 
537
+ @dataclass(repr=False)
538
+ class ThinkingPart:
539
+ """A thinking response from a model."""
540
+
541
+ content: str
542
+ """The thinking content of the response."""
543
+
544
+ id: str | None = None
545
+ """The identifier of the thinking part."""
546
+
547
+ signature: str | None = None
548
+ """The signature of the thinking.
549
+
550
+ The signature is only available on the Anthropic models.
551
+ """
552
+
553
+ part_kind: Literal['thinking'] = 'thinking'
554
+ """Part type identifier, this is available on all parts as a discriminator."""
555
+
556
+ def has_content(self) -> bool:
557
+ """Return `True` if the thinking content is non-empty."""
558
+ return bool(self.content) # pragma: no cover
559
+
560
+ __repr__ = _utils.dataclasses_no_defaults_repr
561
+
562
+
534
563
  @dataclass(repr=False)
535
564
  class ToolCallPart:
536
565
  """A tool call from a model."""
@@ -589,7 +618,7 @@ class ToolCallPart:
589
618
  __repr__ = _utils.dataclasses_no_defaults_repr
590
619
 
591
620
 
592
- ModelResponsePart = Annotated[Union[TextPart, ToolCallPart], pydantic.Discriminator('part_kind')]
621
+ ModelResponsePart = Annotated[Union[TextPart, ToolCallPart, ThinkingPart], pydantic.Discriminator('part_kind')]
593
622
  """A message part returned by a model."""
594
623
 
595
624
 
@@ -699,6 +728,56 @@ class TextPartDelta:
699
728
  __repr__ = _utils.dataclasses_no_defaults_repr
700
729
 
701
730
 
731
+ @dataclass(repr=False)
732
+ class ThinkingPartDelta:
733
+ """A partial update (delta) for a `ThinkingPart` to append new thinking content."""
734
+
735
+ content_delta: str | None = None
736
+ """The incremental thinking content to add to the existing `ThinkingPart` content."""
737
+
738
+ signature_delta: str | None = None
739
+ """Optional signature delta.
740
+
741
+ Note this is never treated as a delta — it can replace None.
742
+ """
743
+
744
+ part_delta_kind: Literal['thinking'] = 'thinking'
745
+ """Part delta type identifier, used as a discriminator."""
746
+
747
+ @overload
748
+ def apply(self, part: ModelResponsePart) -> ThinkingPart: ...
749
+
750
+ @overload
751
+ def apply(self, part: ModelResponsePart | ThinkingPartDelta) -> ThinkingPart | ThinkingPartDelta: ...
752
+
753
+ def apply(self, part: ModelResponsePart | ThinkingPartDelta) -> ThinkingPart | ThinkingPartDelta:
754
+ """Apply this thinking delta to an existing `ThinkingPart`.
755
+
756
+ Args:
757
+ part: The existing model response part, which must be a `ThinkingPart`.
758
+
759
+ Returns:
760
+ A new `ThinkingPart` with updated thinking content.
761
+
762
+ Raises:
763
+ ValueError: If `part` is not a `ThinkingPart`.
764
+ """
765
+ if isinstance(part, ThinkingPart):
766
+ return replace(part, content=part.content + self.content_delta if self.content_delta else None)
767
+ elif isinstance(part, ThinkingPartDelta):
768
+ if self.content_delta is None and self.signature_delta is None:
769
+ raise ValueError('Cannot apply ThinkingPartDelta with no content or signature')
770
+ if self.signature_delta is not None:
771
+ return replace(part, signature_delta=self.signature_delta)
772
+ if self.content_delta is not None:
773
+ return replace(part, content_delta=self.content_delta)
774
+ raise ValueError( # pragma: no cover
775
+ f'Cannot apply ThinkingPartDeltas to non-ThinkingParts or non-ThinkingPartDeltas ({part=}, {self=})'
776
+ )
777
+
778
+ __repr__ = _utils.dataclasses_no_defaults_repr
779
+
780
+
702
781
  @dataclass(repr=False)
703
782
  class ToolCallPartDelta:
704
783
  """A partial update (delta) for a `ToolCallPart` to modify tool name, arguments, or tool call ID."""
@@ -818,7 +897,9 @@ class ToolCallPartDelta:
818
897
  __repr__ = _utils.dataclasses_no_defaults_repr
819
898
 
820
899
 
821
- ModelResponsePartDelta = Annotated[Union[TextPartDelta, ToolCallPartDelta], pydantic.Discriminator('part_delta_kind')]
900
+ ModelResponsePartDelta = Annotated[
901
+ Union[TextPartDelta, ThinkingPartDelta, ToolCallPartDelta], pydantic.Discriminator('part_delta_kind')
902
+ ]
822
903
  """A partial update (delta) for any model response part."""
823
904
 
824
905
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations as _annotations
2
2
 
3
3
  import io
4
+ import warnings
4
5
  from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator
5
6
  from contextlib import asynccontextmanager
6
7
  from dataclasses import dataclass, field
@@ -23,6 +24,7 @@ from ..messages import (
23
24
  RetryPromptPart,
24
25
  SystemPromptPart,
25
26
  TextPart,
27
+ ThinkingPart,
26
28
  ToolCallPart,
27
29
  ToolReturnPart,
28
30
  UserPromptPart,
@@ -52,9 +54,15 @@ try:
52
54
  BetaRawMessageStartEvent,
53
55
  BetaRawMessageStopEvent,
54
56
  BetaRawMessageStreamEvent,
57
+ BetaRedactedThinkingBlock,
58
+ BetaSignatureDelta,
55
59
  BetaTextBlock,
56
60
  BetaTextBlockParam,
57
61
  BetaTextDelta,
62
+ BetaThinkingBlock,
63
+ BetaThinkingBlockParam,
64
+ BetaThinkingConfigParam,
65
+ BetaThinkingDelta,
58
66
  BetaToolChoiceParam,
59
67
  BetaToolParam,
60
68
  BetaToolResultBlockParam,
@@ -90,7 +98,14 @@ class AnthropicModelSettings(ModelSettings, total=False):
90
98
  anthropic_metadata: BetaMetadataParam
91
99
  """An object describing metadata about the request.
92
100
 
93
- Contains `user_id`, an external identifier for the user who is associated with the request."""
101
+ Contains `user_id`, an external identifier for the user who is associated with the request.
102
+ """
103
+
104
+ anthropic_thinking: BetaThinkingConfigParam
105
+ """Determine whether the model should generate a thinking block.
106
+
107
+ See [the Anthropic docs](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) for more information.
108
+ """
94
109
 
95
110
 
96
111
  @dataclass(init=False)
@@ -227,6 +242,7 @@ class AnthropicModel(Model):
227
242
  tools=tools or NOT_GIVEN,
228
243
  tool_choice=tool_choice or NOT_GIVEN,
229
244
  stream=stream,
245
+ thinking=model_settings.get('anthropic_thinking', NOT_GIVEN),
230
246
  stop_sequences=model_settings.get('stop_sequences', NOT_GIVEN),
231
247
  temperature=model_settings.get('temperature', NOT_GIVEN),
232
248
  top_p=model_settings.get('top_p', NOT_GIVEN),
@@ -246,6 +262,14 @@ class AnthropicModel(Model):
246
262
  for item in response.content:
247
263
  if isinstance(item, BetaTextBlock):
248
264
  items.append(TextPart(content=item.text))
265
+ elif isinstance(item, BetaRedactedThinkingBlock): # pragma: no cover
266
+ warnings.warn(
267
+ 'PydanticAI currently does not handle redacted thinking blocks. '
268
+ 'If you have a suggestion on how we should handle them, please open an issue.',
269
+ UserWarning,
270
+ )
271
+ elif isinstance(item, BetaThinkingBlock):
272
+ items.append(ThinkingPart(content=item.thinking, signature=item.signature))
249
273
  else:
250
274
  assert isinstance(item, BetaToolUseBlock), f'unexpected item type {type(item)}'
251
275
  items.append(
@@ -312,11 +336,21 @@ class AnthropicModel(Model):
312
336
  if len(user_content_params) > 0:
313
337
  anthropic_messages.append(BetaMessageParam(role='user', content=user_content_params))
314
338
  elif isinstance(m, ModelResponse):
315
- assistant_content_params: list[BetaTextBlockParam | BetaToolUseBlockParam] = []
339
+ assistant_content_params: list[BetaTextBlockParam | BetaToolUseBlockParam | BetaThinkingBlockParam] = []
316
340
  for response_part in m.parts:
317
341
  if isinstance(response_part, TextPart):
318
342
  if response_part.content: # Only add non-empty text
319
343
  assistant_content_params.append(BetaTextBlockParam(text=response_part.content, type='text'))
344
+ elif isinstance(response_part, ThinkingPart):
345
+ # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
346
+ # please open an issue. The below code is the code to send thinking to the provider.
347
+ # assert response_part.signature is not None, 'Thinking part must have a signature'
348
+ # assistant_content_params.append(
349
+ # BetaThinkingBlockParam(
350
+ # thinking=response_part.content, signature=response_part.signature, type='thinking'
351
+ # )
352
+ # )
353
+ pass
320
354
  else:
321
355
  tool_use_block_param = BetaToolUseBlockParam(
322
356
  id=_guard_tool_call_id(t=response_part),
@@ -445,10 +479,14 @@ class AnthropicStreamedResponse(StreamedResponse):
445
479
  if isinstance(event, BetaRawContentBlockStartEvent):
446
480
  current_block = event.content_block
447
481
  if isinstance(current_block, BetaTextBlock) and current_block.text:
448
- yield self._parts_manager.handle_text_delta( # pragma: lax no cover
449
- vendor_part_id='content', content=current_block.text
482
+ yield self._parts_manager.handle_text_delta(vendor_part_id='content', content=current_block.text)
483
+ elif isinstance(current_block, BetaThinkingBlock):
484
+ yield self._parts_manager.handle_thinking_delta(
485
+ vendor_part_id='thinking',
486
+ content=current_block.thinking,
487
+ signature=current_block.signature,
450
488
  )
451
- elif isinstance(current_block, BetaToolUseBlock): # pragma: no branch
489
+ elif isinstance(current_block, BetaToolUseBlock):
452
490
  maybe_event = self._parts_manager.handle_tool_call_delta(
453
491
  vendor_part_id=current_block.id,
454
492
  tool_name=current_block.name,
@@ -460,14 +498,20 @@ class AnthropicStreamedResponse(StreamedResponse):
460
498
 
461
499
  elif isinstance(event, BetaRawContentBlockDeltaEvent):
462
500
  if isinstance(event.delta, BetaTextDelta):
463
- yield self._parts_manager.handle_text_delta( # pragma: no cover
464
- vendor_part_id='content', content=event.delta.text
501
+ yield self._parts_manager.handle_text_delta(vendor_part_id='content', content=event.delta.text)
502
+ elif isinstance(event.delta, BetaThinkingDelta):
503
+ yield self._parts_manager.handle_thinking_delta(
504
+ vendor_part_id='thinking', content=event.delta.thinking
505
+ )
506
+ elif isinstance(event.delta, BetaSignatureDelta):
507
+ yield self._parts_manager.handle_thinking_delta(
508
+ vendor_part_id='thinking', signature=event.delta.signature
465
509
  )
466
- elif ( # pragma: no branch
510
+ elif (
467
511
  current_block
468
512
  and event.delta.type == 'input_json_delta'
469
513
  and isinstance(current_block, BetaToolUseBlock)
470
- ):
514
+ ): # pragma: no branch
471
515
  maybe_event = self._parts_manager.handle_tool_call_delta(
472
516
  vendor_part_id=current_block.id,
473
517
  tool_name='',
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import functools
4
4
  import typing
5
+ import warnings
5
6
  from collections.abc import AsyncIterator, Iterable, Iterator, Mapping
6
7
  from contextlib import asynccontextmanager
7
8
  from dataclasses import dataclass, field
@@ -27,6 +28,7 @@ from pydantic_ai.messages import (
27
28
  RetryPromptPart,
28
29
  SystemPromptPart,
29
30
  TextPart,
31
+ ThinkingPart,
30
32
  ToolCallPart,
31
33
  ToolReturnPart,
32
34
  UserPromptPart,
@@ -265,11 +267,16 @@ class BedrockConverseModel(Model):
265
267
  items: list[ModelResponsePart] = []
266
268
  if message := response['output'].get('message'): # pragma: no branch
267
269
  for item in message['content']:
270
+ if reasoning_content := item.get('reasoningContent'):
271
+ reasoning_text = reasoning_content.get('reasoningText')
272
+ if reasoning_text: # pragma: no branch
273
+ thinking_part = ThinkingPart(content=reasoning_text['text'])
274
+ if reasoning_signature := reasoning_text.get('signature'):
275
+ thinking_part.signature = reasoning_signature
276
+ items.append(thinking_part)
268
277
  if text := item.get('text'):
269
278
  items.append(TextPart(content=text))
270
- else:
271
- tool_use = item.get('toolUse')
272
- assert tool_use is not None, f'Found a content that is not a text or tool use: {item}'
279
+ elif tool_use := item.get('toolUse'):
273
280
  items.append(
274
281
  ToolCallPart(
275
282
  tool_name=tool_use['name'],
@@ -385,7 +392,7 @@ class BedrockConverseModel(Model):
385
392
 
386
393
  return tool_config
387
394
 
388
- async def _map_messages(
395
+ async def _map_messages( # noqa: C901
389
396
  self, messages: list[ModelMessage]
390
397
  ) -> tuple[list[SystemContentBlockTypeDef], list[MessageUnionTypeDef]]:
391
398
  """Maps a `pydantic_ai.Message` to the Bedrock `MessageUnionTypeDef`.
@@ -448,6 +455,9 @@ class BedrockConverseModel(Model):
448
455
  for item in message.parts:
449
456
  if isinstance(item, TextPart):
450
457
  content.append({'text': item.content})
458
+ elif isinstance(item, ThinkingPart):
459
+ # NOTE: We don't pass the thinking part to Bedrock since it raises an error.
460
+ pass
451
461
  else:
452
462
  assert isinstance(item, ToolCallPart)
453
463
  content.append(self._map_tool_call(item))
@@ -592,6 +602,15 @@ class BedrockStreamedResponse(StreamedResponse):
592
602
  if 'contentBlockDelta' in chunk:
593
603
  index = chunk['contentBlockDelta']['contentBlockIndex']
594
604
  delta = chunk['contentBlockDelta']['delta']
605
+ if 'reasoningContent' in delta:
606
+ if text := delta['reasoningContent'].get('text'):
607
+ yield self._parts_manager.handle_thinking_delta(vendor_part_id=index, content=text)
608
+ else: # pragma: no cover
609
+ warnings.warn(
610
+ f'Only text reasoning content is supported yet, but you got {delta["reasoningContent"]}. '
611
+ 'Please report this to the maintainers.',
612
+ UserWarning,
613
+ )
595
614
  if 'text' in delta:
596
615
  yield self._parts_manager.handle_text_delta(vendor_part_id=index, content=delta['text'])
597
616
  if 'toolUse' in delta:
@@ -6,6 +6,8 @@ from typing import Literal, Union, cast
6
6
 
7
7
  from typing_extensions import assert_never
8
8
 
9
+ from pydantic_ai._thinking_part import split_content_into_text_and_thinking
10
+
9
11
  from .. import ModelHTTPError, usage
10
12
  from .._utils import generate_tool_call_id as _generate_tool_call_id, guard_tool_call_id as _guard_tool_call_id
11
13
  from ..messages import (
@@ -16,6 +18,7 @@ from ..messages import (
16
18
  RetryPromptPart,
17
19
  SystemPromptPart,
18
20
  TextPart,
21
+ ThinkingPart,
19
22
  ToolCallPart,
20
23
  ToolReturnPart,
21
24
  UserPromptPart,
@@ -187,7 +190,7 @@ class CohereModel(Model):
187
190
  # While Cohere's API returns a list, it only does that for future proofing
188
191
  # and currently only one item is being returned.
189
192
  choice = response.message.content[0]
190
- parts.append(TextPart(choice.text))
193
+ parts.extend(split_content_into_text_and_thinking(choice.text))
191
194
  for c in response.message.tool_calls or []:
192
195
  if c.function and c.function.name and c.function.arguments: # pragma: no branch
193
196
  parts.append(
@@ -211,6 +214,11 @@ class CohereModel(Model):
211
214
  for item in message.parts:
212
215
  if isinstance(item, TextPart):
213
216
  texts.append(item.content)
217
+ elif isinstance(item, ThinkingPart):
218
+ # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
219
+ # please open an issue. The below code is the code to send thinking to the provider.
220
+ # texts.append(f'<think>\n{item.content}\n</think>')
221
+ pass
214
222
  elif isinstance(item, ToolCallPart):
215
223
  tool_calls.append(self._map_tool_call(item))
216
224
  else:
@@ -24,6 +24,7 @@ from ..messages import (
24
24
  RetryPromptPart,
25
25
  SystemPromptPart,
26
26
  TextPart,
27
+ ThinkingPart,
27
28
  ToolCallPart,
28
29
  ToolReturnPart,
29
30
  UserContent,
@@ -268,6 +269,10 @@ def _estimate_usage(messages: Iterable[ModelMessage]) -> usage.Usage:
268
269
  for part in message.parts:
269
270
  if isinstance(part, TextPart):
270
271
  response_tokens += _estimate_string_tokens(part.content)
272
+ elif isinstance(part, ThinkingPart):
273
+ # NOTE: We don't send ThinkingPart to the providers yet.
274
+ # If you are unsatisfied with this, please open an issue.
275
+ pass
271
276
  elif isinstance(part, ToolCallPart):
272
277
  call = part
273
278
  response_tokens += 1 + _estimate_string_tokens(call.args_as_json_str())
@@ -27,6 +27,7 @@ from ..messages import (
27
27
  RetryPromptPart,
28
28
  SystemPromptPart,
29
29
  TextPart,
30
+ ThinkingPart,
30
31
  ToolCallPart,
31
32
  ToolReturnPart,
32
33
  UserPromptPart,
@@ -94,6 +95,15 @@ class GeminiModelSettings(ModelSettings, total=False):
94
95
  See the [Gemini API docs](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/add-labels-to-api-calls) for use cases and limitations.
95
96
  """
96
97
 
98
+ gemini_thinking_config: ThinkingConfig
99
+ """Thinking is on by default in both the API and AI Studio.
100
+
101
+ Being on by default doesn't mean the model will send back thoughts. For that, you need to set `include_thoughts`
102
+ to `True`. If you want to turn it off, set `thinking_budget` to `0`.
103
+
104
+ See more about it on <https://ai.google.dev/gemini-api/docs/thinking>.
105
+ """
106
+
97
107
 
98
108
  @dataclass(init=False)
99
109
  class GeminiModel(Model):
@@ -379,7 +389,7 @@ def _settings_to_generation_config(model_settings: GeminiModelSettings) -> _Gemi
379
389
  if (frequency_penalty := model_settings.get('frequency_penalty')) is not None:
380
390
  config['frequency_penalty'] = frequency_penalty
381
391
  if (thinkingConfig := model_settings.get('gemini_thinking_config')) is not None:
382
- config['thinking_config'] = thinkingConfig # pragma: no cover
392
+ config['thinking_config'] = thinkingConfig # pragma: lax no cover
383
393
  return config
384
394
 
385
395
 
@@ -576,6 +586,11 @@ def _content_model_response(m: ModelResponse) -> _GeminiContent:
576
586
  for item in m.parts:
577
587
  if isinstance(item, ToolCallPart):
578
588
  parts.append(_function_call_part_from_call(item))
589
+ elif isinstance(item, ThinkingPart):
590
+ # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
591
+ # please open an issue. The below code is the code to send thinking to the provider.
592
+ # parts.append(_GeminiTextPart(text=item.content, thought=True))
593
+ pass
579
594
  elif isinstance(item, TextPart):
580
595
  if item.content:
581
596
  parts.append(_GeminiTextPart(text=item.content))
@@ -584,29 +599,34 @@ def _content_model_response(m: ModelResponse) -> _GeminiContent:
584
599
  return _GeminiContent(role='model', parts=parts)
585
600
 
586
601
 
587
- class _GeminiTextPart(TypedDict):
602
+ class _BasePart(TypedDict):
603
+ thought: NotRequired[bool]
604
+ """Indicates if the part is thought from the model."""
605
+
606
+
607
+ class _GeminiTextPart(_BasePart):
588
608
  text: str
589
609
 
590
610
 
591
- class _GeminiInlineData(TypedDict):
611
+ class _GeminiInlineData(_BasePart):
592
612
  data: str
593
613
  mime_type: Annotated[str, pydantic.Field(alias='mimeType')]
594
614
 
595
615
 
596
- class _GeminiInlineDataPart(TypedDict):
616
+ class _GeminiInlineDataPart(_BasePart):
597
617
  """See <https://ai.google.dev/api/caching#Blob>."""
598
618
 
599
619
  inline_data: Annotated[_GeminiInlineData, pydantic.Field(alias='inlineData')]
600
620
 
601
621
 
602
- class _GeminiFileData(TypedDict):
622
+ class _GeminiFileData(_BasePart):
603
623
  """See <https://ai.google.dev/api/caching#FileData>."""
604
624
 
605
625
  file_uri: Annotated[str, pydantic.Field(alias='fileUri')]
606
626
  mime_type: Annotated[str, pydantic.Field(alias='mimeType')]
607
627
 
608
628
 
609
- class _GeminiFileDataPart(TypedDict):
629
+ class _GeminiFileDataPart(_BasePart):
610
630
  file_data: Annotated[_GeminiFileData, pydantic.Field(alias='fileData')]
611
631
 
612
632
 
@@ -615,7 +635,7 @@ class _GeminiThoughtPart(TypedDict):
615
635
  thought_signature: Annotated[str, pydantic.Field(alias='thoughtSignature')]
616
636
 
617
637
 
618
- class _GeminiFunctionCallPart(TypedDict):
638
+ class _GeminiFunctionCallPart(_BasePart):
619
639
  function_call: Annotated[_GeminiFunctionCall, pydantic.Field(alias='functionCall')]
620
640
 
621
641
 
@@ -633,7 +653,12 @@ def _process_response_from_parts(
633
653
  items: list[ModelResponsePart] = []
634
654
  for part in parts:
635
655
  if 'text' in part:
636
- items.append(TextPart(content=part['text']))
656
+ # NOTE: Google doesn't include the `thought` field anymore. We handle this here in case they decide to
657
+ # change their mind and start including it again.
658
+ if part.get('thought'): # pragma: no cover
659
+ items.append(ThinkingPart(content=part['text']))
660
+ else:
661
+ items.append(TextPart(content=part['text']))
637
662
  elif 'function_call' in part:
638
663
  items.append(ToolCallPart(tool_name=part['function_call']['name'], args=part['function_call']['args']))
639
664
  elif 'function_response' in part: # pragma: no cover
@@ -23,6 +23,7 @@ from ..messages import (
23
23
  RetryPromptPart,
24
24
  SystemPromptPart,
25
25
  TextPart,
26
+ ThinkingPart,
26
27
  ToolCallPart,
27
28
  ToolReturnPart,
28
29
  UserPromptPart,
@@ -413,7 +414,10 @@ class GeminiStreamedResponse(StreamedResponse):
413
414
  assert candidate.content.parts is not None
414
415
  for part in candidate.content.parts:
415
416
  if part.text is not None:
416
- yield self._parts_manager.handle_text_delta(vendor_part_id='content', content=part.text)
417
+ if part.thought:
418
+ yield self._parts_manager.handle_thinking_delta(vendor_part_id='thinking', content=part.text)
419
+ else:
420
+ yield self._parts_manager.handle_text_delta(vendor_part_id='content', content=part.text)
417
421
  elif part.function_call:
418
422
  maybe_event = self._parts_manager.handle_tool_call_delta(
419
423
  vendor_part_id=uuid4(),
@@ -446,6 +450,11 @@ def _content_model_response(m: ModelResponse) -> ContentDict:
446
450
  elif isinstance(item, TextPart):
447
451
  if item.content: # pragma: no branch
448
452
  parts.append({'text': item.content})
453
+ elif isinstance(item, ThinkingPart): # pragma: no cover
454
+ # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
455
+ # please open an issue. The below code is the code to send thinking to the provider.
456
+ # parts.append({'text': item.content, 'thought': True})
457
+ pass
449
458
  else:
450
459
  assert_never(item)
451
460
  return ContentDict(role='model', parts=parts)
@@ -461,7 +470,10 @@ def _process_response_from_parts(
461
470
  items: list[ModelResponsePart] = []
462
471
  for part in parts:
463
472
  if part.text is not None:
464
- items.append(TextPart(content=part.text))
473
+ if part.thought:
474
+ items.append(ThinkingPart(content=part.text))
475
+ else:
476
+ items.append(TextPart(content=part.text))
465
477
  elif part.function_call:
466
478
  assert part.function_call.name is not None
467
479
  tool_call_part = ToolCallPart(tool_name=part.function_call.name, args=part.function_call.args)
@@ -9,6 +9,8 @@ from typing import Literal, Union, cast, overload
9
9
 
10
10
  from typing_extensions import assert_never
11
11
 
12
+ from pydantic_ai._thinking_part import split_content_into_text_and_thinking
13
+
12
14
  from .. import ModelHTTPError, UnexpectedModelBehavior, _utils, usage
13
15
  from .._utils import guard_tool_call_id as _guard_tool_call_id, number_to_datetime
14
16
  from ..messages import (
@@ -23,6 +25,7 @@ from ..messages import (
23
25
  RetryPromptPart,
24
26
  SystemPromptPart,
25
27
  TextPart,
28
+ ThinkingPart,
26
29
  ToolCallPart,
27
30
  ToolReturnPart,
28
31
  UserPromptPart,
@@ -95,7 +98,7 @@ class GroqModelSettings(ModelSettings, total=False):
95
98
  ALL FIELDS MUST BE `groq_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS.
96
99
  """
97
100
 
98
- # This class is a placeholder for any future groq-specific settings
101
+ groq_reasoning_format: Literal['hidden', 'raw', 'parsed']
99
102
 
100
103
 
101
104
  @dataclass(init=False)
@@ -234,6 +237,7 @@ class GroqModel(Model):
234
237
  timeout=model_settings.get('timeout', NOT_GIVEN),
235
238
  seed=model_settings.get('seed', NOT_GIVEN),
236
239
  presence_penalty=model_settings.get('presence_penalty', NOT_GIVEN),
240
+ reasoning_format=model_settings.get('groq_reasoning_format', NOT_GIVEN),
237
241
  frequency_penalty=model_settings.get('frequency_penalty', NOT_GIVEN),
238
242
  logit_bias=model_settings.get('logit_bias', NOT_GIVEN),
239
243
  extra_headers=extra_headers,
@@ -249,8 +253,12 @@ class GroqModel(Model):
249
253
  timestamp = number_to_datetime(response.created)
250
254
  choice = response.choices[0]
251
255
  items: list[ModelResponsePart] = []
256
+ # NOTE: The `reasoning` field is only present if `groq_reasoning_format` is set to `parsed`.
257
+ if choice.message.reasoning is not None:
258
+ items.append(ThinkingPart(content=choice.message.reasoning))
252
259
  if choice.message.content is not None:
253
- items.append(TextPart(content=choice.message.content))
260
+ # NOTE: The `<think>` tag is only present if `groq_reasoning_format` is set to `raw`.
261
+ items.extend(split_content_into_text_and_thinking(choice.message.content))
254
262
  if choice.message.tool_calls is not None:
255
263
  for c in choice.message.tool_calls:
256
264
  items.append(ToolCallPart(tool_name=c.function.name, args=c.function.arguments, tool_call_id=c.id))
@@ -293,6 +301,9 @@ class GroqModel(Model):
293
301
  texts.append(item.content)
294
302
  elif isinstance(item, ToolCallPart):
295
303
  tool_calls.append(self._map_tool_call(item))
304
+ elif isinstance(item, ThinkingPart):
305
+ # Skip thinking parts when mapping to Groq messages
306
+ continue
296
307
  else:
297
308
  assert_never(item)
298
309
  message_param = chat.ChatCompletionAssistantMessageParam(role='assistant')
@@ -134,7 +134,7 @@ class InstrumentationSettings:
134
134
  **tokens_histogram_kwargs,
135
135
  explicit_bucket_boundaries_advisory=TOKEN_HISTOGRAM_BOUNDARIES,
136
136
  )
137
- except TypeError:
137
+ except TypeError: # pragma: lax no cover
138
138
  # Older OTel/logfire versions don't support explicit_bucket_boundaries_advisory
139
139
  self.tokens_histogram = self.meter.create_histogram(
140
140
  **tokens_histogram_kwargs, # pyright: ignore
@@ -11,6 +11,8 @@ import pydantic_core
11
11
  from httpx import Timeout
12
12
  from typing_extensions import assert_never
13
13
 
14
+ from pydantic_ai._thinking_part import split_content_into_text_and_thinking
15
+
14
16
  from .. import ModelHTTPError, UnexpectedModelBehavior, _utils
15
17
  from .._utils import generate_tool_call_id as _generate_tool_call_id, now_utc as _now_utc, number_to_datetime
16
18
  from ..messages import (
@@ -25,6 +27,7 @@ from ..messages import (
25
27
  RetryPromptPart,
26
28
  SystemPromptPart,
27
29
  TextPart,
30
+ ThinkingPart,
28
31
  ToolCallPart,
29
32
  ToolReturnPart,
30
33
  UserPromptPart,
@@ -322,7 +325,7 @@ class MistralModel(Model):
322
325
 
323
326
  parts: list[ModelResponsePart] = []
324
327
  if text := _map_content(content):
325
- parts.append(TextPart(content=text))
328
+ parts.extend(split_content_into_text_and_thinking(text))
326
329
 
327
330
  if isinstance(tool_calls, list):
328
331
  for tool_call in tool_calls:
@@ -484,6 +487,11 @@ class MistralModel(Model):
484
487
  for part in message.parts:
485
488
  if isinstance(part, TextPart):
486
489
  content_chunks.append(MistralTextChunk(text=part.content))
490
+ elif isinstance(part, ThinkingPart):
491
+ # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
492
+ # please open an issue. The below code is the code to send thinking to the provider.
493
+ # content_chunks.append(MistralTextChunk(text=f'<think>{part.content}</think>'))
494
+ pass
487
495
  elif isinstance(part, ToolCallPart):
488
496
  tool_calls.append(self._map_tool_call(part))
489
497
  else:
@@ -10,6 +10,7 @@ from typing import Any, Literal, Union, cast, overload
10
10
 
11
11
  from typing_extensions import assert_never
12
12
 
13
+ from pydantic_ai._thinking_part import split_content_into_text_and_thinking
13
14
  from pydantic_ai.profiles.openai import OpenAIModelProfile
14
15
  from pydantic_ai.providers import Provider, infer_provider
15
16
 
@@ -28,6 +29,7 @@ from ..messages import (
28
29
  RetryPromptPart,
29
30
  SystemPromptPart,
30
31
  TextPart,
32
+ ThinkingPart,
31
33
  ToolCallPart,
32
34
  ToolReturnPart,
33
35
  UserPromptPart,
@@ -137,6 +139,9 @@ class OpenAIResponsesModelSettings(OpenAIModelSettings, total=False):
137
139
  """
138
140
 
139
141
  openai_reasoning_generate_summary: Literal['detailed', 'concise']
142
+ """Deprecated alias for `openai_reasoning_summary`."""
143
+
144
+ openai_reasoning_summary: Literal['detailed', 'concise']
140
145
  """A summary of the reasoning performed by the model.
141
146
 
142
147
  This can be useful for debugging and understanding the model's reasoning process.
@@ -325,6 +330,10 @@ class OpenAIModel(Model):
325
330
  timestamp = number_to_datetime(response.created)
326
331
  choice = response.choices[0]
327
332
  items: list[ModelResponsePart] = []
333
+ # The `reasoning_content` is only present in DeepSeek models.
334
+ if reasoning_content := getattr(choice.message, 'reasoning_content', None):
335
+ items.append(ThinkingPart(content=reasoning_content))
336
+
328
337
  vendor_details: dict[str, Any] | None = None
329
338
 
330
339
  # Add logprobs to vendor_details if available
@@ -345,7 +354,7 @@ class OpenAIModel(Model):
345
354
  }
346
355
 
347
356
  if choice.message.content is not None:
348
- items.append(TextPart(choice.message.content))
357
+ items.extend(split_content_into_text_and_thinking(choice.message.content))
349
358
  if choice.message.tool_calls is not None:
350
359
  for c in choice.message.tool_calls:
351
360
  part = ToolCallPart(c.function.name, c.function.arguments, tool_call_id=c.id)
@@ -394,6 +403,11 @@ class OpenAIModel(Model):
394
403
  for item in message.parts:
395
404
  if isinstance(item, TextPart):
396
405
  texts.append(item.content)
406
+ elif isinstance(item, ThinkingPart):
407
+ # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
408
+ # please open an issue. The below code is the code to send thinking to the provider.
409
+ # texts.append(f'<think>\n{item.content}\n</think>')
410
+ pass
397
411
  elif isinstance(item, ToolCallPart):
398
412
  tool_calls.append(self._map_tool_call(item))
399
413
  else:
@@ -611,7 +625,12 @@ class OpenAIResponsesModel(Model):
611
625
  items: list[ModelResponsePart] = []
612
626
  items.append(TextPart(response.output_text))
613
627
  for item in response.output:
614
- if item.type == 'function_call':
628
+ if item.type == 'reasoning':
629
+ for summary in item.summary:
630
+ # NOTE: We use the same id for all summaries because we can merge them on the round trip.
631
+ # The providers don't force the signature to be unique.
632
+ items.append(ThinkingPart(content=summary.text, id=item.id))
633
+ elif item.type == 'function_call':
615
634
  items.append(ToolCallPart(item.name, item.arguments, tool_call_id=item.call_id))
616
635
  return ModelResponse(
617
636
  items,
@@ -710,11 +729,22 @@ class OpenAIResponsesModel(Model):
710
729
 
711
730
  def _get_reasoning(self, model_settings: OpenAIResponsesModelSettings) -> Reasoning | NotGiven:
712
731
  reasoning_effort = model_settings.get('openai_reasoning_effort', None)
732
+ reasoning_summary = model_settings.get('openai_reasoning_summary', None)
713
733
  reasoning_generate_summary = model_settings.get('openai_reasoning_generate_summary', None)
714
734
 
715
- if reasoning_effort is None and reasoning_generate_summary is None:
735
+ if reasoning_summary and reasoning_generate_summary: # pragma: no cover
736
+ raise ValueError('`openai_reasoning_summary` and `openai_reasoning_generate_summary` cannot both be set.')
737
+
738
+ if reasoning_generate_summary is not None: # pragma: no cover
739
+ warnings.warn(
740
+ '`openai_reasoning_generate_summary` is deprecated, use `openai_reasoning_summary` instead',
741
+ DeprecationWarning,
742
+ )
743
+ reasoning_summary = reasoning_generate_summary
744
+
745
+ if reasoning_effort is None and reasoning_summary is None:
716
746
  return NOT_GIVEN
717
- return Reasoning(effort=reasoning_effort, generate_summary=reasoning_generate_summary)
747
+ return Reasoning(effort=reasoning_effort, summary=reasoning_summary)
718
748
 
719
749
  def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[responses.FunctionToolParam]:
720
750
  tools = [self._map_tool_definition(r) for r in model_request_parameters.function_tools]
@@ -770,11 +800,30 @@ class OpenAIResponsesModel(Model):
770
800
  else:
771
801
  assert_never(part)
772
802
  elif isinstance(message, ModelResponse):
803
+ # last_thinking_part_idx: int | None = None
773
804
  for item in message.parts:
774
805
  if isinstance(item, TextPart):
775
806
  openai_messages.append(responses.EasyInputMessageParam(role='assistant', content=item.content))
776
807
  elif isinstance(item, ToolCallPart):
777
808
  openai_messages.append(self._map_tool_call(item))
809
+ elif isinstance(item, ThinkingPart):
810
+ # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
811
+ # please open an issue. The below code is the code to send thinking to the provider.
812
+ # if last_thinking_part_idx is not None:
813
+ # reasoning_item = cast(responses.ResponseReasoningItemParam, openai_messages[last_thinking_part_idx]) # fmt: skip
814
+ # if item.id == reasoning_item['id']:
815
+ # assert isinstance(reasoning_item['summary'], list)
816
+ # reasoning_item['summary'].append(Summary(text=item.content, type='summary_text'))
817
+ # continue
818
+ # last_thinking_part_idx = len(openai_messages)
819
+ # openai_messages.append(
820
+ # responses.ResponseReasoningItemParam(
821
+ # id=item.id or generate_tool_call_id(),
822
+ # summary=[Summary(text=item.content, type='summary_text')],
823
+ # type='reasoning',
824
+ # )
825
+ # )
826
+ pass
778
827
  else:
779
828
  assert_never(item)
780
829
  else:
@@ -948,13 +997,43 @@ class OpenAIResponsesStreamedResponse(StreamedResponse):
948
997
  vendor_part_id=chunk.item.id,
949
998
  tool_name=chunk.item.name,
950
999
  args=chunk.item.arguments,
951
- tool_call_id=chunk.item.id,
1000
+ tool_call_id=chunk.item.call_id,
1001
+ )
1002
+ elif isinstance(chunk.item, responses.ResponseReasoningItem):
1003
+ content = chunk.item.summary[0].text if chunk.item.summary else ''
1004
+ yield self._parts_manager.handle_thinking_delta(
1005
+ vendor_part_id=chunk.item.id,
1006
+ content=content,
1007
+ signature=chunk.item.id,
1008
+ )
1009
+ elif isinstance(chunk.item, responses.ResponseOutputMessage):
1010
+ pass
1011
+ else:
1012
+ warnings.warn( # pragma: no cover
1013
+ f'Handling of this item type is not yet implemented. Please report on our GitHub: {chunk}',
1014
+ UserWarning,
952
1015
  )
953
1016
 
954
1017
  elif isinstance(chunk, responses.ResponseOutputItemDoneEvent):
955
1018
  # NOTE: We only need this if the tool call deltas don't include the final info.
956
1019
  pass
957
1020
 
1021
+ elif isinstance(chunk, responses.ResponseReasoningSummaryPartAddedEvent):
1022
+ pass # there's nothing we need to do here
1023
+
1024
+ elif isinstance(chunk, responses.ResponseReasoningSummaryPartDoneEvent):
1025
+ pass # there's nothing we need to do here
1026
+
1027
+ elif isinstance(chunk, responses.ResponseReasoningSummaryTextDoneEvent):
1028
+ pass # there's nothing we need to do here
1029
+
1030
+ elif isinstance(chunk, responses.ResponseReasoningSummaryTextDeltaEvent):
1031
+ yield self._parts_manager.handle_thinking_delta(
1032
+ vendor_part_id=chunk.item_id,
1033
+ content=chunk.delta,
1034
+ signature=chunk.item_id,
1035
+ )
1036
+
958
1037
  elif isinstance(chunk, responses.ResponseTextDeltaEvent):
959
1038
  yield self._parts_manager.handle_text_delta(vendor_part_id=chunk.content_index, content=chunk.delta)
960
1039
 
@@ -9,6 +9,7 @@ from datetime import date, datetime, timedelta
9
9
  from typing import Any, Literal
10
10
 
11
11
  import pydantic_core
12
+ from typing_extensions import assert_never
12
13
 
13
14
  from .. import _utils
14
15
  from ..messages import (
@@ -19,17 +20,14 @@ from ..messages import (
19
20
  ModelResponseStreamEvent,
20
21
  RetryPromptPart,
21
22
  TextPart,
23
+ ThinkingPart,
22
24
  ToolCallPart,
23
25
  ToolReturnPart,
24
26
  )
25
27
  from ..settings import ModelSettings
26
28
  from ..tools import ToolDefinition
27
29
  from ..usage import Usage
28
- from . import (
29
- Model,
30
- ModelRequestParameters,
31
- StreamedResponse,
32
- )
30
+ from . import Model, ModelRequestParameters, StreamedResponse
33
31
  from .function import _estimate_string_tokens, _estimate_usage # pyright: ignore[reportPrivateUsage]
34
32
 
35
33
 
@@ -254,10 +252,15 @@ class TestStreamedResponse(StreamedResponse):
254
252
  for word in words:
255
253
  self._usage += _get_string_usage(word)
256
254
  yield self._parts_manager.handle_text_delta(vendor_part_id=i, content=word)
257
- else:
255
+ elif isinstance(part, ToolCallPart):
258
256
  yield self._parts_manager.handle_tool_call_part(
259
257
  vendor_part_id=i, tool_name=part.tool_name, args=part.args, tool_call_id=part.tool_call_id
260
258
  )
259
+ elif isinstance(part, ThinkingPart): # pragma: no cover
260
+ # NOTE: There's no way to reach this part of the code, since we don't generate ThinkingPart on TestModel.
261
+ assert False, "This should be unreachable — we don't generate ThinkingPart on TestModel."
262
+ else:
263
+ assert_never(part)
261
264
 
262
265
  @property
263
266
  def model_name(self) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydantic-ai-slim
3
- Version: 0.2.20
3
+ Version: 0.3.0
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,15 +30,15 @@ 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.20
33
+ Requires-Dist: pydantic-graph==0.3.0
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.20; extra == 'a2a'
37
+ Requires-Dist: fasta2a==0.3.0; extra == 'a2a'
38
38
  Provides-Extra: anthropic
39
39
  Requires-Dist: anthropic>=0.52.0; extra == 'anthropic'
40
40
  Provides-Extra: bedrock
41
- Requires-Dist: boto3>=1.35.74; extra == 'bedrock'
41
+ Requires-Dist: boto3>=1.37.24; extra == 'bedrock'
42
42
  Provides-Extra: cli
43
43
  Requires-Dist: argcomplete>=3.5.0; extra == 'cli'
44
44
  Requires-Dist: prompt-toolkit>=3; extra == 'cli'
@@ -48,11 +48,11 @@ 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.20; extra == 'evals'
51
+ Requires-Dist: pydantic-evals==0.3.0; extra == 'evals'
52
52
  Provides-Extra: google
53
53
  Requires-Dist: google-genai>=1.15.0; extra == 'google'
54
54
  Provides-Extra: groq
55
- Requires-Dist: groq>=0.15.0; extra == 'groq'
55
+ Requires-Dist: groq>=0.19.0; extra == 'groq'
56
56
  Provides-Extra: logfire
57
57
  Requires-Dist: logfire>=3.11.0; extra == 'logfire'
58
58
  Provides-Extra: mcp
@@ -60,7 +60,7 @@ Requires-Dist: mcp>=1.9.4; (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
63
- Requires-Dist: openai>=1.75.0; extra == 'openai'
63
+ Requires-Dist: openai>=1.76.0; extra == 'openai'
64
64
  Provides-Extra: tavily
65
65
  Requires-Dist: tavily-python>=0.5.0; extra == 'tavily'
66
66
  Provides-Extra: vertexai
@@ -1,13 +1,14 @@
1
1
  pydantic_ai/__init__.py,sha256=5flxyMQJVrHRMQ3MYaZf1el2ctNs0JmPClKbw2Q-Lsk,1160
2
2
  pydantic_ai/__main__.py,sha256=Q_zJU15DUA01YtlJ2mnaLCoId2YmgmreVEERGuQT-Y0,132
3
3
  pydantic_ai/_a2a.py,sha256=8nNtx6GENDt2Ej3f1ui9L-FuNQBYVELpJFfwz-y7fUw,7234
4
- pydantic_ai/_agent_graph.py,sha256=ZDXFG1zNlj9_n3_YYR7JTN9d0Eiqlx8viojaXCePeMk,39142
4
+ pydantic_ai/_agent_graph.py,sha256=8Y1xEFwNCp0hVsRst_2I4WQymeTFRJ4Ec5-lURMd5HQ,39671
5
5
  pydantic_ai/_cli.py,sha256=kc9UxGjYsKK0IR4No-V5BGiAtq2fY6eZZ9rBkAdHWOM,12948
6
6
  pydantic_ai/_function_schema.py,sha256=VXHGnudrpyW40UJqCopgSUB_IuSip5pEEBSLGhVEuFI,10846
7
7
  pydantic_ai/_griffe.py,sha256=Sf_DisE9k2TA0VFeVIK2nf1oOct5MygW86PBCACJkFA,5244
8
8
  pydantic_ai/_output.py,sha256=HYhcaqcisU16PT_EFdl2VuV5MI-nRFbUPzijd_rTTgM,16787
9
- pydantic_ai/_parts_manager.py,sha256=c0Gj29FH8K20AmxIr7MY8_SQVdb7SRIRcJYTQVmVYgc,12204
9
+ pydantic_ai/_parts_manager.py,sha256=Lioi8b7Nfyax09yQu8jTkMzxd26dYDrdAqhYvjRSKqQ,16182
10
10
  pydantic_ai/_system_prompt.py,sha256=W5wYN6rH5JCshl1xI2s0ygevBCutCraqyG6t75yZubk,1117
11
+ pydantic_ai/_thinking_part.py,sha256=mzx2RZSfiQxAKpljEflrcXRXmFKxtp6bKVyorY3UYZk,1554
11
12
  pydantic_ai/_utils.py,sha256=qi2NjYpIVOgCHDMPgyV8oUL42Fv2_rLyj8KdOUO5fQU,11319
12
13
  pydantic_ai/agent.py,sha256=oNW5ffihOF1Kn13N3GZ9wudyooGxN0O3r1wGJAwYUMY,94448
13
14
  pydantic_ai/direct.py,sha256=tXRcQ3fMkykaawO51VxnSwQnqcEmu1LhCy7U9gOyM-g,7768
@@ -15,7 +16,7 @@ pydantic_ai/exceptions.py,sha256=IdFw594Ou7Vn4YFa7xdZ040_j_6nmyA3MPANbC7sys4,317
15
16
  pydantic_ai/format_as_xml.py,sha256=IINfh1evWDphGahqHNLBArB5dQ4NIqS3S-kru35ztGg,372
16
17
  pydantic_ai/format_prompt.py,sha256=qdKep95Sjlr7u1-qag4JwPbjoURbG0GbeU_l5ODTNw4,4466
17
18
  pydantic_ai/mcp.py,sha256=OkbwSBODgeC4BX2QIvTmECZJbeSYtjZ15ZPnEyf95UI,20157
18
- pydantic_ai/messages.py,sha256=UraSZP4yTSQa0UnFfBrnD5m80GXNY1hf6YE72F5wz4A,33350
19
+ pydantic_ai/messages.py,sha256=Z8cNpaEcMgdJpyE9ydBLBDJV0A-Hf-GllLAWeUKY4_0,36124
19
20
  pydantic_ai/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
21
  pydantic_ai/result.py,sha256=YlcR0QAQIejz3fbZ50zYfHKIZco0dwmnZTxytV-n3oM,24609
21
22
  pydantic_ai/settings.py,sha256=eRJs2fI2yaIrhtYRlWqKlC9KnFaJHvslgSll8NQ20jc,3533
@@ -27,18 +28,18 @@ pydantic_ai/common_tools/tavily.py,sha256=Q1xxSF5HtXAaZ10Pp-OaDOHXwJf2mco9wScGEQ
27
28
  pydantic_ai/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
29
  pydantic_ai/ext/langchain.py,sha256=TI8B6eBjEGKFfvwyLgC_-0eeba4hDJq7wLZ0OZhbiWw,1967
29
30
  pydantic_ai/models/__init__.py,sha256=LhBw4yxIEMByJPthAiWtQwGgNlj3cQkOaX6wtzeMFjA,27947
30
- pydantic_ai/models/anthropic.py,sha256=E7vVhCkw474aKWTzn5UZOlwEV8xgjeFrjJo9HbcCltc,21402
31
- pydantic_ai/models/bedrock.py,sha256=OVuPe_gQ1KHzvN9k5w0IZjdY3uGbQ1LqDAeaC9ffBlE,27944
32
- pydantic_ai/models/cohere.py,sha256=bQLTQguqVXkzkPgWmMncrxApO9CQ7YtgrmFwa057g7g,12116
31
+ pydantic_ai/models/anthropic.py,sha256=s7yvNObBfS-gcXLT0vU8UXjLHITsbr5kkXgP1SYkPms,23832
32
+ pydantic_ai/models/bedrock.py,sha256=67qf_mFnx0kfmKoI96zLOAUn3P47PxPqMrQsaYUrJJ0,29120
33
+ pydantic_ai/models/cohere.py,sha256=UU04-_O-KLgC4DUpM-g4FBPoTOatbmVJJ7mkZNBGsbQ,12626
33
34
  pydantic_ai/models/fallback.py,sha256=idOYGMo3CZzpCBT8DDiuPAAgnV2jzluDUq3ESb3KteM,4981
34
- pydantic_ai/models/function.py,sha256=rnihsyakyieCGbEyxfqzvoBHnR_3LJn4x6DXQqdAAM4,11458
35
- pydantic_ai/models/gemini.py,sha256=6tjI0aQgzW4i6qtAZzzjDLAkre-mfLywybjaFos8hbA,36199
36
- pydantic_ai/models/google.py,sha256=ubEHm8PyGziJO0IpPjElPtqbKxc6ZM0F1IBnTt2t1rA,21628
37
- pydantic_ai/models/groq.py,sha256=rnAwTJ5AXgspqSqi2nJPlqj7sOeZ8H04XNs5cWJqKE4,17816
38
- pydantic_ai/models/instrumented.py,sha256=DvBNxgkxmMGeUQvBUJUAfRCpgXpJzQhNNe_M5TAVCbw,15679
39
- pydantic_ai/models/mistral.py,sha256=teNN6IfTTpfZxtSXFdD2M1IDrDj0_EHJt-9gDmAKLvg,29563
40
- pydantic_ai/models/openai.py,sha256=l1TZ2ZR7zmga2yexGVElbkCGnY35yHSTKU8CQvPRGjQ,44996
41
- pydantic_ai/models/test.py,sha256=Jlq-YQ9dhzENgmBMVerZpM4L-I2aPf7HH7ifIncyDlE,17010
35
+ pydantic_ai/models/function.py,sha256=xvN_oNKw0X4c16oe1l3MX2_kJtFWMOMaseMNO6eNBYI,11709
36
+ pydantic_ai/models/gemini.py,sha256=d8HY9nc-tcuWFmA5OdKsWABMTpXq68sUL6xE8zY6dzs,37383
37
+ pydantic_ai/models/google.py,sha256=AVXC3CPG1aduGXSc0XFEYnrT6LsNKfNWp-kmf1SQssg,22294
38
+ pydantic_ai/models/groq.py,sha256=lojKRdvg0p-EtZ20Z2CS4I0goq4CoGkLj3LuYHA6o-I,18497
39
+ pydantic_ai/models/instrumented.py,sha256=vVq7mS071EXS2PZ3NJ4Zgt93iQgAscFr2dyg9fAeuCE,15703
40
+ pydantic_ai/models/mistral.py,sha256=LHm3F2yVKoE1uDjEPtTPug6duHwr4A42qey2Pncqqx4,30093
41
+ pydantic_ai/models/openai.py,sha256=onyJSKCo5zj_VY22RTQnPRE0Bpxu1ojgtftveQF_VQc,49633
42
+ pydantic_ai/models/test.py,sha256=X5QVCsBAWXxw4MKet-UTGZ0FteUnCHoK3Py3ngJM2Zk,17437
42
43
  pydantic_ai/models/wrapper.py,sha256=43ntRkTF7rVBYLC-Ihdo1fkwpeveOpA_1fXe1fd3W9Y,1690
43
44
  pydantic_ai/profiles/__init__.py,sha256=uO_f1kSqrnXuO0x5U0EHTTMRYcmOiOoa-tS1OZppxBk,1426
44
45
  pydantic_ai/profiles/_json_schema.py,sha256=3ofRGnBca9WzqlUbw0C1ywhv_V7eGTmFAf2O7Bs5zgk,7199
@@ -69,8 +70,8 @@ pydantic_ai/providers/mistral.py,sha256=EIUSENjFuGzBhvbdrarUTM4VPkesIMnZrzfnEKHO
69
70
  pydantic_ai/providers/openai.py,sha256=7iGij0EaFylab7dTZAZDgXr78tr-HsZrn9EI9AkWBNQ,3091
70
71
  pydantic_ai/providers/openrouter.py,sha256=NXjNdnlXIBrBMMqbzcWQnowXOuZh4NHikXenBn5h3mc,4061
71
72
  pydantic_ai/providers/together.py,sha256=zFVSMSm5jXbpkNouvBOTjWrPmlPpCp6sQS5LMSyVjrQ,3482
72
- pydantic_ai_slim-0.2.20.dist-info/METADATA,sha256=hni_XHSMACXOSmxZ0B1__3CM4FoWm_Wp0-NZ1PNlz7w,3850
73
- pydantic_ai_slim-0.2.20.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
74
- pydantic_ai_slim-0.2.20.dist-info/entry_points.txt,sha256=kbKxe2VtDCYS06hsI7P3uZGxcVC08-FPt1rxeiMpIps,50
75
- pydantic_ai_slim-0.2.20.dist-info/licenses/LICENSE,sha256=vA6Jc482lEyBBuGUfD1pYx-cM7jxvLYOxPidZ30t_PQ,1100
76
- pydantic_ai_slim-0.2.20.dist-info/RECORD,,
73
+ pydantic_ai_slim-0.3.0.dist-info/METADATA,sha256=GmMBkJvakRA_lUHh_jO941_uxk5JwGKgWNle0dLCAOQ,3846
74
+ pydantic_ai_slim-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
75
+ pydantic_ai_slim-0.3.0.dist-info/entry_points.txt,sha256=kbKxe2VtDCYS06hsI7P3uZGxcVC08-FPt1rxeiMpIps,50
76
+ pydantic_ai_slim-0.3.0.dist-info/licenses/LICENSE,sha256=vA6Jc482lEyBBuGUfD1pYx-cM7jxvLYOxPidZ30t_PQ,1100
77
+ pydantic_ai_slim-0.3.0.dist-info/RECORD,,