Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
473623b
support rae cot 1
dsfaccini Nov 25, 2025
ff4d45b
include live gpt-oss streaming test and remove computer-use model
dsfaccini Nov 25, 2025
9c8007f
simplify filter
dsfaccini Nov 25, 2025
88de1ed
note about test flakiness
dsfaccini Nov 25, 2025
364b711
re-add computer use names
dsfaccini Nov 25, 2025
4afd4b7
handle raw cot in parts manager
dsfaccini Nov 27, 2025
50bc7fa
Merge branch 'main' into lm-studio-openai-responses-with-gpt-oss
dsfaccini Nov 27, 2025
ddd7df4
Merge branch 'main' into lm-studio-openai-responses-with-gpt-oss
dsfaccini Nov 27, 2025
65ae9a5
refactor parts manager
dsfaccini Nov 27, 2025
d0c7d77
add defensive handling of potential summary after rawCoT
dsfaccini Nov 28, 2025
8d52d65
Clarify usage of agent factories
dsfaccini Nov 28, 2025
99812f8
migrate to callback
dsfaccini Nov 29, 2025
0a245b6
dont emit empty events
dsfaccini Nov 30, 2025
3128b4a
Merge branch 'pydantic:main' into main
dsfaccini Nov 30, 2025
dc7aa6a
Merge branch 'main' into lm-studio-openai-responses-with-gpt-oss
dsfaccini Dec 1, 2025
3b6013f
complex testcase
dsfaccini Dec 1, 2025
f87896a
improvde dostring
dsfaccini Dec 1, 2025
2c3d767
narrow docstring
dsfaccini Dec 1, 2025
e69a7c2
Clarify agent instantiation options in documentation
dsfaccini Dec 2, 2025
bc2e31e
address review points
dsfaccini Dec 4, 2025
d4a6c8b
Merge remote-tracking branch 'origin/main' into lm-studio-openai-resp…
dsfaccini Dec 4, 2025
d5f6503
Merge upstream/main into lm-studio-openai-responses-with-gpt-oss
dsfaccini Dec 4, 2025
c7d43bd
Merge branch 'main' into lm-studio-openai-responses-with-gpt-oss
dsfaccini Dec 4, 2025
85636a8
chain callables or dict mergings
dsfaccini Dec 4, 2025
53579d0
Merge branch 'main' into lm-studio-openai-responses-with-gpt-oss
dsfaccini Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/thinking.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ agent = Agent(model, model_settings=settings)
...
```

!!! note "Raw reasoning without summaries"
Some OpenAI-compatible APIs (such as LM Studio, vLLM, or OpenRouter with gpt-oss models) may return raw reasoning content without reasoning summaries. In this case, [`ThinkingPart.content`][pydantic_ai.messages.ThinkingPart.content] will be empty, but the raw reasoning is available in `provider_details['raw_content']`. Following [OpenAI's guidance](https://cookbook.openai.com/examples/responses_api/reasoning_items) that raw reasoning should not be shown directly to users, we store it in `provider_details` rather than in the main `content` field.

## Anthropic

To enable thinking, use the [`AnthropicModelSettings.anthropic_thinking`][pydantic_ai.models.anthropic.AnthropicModelSettings.anthropic_thinking] [model setting](agents.md#model-run-settings).
Expand Down
189 changes: 113 additions & 76 deletions pydantic_ai_slim/pydantic_ai/_parts_manager.py

Large diffs are not rendered by default.

34 changes: 18 additions & 16 deletions pydantic_ai_slim/pydantic_ai/models/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -1106,25 +1106,26 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
elif isinstance(event, BetaRawContentBlockStartEvent):
current_block = event.content_block
if isinstance(current_block, BetaTextBlock) and current_block.text:
maybe_event = self._parts_manager.handle_text_delta(
for event_ in self._parts_manager.handle_text_delta(
vendor_part_id=event.index, content=current_block.text
)
if maybe_event is not None: # pragma: no branch
yield maybe_event
):
yield event_
elif isinstance(current_block, BetaThinkingBlock):
yield self._parts_manager.handle_thinking_delta(
for event_ in self._parts_manager.handle_thinking_delta(
vendor_part_id=event.index,
content=current_block.thinking,
signature=current_block.signature,
provider_name=self.provider_name,
)
):
yield event_
elif isinstance(current_block, BetaRedactedThinkingBlock):
yield self._parts_manager.handle_thinking_delta(
for event_ in self._parts_manager.handle_thinking_delta(
vendor_part_id=event.index,
id='redacted_thinking',
signature=current_block.data,
provider_name=self.provider_name,
)
):
yield event_
elif isinstance(current_block, BetaToolUseBlock):
maybe_event = self._parts_manager.handle_tool_call_delta(
vendor_part_id=event.index,
Expand Down Expand Up @@ -1185,23 +1186,24 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:

elif isinstance(event, BetaRawContentBlockDeltaEvent):
if isinstance(event.delta, BetaTextDelta):
maybe_event = self._parts_manager.handle_text_delta(
for event_ in self._parts_manager.handle_text_delta(
vendor_part_id=event.index, content=event.delta.text
)
if maybe_event is not None: # pragma: no branch
yield maybe_event
):
yield event_
elif isinstance(event.delta, BetaThinkingDelta):
yield self._parts_manager.handle_thinking_delta(
for event_ in self._parts_manager.handle_thinking_delta(
vendor_part_id=event.index,
content=event.delta.thinking,
provider_name=self.provider_name,
)
):
yield event_
elif isinstance(event.delta, BetaSignatureDelta):
yield self._parts_manager.handle_thinking_delta(
for event_ in self._parts_manager.handle_thinking_delta(
vendor_part_id=event.index,
signature=event.delta.signature,
provider_name=self.provider_name,
)
):
yield event_
elif isinstance(event.delta, BetaInputJSONDelta):
maybe_event = self._parts_manager.handle_tool_call_delta(
vendor_part_id=event.index,
Expand Down
15 changes: 8 additions & 7 deletions pydantic_ai_slim/pydantic_ai/models/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,24 +751,25 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
delta = content_block_delta['delta']
if 'reasoningContent' in delta:
if redacted_content := delta['reasoningContent'].get('redactedContent'):
yield self._parts_manager.handle_thinking_delta(
for event in self._parts_manager.handle_thinking_delta(
vendor_part_id=index,
id='redacted_content',
signature=redacted_content.decode('utf-8'),
provider_name=self.provider_name,
)
):
yield event
else:
signature = delta['reasoningContent'].get('signature')
yield self._parts_manager.handle_thinking_delta(
for event in self._parts_manager.handle_thinking_delta(
vendor_part_id=index,
content=delta['reasoningContent'].get('text'),
signature=signature,
provider_name=self.provider_name if signature else None,
)
):
yield event
if text := delta.get('text'):
maybe_event = self._parts_manager.handle_text_delta(vendor_part_id=index, content=text)
if maybe_event is not None: # pragma: no branch
yield maybe_event
for event in self._parts_manager.handle_text_delta(vendor_part_id=index, content=text):
yield event
if 'toolUse' in delta:
tool_use = delta['toolUse']
maybe_event = self._parts_manager.handle_tool_call_delta(
Expand Down
12 changes: 6 additions & 6 deletions pydantic_ai_slim/pydantic_ai/models/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,26 +292,26 @@ class FunctionStreamedResponse(StreamedResponse):
def __post_init__(self):
self._usage += _estimate_usage([])

async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: # noqa: C901
async for item in self._iter:
if isinstance(item, str):
response_tokens = _estimate_string_tokens(item)
self._usage += usage.RequestUsage(output_tokens=response_tokens)
maybe_event = self._parts_manager.handle_text_delta(vendor_part_id='content', content=item)
if maybe_event is not None: # pragma: no branch
yield maybe_event
for event in self._parts_manager.handle_text_delta(vendor_part_id='content', content=item):
yield event
elif isinstance(item, dict) and item:
for dtc_index, delta in item.items():
if isinstance(delta, DeltaThinkingPart):
if delta.content: # pragma: no branch
response_tokens = _estimate_string_tokens(delta.content)
self._usage += usage.RequestUsage(output_tokens=response_tokens)
yield self._parts_manager.handle_thinking_delta(
for event in self._parts_manager.handle_thinking_delta(
vendor_part_id=dtc_index,
content=delta.content,
signature=delta.signature,
provider_name='function' if delta.signature else None,
)
):
yield event
elif isinstance(delta, DeltaToolCall):
if delta.json_args:
response_tokens = _estimate_string_tokens(delta.json_args)
Expand Down
7 changes: 3 additions & 4 deletions pydantic_ai_slim/pydantic_ai/models/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,11 +465,10 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
if 'text' in gemini_part:
# Using vendor_part_id=None means we can produce multiple text parts if their deltas are sprinkled
# amongst the tool call deltas
maybe_event = self._parts_manager.handle_text_delta(
for event in self._parts_manager.handle_text_delta(
vendor_part_id=None, content=gemini_part['text']
)
if maybe_event is not None: # pragma: no branch
yield maybe_event
):
yield event

elif 'function_call' in gemini_part:
# Here, we assume all function_call parts are complete and don't have deltas.
Expand Down
12 changes: 6 additions & 6 deletions pydantic_ai_slim/pydantic_ai/models/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,15 +722,15 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
if len(part.text) == 0 and not provider_details:
continue
if part.thought:
yield self._parts_manager.handle_thinking_delta(
for event in self._parts_manager.handle_thinking_delta(
vendor_part_id=None, content=part.text, provider_details=provider_details
)
):
yield event
else:
maybe_event = self._parts_manager.handle_text_delta(
for event in self._parts_manager.handle_text_delta(
vendor_part_id=None, content=part.text, provider_details=provider_details
)
if maybe_event is not None: # pragma: no branch
yield maybe_event
):
yield event
elif part.function_call:
maybe_event = self._parts_manager.handle_tool_call_delta(
vendor_part_id=uuid4(),
Expand Down
12 changes: 6 additions & 6 deletions pydantic_ai_slim/pydantic_ai/models/groq.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,9 +551,10 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
reasoning = True

# NOTE: The `reasoning` field is only present if `groq_reasoning_format` is set to `parsed`.
yield self._parts_manager.handle_thinking_delta(
for event in self._parts_manager.handle_thinking_delta(
vendor_part_id=f'reasoning-{reasoning_index}', content=choice.delta.reasoning
)
):
yield event
else:
reasoning = False

Expand All @@ -576,14 +577,13 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
# Handle the text part of the response
content = choice.delta.content
if content:
maybe_event = self._parts_manager.handle_text_delta(
for event in self._parts_manager.handle_text_delta(
vendor_part_id='content',
content=content,
thinking_tags=self._model_profile.thinking_tags,
ignore_leading_whitespace=self._model_profile.ignore_streamed_leading_whitespace,
)
if maybe_event is not None: # pragma: no branch
yield maybe_event
):
yield event

# Handle the tool calls
for dtc in choice.delta.tool_calls or []:
Expand Down
7 changes: 3 additions & 4 deletions pydantic_ai_slim/pydantic_ai/models/huggingface.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,14 +487,13 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
# Handle the text part of the response
content = choice.delta.content
if content:
maybe_event = self._parts_manager.handle_text_delta(
for event in self._parts_manager.handle_text_delta(
vendor_part_id='content',
content=content,
thinking_tags=self._model_profile.thinking_tags,
ignore_leading_whitespace=self._model_profile.ignore_streamed_leading_whitespace,
)
if maybe_event is not None: # pragma: no branch
yield maybe_event
):
yield event

for dtc in choice.delta.tool_calls or []:
maybe_event = self._parts_manager.handle_tool_call_delta(
Expand Down
8 changes: 4 additions & 4 deletions pydantic_ai_slim/pydantic_ai/models/mistral.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,8 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
content = choice.delta.content
text, thinking = _map_content(content)
for thought in thinking:
self._parts_manager.handle_thinking_delta(vendor_part_id='thinking', content=thought)
for event in self._parts_manager.handle_thinking_delta(vendor_part_id='thinking', content=thought):
yield event
Comment on lines -642 to +643
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this was a bug or this call was meant so simply update the parts manager, do you know @DouweM ? I ran the mistral tests against the API to double check it doesn't break.

if text:
# Attempt to produce an output tool call from the received text
output_tools = {c.name: c for c in self.model_request_parameters.output_tools}
Expand All @@ -655,9 +656,8 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
tool_call_id=maybe_tool_call_part.tool_call_id,
)
else:
maybe_event = self._parts_manager.handle_text_delta(vendor_part_id='content', content=text)
if maybe_event is not None: # pragma: no branch
yield maybe_event
for event in self._parts_manager.handle_text_delta(vendor_part_id='content', content=text):
yield event

# Handle the explicit tool calls
for index, dtc in enumerate(choice.delta.tool_calls or []):
Expand Down
Loading