Skip to content

Commit c37a657

Browse files
committed
Resolve review suggestions.
1 parent 31b3042 commit c37a657

File tree

9 files changed

+71
-74
lines changed

9 files changed

+71
-74
lines changed

docs/thinking.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@ See the sections below for how to enable thinking for each provider.
1111
When using the [`OpenAIChatModel`][pydantic_ai.models.openai.OpenAIChatModel], text output inside `<think>` tags are converted to [`ThinkingPart`][pydantic_ai.messages.ThinkingPart] objects.
1212
You can customize the tags using the [`thinking_tags`][pydantic_ai.profiles.ModelProfile.thinking_tags] field on the [model profile](models/openai.md#model-profile).
1313

14-
Some providers might also support native thinking parts that are not delimited by tags. Instead, they are sent and received as separate fields in the API. You can configure the fields with [`openai_chat_custom_reasoning_field`][pydantic_ai.profiles.openai.OpenAIModelProfile.openai_chat_custom_reasoning_field].
14+
Some [OpenAI-compatible model providers](models/openai.md#openai-compatible-models) might also support native thinking parts that are not delimited by tags. Instead, they are sent and received as separate, custom fields in the API. Typically, if you are calling the model via the `<provider>:<model>` shorthand, Pydantic AI handles it for you. Nonetheless, you can still configure the fields with [`openai_chat_custom_reasoning_field`][pydantic_ai.profiles.openai.OpenAIModelProfile.openai_chat_custom_reasoning_field].
1515

1616
If your provider recommends to send back these custom fields not changed, for caching or interleaved thinking benefits, you can also achieve this with [`openai_chat_include_reasoning_in_request`][pydantic_ai.profiles.openai.OpenAIModelProfile.openai_chat_include_reasoning_in_request].
1717

18-
And finally, if your provider generates reasoning parts in a somewhat complex `reasoning_details` field, you might want to look into [`OpenRouterModel`][pydantic_ai.models.openrouter.OpenRouterModel] which has built-in support for parsing such fields.
19-
2018
### OpenAI Responses
2119

2220
The [`OpenAIResponsesModel`][pydantic_ai.models.openai.OpenAIResponsesModel] can generate native thinking parts.

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -633,24 +633,24 @@ def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[Thinkin
633633
This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings.
634634
"""
635635
profile = OpenAIModelProfile.from_profile(self.profile)
636-
custom_field = profile.openai_chat_custom_reasoning_field
636+
custom_field = profile.openai_chat_custom_reasoning_field or ''
637637
items: list[ThinkingPart] = []
638638

639639
# Prefer the configured custom reasoning field, if present in profile.
640-
if custom_field:
641-
reasoning = getattr(message, custom_field, None)
640+
# Fall back to built-in fields if no custom field result was found.
641+
642+
# The `reasoning_content` field is typically present in DeepSeek and Moonshot models.
643+
# https://api-docs.deepseek.com/guides/reasoning_model
644+
645+
# The `reasoning` field is typically present in gpt-oss via Ollama and OpenRouter.
646+
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api
647+
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens
648+
for field_name in (custom_field, 'reasoning', 'reasoning_content'):
649+
reasoning: str | None = getattr(message, field_name, None)
642650
if reasoning: # pragma: no branch
643-
items.append(ThinkingPart(id=custom_field, content=reasoning, provider_name=self.system))
651+
items.append(ThinkingPart(id=field_name, content=reasoning, provider_name=self.system))
644652
return items
645653

646-
# Fall back to built-in fields if no custom field result was found.
647-
# This behavior is for backward compatibility with older models/profiles.
648-
for fallback_field in ('reasoning', 'reasoning_content'):
649-
reasoning = getattr(message, fallback_field, None)
650-
if reasoning:
651-
items.append(ThinkingPart(id=fallback_field, content=reasoning, provider_name=self.system))
652-
break
653-
654654
return items or None
655655

656656
async def _process_streamed_response(
@@ -759,10 +759,10 @@ def _into_message_param(self) -> chat.ChatCompletionAssistantMessageParam:
759759
message_param = chat.ChatCompletionAssistantMessageParam(role='assistant')
760760
# Note: model responses from this model should only have one text item, so the following
761761
# shouldn't merge multiple texts into one unless you switch models between runs:
762-
if profile.openai_chat_include_reasoning_in_request == 'separated' and self.thinkings:
762+
if profile.openai_chat_send_back_thinking_parts == 'custom_field' and self.thinkings:
763763
field = profile.openai_chat_custom_reasoning_field
764764
if field: # pragma: no branch (handled by profile validation)
765-
message_param[field] = '\n\n'.join(self.thinkings) # pyright: ignore[reportGeneralTypeIssues]
765+
message_param[field] = '\n\n'.join(self.thinkings)
766766
if self.texts:
767767
message_param['content'] = '\n\n'.join(self.texts)
768768
else:
@@ -786,11 +786,11 @@ def _map_response_thinking_part(self, item: ThinkingPart) -> None:
786786
to implement custom logic for handling thinking parts.
787787
"""
788788
profile = OpenAIModelProfile.from_profile(self._model.profile)
789-
include_method = profile.openai_chat_include_reasoning_in_request
790-
if include_method == 'combined':
789+
include_method = profile.openai_chat_send_back_thinking_parts
790+
if include_method == 'thinking_tags':
791791
start_tag, end_tag = self._model.profile.thinking_tags
792792
self.texts.append('\n'.join([start_tag, item.content, end_tag]))
793-
elif include_method == 'separated':
793+
elif include_method == 'custom_field':
794794
self.thinkings.append(item.content)
795795

796796
def _map_response_tool_call_part(self, item: ToolCallPart) -> None:
@@ -1885,24 +1885,22 @@ def _map_thinking_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[
18851885
custom_field = profile.openai_chat_custom_reasoning_field
18861886

18871887
# Prefer the configured custom reasoning field, if present in profile.
1888-
if custom_field:
1889-
reasoning = getattr(choice.delta, custom_field, None)
1890-
if reasoning:
1891-
yield self._parts_manager.handle_thinking_delta(
1892-
vendor_part_id=custom_field,
1893-
id=custom_field,
1894-
content=reasoning,
1895-
provider_name=self.provider_name,
1896-
)
1897-
18981888
# Fall back to built-in fields if no custom field result was found.
1899-
# This behavior is for backward compatibility with older models/profiles.
1900-
for fallback_field in ('reasoning', 'reasoning_content'):
1901-
reasoning = getattr(choice.delta, fallback_field, None)
1902-
if reasoning:
1889+
1890+
# The `reasoning_content` field is typically present in DeepSeek and Moonshot models.
1891+
# https://api-docs.deepseek.com/guides/reasoning_model
1892+
1893+
# The `reasoning` field is typically present in gpt-oss via Ollama and OpenRouter.
1894+
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api
1895+
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens
1896+
for field_name in (custom_field, 'reasoning', 'reasoning_content'):
1897+
if not field_name:
1898+
continue
1899+
reasoning: str | None = getattr(choice.delta, field_name, None)
1900+
if reasoning: # pragma: no branch
19031901
yield self._parts_manager.handle_thinking_delta(
1904-
vendor_part_id=fallback_field,
1905-
id=fallback_field,
1902+
vendor_part_id=field_name,
1903+
id=field_name,
19061904
content=reasoning,
19071905
provider_name=self.provider_name,
19081906
)
Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
from __future__ import annotations as _annotations
22

3-
from .openai import OpenAIModelProfile
3+
from . import ModelProfile
44

55

6-
def deepseek_model_profile(model_name: str) -> OpenAIModelProfile | None:
6+
def deepseek_model_profile(model_name: str) -> ModelProfile | None:
77
"""Get the model profile for a DeepSeek model."""
8-
return OpenAIModelProfile(
9-
ignore_streamed_leading_whitespace='r1' in model_name,
10-
openai_chat_custom_reasoning_field='reasoning_content',
11-
# For compatibility with existing behavior. May want to change later.
12-
openai_chat_include_reasoning_in_request='combined',
13-
)
8+
return ModelProfile(ignore_streamed_leading_whitespace='r1' in model_name)
Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
from __future__ import annotations as _annotations
22

3-
from .openai import OpenAIModelProfile
3+
from . import ModelProfile
44

55

6-
def moonshotai_model_profile(model_name: str) -> OpenAIModelProfile | None:
6+
def moonshotai_model_profile(model_name: str) -> ModelProfile | None:
77
"""Get the model profile for a MoonshotAI model."""
8-
return OpenAIModelProfile(
9-
ignore_streamed_leading_whitespace=True,
10-
openai_chat_custom_reasoning_field='reasoning_content',
11-
openai_chat_include_reasoning_in_request='separated',
12-
)
8+
return ModelProfile(ignore_streamed_leading_whitespace=True)

pydantic_ai_slim/pydantic_ai/profiles/openai.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
from typing import Any, Literal
88

99
from .._json_schema import JsonSchema, JsonSchemaTransformer
10+
from ..exceptions import UserError
1011
from . import ModelProfile
1112

12-
OpenAICustomReasoningField = Literal['reasoning', 'reasoning_content']
13-
OpenAIIncludeReasoningInRequest = Literal['combined', 'separated', 'none']
1413
OpenAISystemPromptRole = Literal['system', 'developer', 'user']
1514

1615

@@ -21,26 +20,25 @@ class OpenAIModelProfile(ModelProfile):
2120
ALL FIELDS MUST BE `openai_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS.
2221
"""
2322

24-
openai_chat_custom_reasoning_field: OpenAICustomReasoningField | None = None
23+
openai_chat_custom_reasoning_field: str | None = None
2524
"""Non-standard field name used by some providers for model reasoning content in Chat Completions API responses.
2625
2726
Plenty of providers use custom field names for reasoning content. Ollama and newer versions of vLLM use `reasoning`,
2827
while DeepSeek, older vLLM and some others use `reasoning_content`.
2928
30-
Notice that the reasoning field configured here is currently limited to `str` type content. If your provider is using
31-
complex `reasoning_details` (like newer OpenRouter API), you may want to look into `OpenRouterModel` instead.
29+
Notice that the reasoning field configured here is currently limited to `str` type content.
3230
33-
If `openai_chat_include_reasoning_in_request` is set to `'separated'`, this field must be set to a non-None value."""
31+
If `openai_chat_send_back_thinking_parts` is set to `'custom_field'`, this field must be set to a non-None value."""
3432

35-
openai_chat_include_reasoning_in_request: OpenAIIncludeReasoningInRequest = 'combined'
33+
openai_chat_send_back_thinking_parts: Literal['thinking_tags', 'custom_field', False] = 'thinking_tags'
3634
"""Whether the model includes reasoning content in requests.
3735
3836
This can be:
39-
* `'combined'` (default): The reasoning content is included in the main `content` field, enclosed within thinking tags.
40-
* `'separated'`: The reasoning content is included in a separate field specified by `openai_chat_custom_reasoning_field`.
41-
* `'none'`: No reasoning content is sent in the request.
37+
* `'thinking_tags'` (default): The reasoning content is included in the main `content` field, enclosed within thinking tags.
38+
* `'custom_field'`: The reasoning content is included in a separate field specified by `openai_chat_custom_reasoning_field`.
39+
* `False`: No reasoning content is sent in the request.
4240
43-
Defaults to `'combined'` for compatibility reasons."""
41+
Defaults to `'thinking_tags'` for compatibility reasons."""
4442

4543
openai_supports_strict_tool_definition: bool = True
4644
"""This can be set by a provider or user if the OpenAI-"compatible" API doesn't support strict tool definitions."""
@@ -81,9 +79,9 @@ def __post_init__(self): # pragma: no cover
8179
'Use `openai_unsupported_model_settings` instead.',
8280
DeprecationWarning,
8381
)
84-
if self.openai_chat_include_reasoning_in_request == 'separated' and not self.openai_chat_custom_reasoning_field:
85-
raise ValueError(
86-
'If `openai_chat_include_reasoning_in_request` is "separated", '
82+
if self.openai_chat_send_back_thinking_parts == 'custom_field' and not self.openai_chat_custom_reasoning_field:
83+
raise UserError(
84+
'If `openai_chat_include_reasoning_in_request` is "custom_field", '
8785
'`openai_chat_custom_reasoning_field` must be set to a non-None value.'
8886
)
8987

pydantic_ai_slim/pydantic_ai/providers/deepseek.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ def model_profile(self, model_name: str) -> ModelProfile | None:
4444
# we need to maintain that behavior unless json_schema_transformer is set explicitly.
4545
# This was not the case when using a DeepSeek model with another model class (e.g. BedrockConverseModel or GroqModel),
4646
# so we won't do this in `deepseek_model_profile` unless we learn it's always needed.
47-
return OpenAIModelProfile(json_schema_transformer=OpenAIJsonSchemaTransformer).update(profile)
47+
return OpenAIModelProfile(
48+
json_schema_transformer=OpenAIJsonSchemaTransformer,
49+
openai_chat_custom_reasoning_field='reasoning_content',
50+
# DeepSeek recommends against sending back unchanged reasoning parts in requests.
51+
# The following is for compatibility with existing behavior. May want to change later.
52+
openai_chat_send_back_thinking_parts='thinking_tags',
53+
).update(profile)
4854

4955
@overload
5056
def __init__(self) -> None: ...

pydantic_ai_slim/pydantic_ai/providers/moonshotai.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def model_profile(self, model_name: str) -> ModelProfile | None:
5757
json_schema_transformer=OpenAIJsonSchemaTransformer,
5858
openai_supports_tool_choice_required=False,
5959
supports_json_object_output=True,
60+
openai_chat_custom_reasoning_field='reasoning_content',
61+
openai_chat_send_back_thinking_parts='thinking_tags',
6062
).update(profile)
6163

6264
@overload

pydantic_ai_slim/pydantic_ai/providers/ollama.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ def model_profile(self, model_name: str) -> ModelProfile | None:
6262

6363
# As OllamaProvider is always used with OpenAIChatModel, which used to unconditionally use OpenAIJsonSchemaTransformer,
6464
# we need to maintain that behavior unless json_schema_transformer is set explicitly
65-
return OpenAIModelProfile(json_schema_transformer=OpenAIJsonSchemaTransformer).update(profile)
65+
return OpenAIModelProfile(
66+
json_schema_transformer=OpenAIJsonSchemaTransformer,
67+
openai_chat_custom_reasoning_field='reasoning',
68+
openai_chat_send_back_thinking_parts='thinking_tags',
69+
).update(profile)
6670

6771
def __init__(
6872
self,

tests/models/test_openai.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3118,7 +3118,7 @@ async def test_cache_point_filtering_responses_model():
31183118
assert msg['content'][1]['text'] == 'text after' # type: ignore[reportUnknownArgumentType]
31193119

31203120

3121-
async def test_openai_custom_reasoning_field_sending_back_combined(allow_model_requests: None):
3121+
async def test_openai_custom_reasoning_field_sending_back_in_thinking_tags(allow_model_requests: None):
31223122
c = completion_message(
31233123
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
31243124
)
@@ -3127,7 +3127,7 @@ async def test_openai_custom_reasoning_field_sending_back_combined(allow_model_r
31273127
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
31283128
profile=OpenAIModelProfile(
31293129
openai_chat_custom_reasoning_field='reasoning_content',
3130-
openai_chat_include_reasoning_in_request='combined',
3130+
openai_chat_send_back_thinking_parts='thinking_tags',
31313131
),
31323132
)
31333133
settings = ModelSettings()
@@ -3147,7 +3147,7 @@ async def test_openai_custom_reasoning_field_sending_back_combined(allow_model_r
31473147
)
31483148

31493149

3150-
async def test_openai_custom_reasoning_field_sending_back_separated(allow_model_requests: None):
3150+
async def test_openai_custom_reasoning_field_sending_back_in_custom_field(allow_model_requests: None):
31513151
c = completion_message(
31523152
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
31533153
)
@@ -3156,7 +3156,7 @@ async def test_openai_custom_reasoning_field_sending_back_separated(allow_model_
31563156
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
31573157
profile=OpenAIModelProfile(
31583158
openai_chat_custom_reasoning_field='reasoning_content',
3159-
openai_chat_include_reasoning_in_request='separated',
3159+
openai_chat_send_back_thinking_parts='custom_field',
31603160
),
31613161
)
31623162
settings = ModelSettings()
@@ -3176,7 +3176,7 @@ async def test_openai_custom_reasoning_field_not_sending(allow_model_requests: N
31763176
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
31773177
profile=OpenAIModelProfile(
31783178
openai_chat_custom_reasoning_field='reasoning_content',
3179-
openai_chat_include_reasoning_in_request='none',
3179+
openai_chat_send_back_thinking_parts=False,
31803180
),
31813181
)
31823182
settings = ModelSettings()
@@ -3187,14 +3187,14 @@ async def test_openai_custom_reasoning_field_not_sending(allow_model_requests: N
31873187
)
31883188

31893189

3190-
async def test_openai_embedded_reasoning(allow_model_requests: None):
3190+
async def test_openai_reasoning_in_thinking_tags(allow_model_requests: None):
31913191
c = completion_message(
31923192
ChatCompletionMessage.model_construct(content='<think>reasoning</think>response', role='assistant')
31933193
)
31943194
m = OpenAIChatModel(
31953195
'foobar',
31963196
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
3197-
profile=OpenAIModelProfile(),
3197+
profile=OpenAIModelProfile(openai_chat_send_back_thinking_parts='thinking_tags'),
31983198
)
31993199
settings = ModelSettings()
32003200
params = ModelRequestParameters()

0 commit comments

Comments
 (0)