Skip to content

Commit 0de174f

Browse files
authored
Add custom reasoning field support to OpenAI model profiles (#3536)
1 parent c1f971d commit 0de174f

File tree

7 files changed

+188
-32
lines changed

7 files changed

+188
-32
lines changed

docs/thinking.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +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 [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_thinking_field`][pydantic_ai.profiles.openai.OpenAIModelProfile.openai_chat_thinking_field].
15+
16+
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_send_back_thinking_parts`][pydantic_ai.profiles.openai.OpenAIModelProfile.openai_chat_send_back_thinking_parts].
17+
1418
### OpenAI Responses
1519

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

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -637,20 +637,28 @@ def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[Thinkin
637637
638638
This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings.
639639
"""
640+
profile = OpenAIModelProfile.from_profile(self.profile)
641+
custom_field = profile.openai_chat_thinking_field
640642
items: list[ThinkingPart] = []
641643

642-
# The `reasoning_content` field is only present in DeepSeek models.
644+
# Prefer the configured custom reasoning field, if present in profile.
645+
# Fall back to built-in fields if no custom field result was found.
646+
647+
# The `reasoning_content` field is typically present in DeepSeek and Moonshot models.
643648
# https://api-docs.deepseek.com/guides/reasoning_model
644-
if reasoning_content := getattr(message, 'reasoning_content', None):
645-
items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system))
646649

647-
# The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter.
650+
# The `reasoning` field is typically present in gpt-oss via Ollama and OpenRouter.
648651
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api
649652
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens
650-
if reasoning := getattr(message, 'reasoning', None):
651-
items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system))
653+
for field_name in (custom_field, 'reasoning', 'reasoning_content'):
654+
if not field_name:
655+
continue
656+
reasoning: str | None = getattr(message, field_name, None)
657+
if reasoning: # pragma: no branch
658+
items.append(ThinkingPart(id=field_name, content=reasoning, provider_name=self.system))
659+
return items
652660

653-
return items
661+
return items or None
654662

655663
async def _process_streamed_response(
656664
self, response: AsyncStream[ChatCompletionChunk], model_request_parameters: ModelRequestParameters
@@ -726,6 +734,7 @@ class _MapModelResponseContext:
726734
_model: OpenAIChatModel
727735

728736
texts: list[str] = field(default_factory=list)
737+
thinkings: list[str] = field(default_factory=list)
729738
tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = field(default_factory=list)
730739

731740
def map_assistant_message(self, message: ModelResponse) -> chat.ChatCompletionAssistantMessageParam:
@@ -753,10 +762,15 @@ def _into_message_param(self) -> chat.ChatCompletionAssistantMessageParam:
753762
Returns:
754763
An OpenAI `ChatCompletionAssistantMessageParam` object representing the assistant's response.
755764
"""
765+
profile = OpenAIModelProfile.from_profile(self._model.profile)
756766
message_param = chat.ChatCompletionAssistantMessageParam(role='assistant')
767+
# Note: model responses from this model should only have one text item, so the following
768+
# shouldn't merge multiple texts into one unless you switch models between runs:
769+
if profile.openai_chat_send_back_thinking_parts == 'field' and self.thinkings:
770+
field = profile.openai_chat_thinking_field
771+
if field: # pragma: no branch (handled by profile validation)
772+
message_param[field] = '\n\n'.join(self.thinkings)
757773
if self.texts:
758-
# Note: model responses from this model should only have one text item, so the following
759-
# shouldn't merge multiple texts into one unless you switch models between runs:
760774
message_param['content'] = '\n\n'.join(self.texts)
761775
else:
762776
message_param['content'] = None
@@ -778,11 +792,13 @@ def _map_response_thinking_part(self, item: ThinkingPart) -> None:
778792
This method serves as a hook that can be overridden by subclasses
779793
to implement custom logic for handling thinking parts.
780794
"""
781-
# NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model,
782-
# but we currently just send it in `<think>` tags anyway as we don't want DeepSeek-specific checks here.
783-
# If you need this changed, please file an issue.
784-
start_tag, end_tag = self._model.profile.thinking_tags
785-
self.texts.append('\n'.join([start_tag, item.content, end_tag]))
795+
profile = OpenAIModelProfile.from_profile(self._model.profile)
796+
include_method = profile.openai_chat_send_back_thinking_parts
797+
if include_method == 'tags':
798+
start_tag, end_tag = self._model.profile.thinking_tags
799+
self.texts.append('\n'.join([start_tag, item.content, end_tag]))
800+
elif include_method == 'field':
801+
self.thinkings.append(item.content)
786802

787803
def _map_response_tool_call_part(self, item: ToolCallPart) -> None:
788804
"""Maps a `ToolCallPart` to the response context.
@@ -1890,26 +1906,30 @@ def _map_thinking_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[
18901906
18911907
This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping.
18921908
"""
1893-
# The `reasoning_content` field is only present in DeepSeek models.
1909+
profile = OpenAIModelProfile.from_profile(self._model_profile)
1910+
custom_field = profile.openai_chat_thinking_field
1911+
1912+
# Prefer the configured custom reasoning field, if present in profile.
1913+
# Fall back to built-in fields if no custom field result was found.
1914+
1915+
# The `reasoning_content` field is typically present in DeepSeek and Moonshot models.
18941916
# https://api-docs.deepseek.com/guides/reasoning_model
1895-
if reasoning_content := getattr(choice.delta, 'reasoning_content', None):
1896-
yield self._parts_manager.handle_thinking_delta(
1897-
vendor_part_id='reasoning_content',
1898-
id='reasoning_content',
1899-
content=reasoning_content,
1900-
provider_name=self.provider_name,
1901-
)
19021917

1903-
# The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter.
1918+
# The `reasoning` field is typically present in gpt-oss via Ollama and OpenRouter.
19041919
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api
19051920
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens
1906-
if reasoning := getattr(choice.delta, 'reasoning', None): # pragma: no cover
1907-
yield self._parts_manager.handle_thinking_delta(
1908-
vendor_part_id='reasoning',
1909-
id='reasoning',
1910-
content=reasoning,
1911-
provider_name=self.provider_name,
1912-
)
1921+
for field_name in (custom_field, 'reasoning', 'reasoning_content'):
1922+
if not field_name:
1923+
continue
1924+
reasoning: str | None = getattr(choice.delta, field_name, None)
1925+
if reasoning: # pragma: no branch
1926+
yield self._parts_manager.handle_thinking_delta(
1927+
vendor_part_id=field_name,
1928+
id=field_name,
1929+
content=reasoning,
1930+
provider_name=self.provider_name,
1931+
)
1932+
break
19131933

19141934
def _map_text_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[ModelResponseStreamEvent]:
19151935
"""Hook that maps text delta content to events.

pydantic_ai_slim/pydantic_ai/profiles/openai.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Literal
88

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

1213
OpenAISystemPromptRole = Literal['system', 'developer', 'user']
@@ -19,6 +20,27 @@ class OpenAIModelProfile(ModelProfile):
1920
ALL FIELDS MUST BE `openai_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS.
2021
"""
2122

23+
openai_chat_thinking_field: str | None = None
24+
"""Non-standard field name used by some providers for model thinking content in Chat Completions API responses.
25+
26+
Plenty of providers use custom field names for thinking content. Ollama and newer versions of vLLM use `reasoning`,
27+
while DeepSeek, older vLLM and some others use `reasoning_content`.
28+
29+
Notice that the thinking field configured here is currently limited to `str` type content.
30+
31+
If `openai_chat_send_back_thinking_parts` is set to `'field'`, this field must be set to a non-None value."""
32+
33+
openai_chat_send_back_thinking_parts: Literal['tags', 'field', False] = 'tags'
34+
"""Whether the model includes thinking content in requests.
35+
36+
This can be:
37+
* `'tags'` (default): The thinking content is included in the main `content` field, enclosed within thinking tags as
38+
specified in `thinking_tags` profile option.
39+
* `'field'`: The thinking content is included in a separate field specified by `openai_chat_thinking_field`.
40+
* `False`: No thinking content is sent in the request.
41+
42+
Defaults to `'thinking_tags'` for backward compatibility reasons."""
43+
2244
openai_supports_strict_tool_definition: bool = True
2345
"""This can be set by a provider or user if the OpenAI-"compatible" API doesn't support strict tool definitions."""
2446

@@ -58,6 +80,11 @@ def __post_init__(self): # pragma: no cover
5880
'Use `openai_unsupported_model_settings` instead.',
5981
DeprecationWarning,
6082
)
83+
if self.openai_chat_send_back_thinking_parts == 'field' and not self.openai_chat_thinking_field:
84+
raise UserError(
85+
'If `openai_chat_send_back_thinking_parts` is "field", '
86+
'`openai_chat_thinking_field` must be set to a non-None value.'
87+
)
6188

6289

6390
def openai_model_profile(model_name: str) -> ModelProfile:

pydantic_ai_slim/pydantic_ai/providers/deepseek.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ def model_profile(self, model_name: str) -> ModelProfile | None:
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.
4747
return OpenAIModelProfile(
48-
json_schema_transformer=OpenAIJsonSchemaTransformer, supports_json_object_output=True
48+
json_schema_transformer=OpenAIJsonSchemaTransformer,
49+
supports_json_object_output=True,
50+
openai_chat_thinking_field='reasoning_content',
51+
# Starting from DeepSeek v3.2, DeepSeek requires sending thinking parts for optimal agentic performance.
52+
openai_chat_send_back_thinking_parts='field',
4953
).update(profile)
5054

5155
@overload

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_thinking_field='reasoning_content',
61+
openai_chat_send_back_thinking_parts='field',
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_thinking_field='reasoning',
68+
openai_chat_send_back_thinking_parts='tags',
69+
).update(profile)
6670

6771
def __init__(
6872
self,

tests/models/test_openai.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3201,3 +3201,98 @@ async def test_cache_point_filtering_responses_model():
32013201
assert len(msg['content']) == 2
32023202
assert msg['content'][0]['text'] == 'text before' # type: ignore[reportUnknownArgumentType]
32033203
assert msg['content'][1]['text'] == 'text after' # type: ignore[reportUnknownArgumentType]
3204+
3205+
3206+
async def test_openai_custom_reasoning_field_sending_back_in_thinking_tags(allow_model_requests: None):
3207+
c = completion_message(
3208+
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
3209+
)
3210+
m = OpenAIChatModel(
3211+
'foobar',
3212+
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
3213+
profile=OpenAIModelProfile(
3214+
openai_chat_thinking_field='reasoning_content',
3215+
openai_chat_send_back_thinking_parts='tags',
3216+
),
3217+
)
3218+
settings = ModelSettings()
3219+
params = ModelRequestParameters()
3220+
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
3221+
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
3222+
{
3223+
'role': 'assistant',
3224+
'content': """\
3225+
<think>
3226+
reasoning
3227+
</think>
3228+
3229+
response\
3230+
""",
3231+
}
3232+
)
3233+
3234+
3235+
async def test_openai_custom_reasoning_field_sending_back_in_custom_field(allow_model_requests: None):
3236+
c = completion_message(
3237+
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
3238+
)
3239+
m = OpenAIChatModel(
3240+
'foobar',
3241+
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
3242+
profile=OpenAIModelProfile(
3243+
openai_chat_thinking_field='reasoning_content',
3244+
openai_chat_send_back_thinking_parts='field',
3245+
),
3246+
)
3247+
settings = ModelSettings()
3248+
params = ModelRequestParameters()
3249+
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
3250+
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
3251+
{'role': 'assistant', 'reasoning_content': 'reasoning', 'content': 'response'}
3252+
)
3253+
3254+
3255+
async def test_openai_custom_reasoning_field_not_sending(allow_model_requests: None):
3256+
c = completion_message(
3257+
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
3258+
)
3259+
m = OpenAIChatModel(
3260+
'foobar',
3261+
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
3262+
profile=OpenAIModelProfile(
3263+
openai_chat_thinking_field='reasoning_content',
3264+
openai_chat_send_back_thinking_parts=False,
3265+
),
3266+
)
3267+
settings = ModelSettings()
3268+
params = ModelRequestParameters()
3269+
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
3270+
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
3271+
{'role': 'assistant', 'content': 'response'}
3272+
)
3273+
3274+
3275+
async def test_openai_reasoning_in_thinking_tags(allow_model_requests: None):
3276+
c = completion_message(
3277+
ChatCompletionMessage.model_construct(content='<think>reasoning</think>response', role='assistant')
3278+
)
3279+
m = OpenAIChatModel(
3280+
'foobar',
3281+
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
3282+
profile=OpenAIModelProfile(openai_chat_send_back_thinking_parts='tags'),
3283+
)
3284+
settings = ModelSettings()
3285+
params = ModelRequestParameters()
3286+
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
3287+
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
3288+
{
3289+
'role': 'assistant',
3290+
'content': """\
3291+
<think>
3292+
reasoning
3293+
</think>
3294+
3295+
response\
3296+
""",
3297+
}
3298+
)

0 commit comments

Comments
 (0)