Skip to content

Commit 8e32475

Browse files
committed
replace OpenRouterThinking with encoding in 'id'
1 parent 7c50f07 commit 8e32475

File tree

3 files changed

+96
-100
lines changed

3 files changed

+96
-100
lines changed

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -538,27 +538,6 @@ def _validate_completion(self, response: chat.ChatCompletion) -> chat.ChatComple
538538
"""
539539
return chat.ChatCompletion.model_validate(response.model_dump())
540540

541-
def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart]:
542-
"""Hook that maps reasoning tokens to thinking parts.
543-
544-
This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings.
545-
"""
546-
message = response.choices[0].message
547-
items: list[ThinkingPart] = []
548-
549-
# The `reasoning_content` field is only present in DeepSeek models.
550-
# https://api-docs.deepseek.com/guides/reasoning_model
551-
if reasoning_content := getattr(message, 'reasoning_content', None):
552-
items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system))
553-
554-
# The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter.
555-
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api
556-
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens
557-
if reasoning := getattr(message, 'reasoning', None):
558-
items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system))
559-
560-
return items
561-
562541
def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]:
563542
"""Hook that response content to provider details.
564543
@@ -616,7 +595,7 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
616595
choice = response.choices[0]
617596
items: list[ModelResponsePart] = []
618597

619-
if thinking_parts := self._process_reasoning(response):
598+
if thinking_parts := self._process_thinking(choice.message):
620599
items.extend(thinking_parts)
621600

622601
if choice.message.content:
@@ -648,6 +627,26 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
648627
finish_reason=self._map_finish_reason(choice.finish_reason),
649628
)
650629

630+
def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[ThinkingPart] | None:
631+
"""Hook that maps reasoning tokens to thinking parts.
632+
633+
This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings.
634+
"""
635+
items: list[ThinkingPart] = []
636+
637+
# The `reasoning_content` field is only present in DeepSeek models.
638+
# https://api-docs.deepseek.com/guides/reasoning_model
639+
if reasoning_content := getattr(message, 'reasoning_content', None):
640+
items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system))
641+
642+
# The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter.
643+
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api
644+
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens
645+
if reasoning := getattr(message, 'reasoning', None):
646+
items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system))
647+
648+
return items
649+
651650
async def _process_streamed_response(
652651
self, response: AsyncStream[ChatCompletionChunk], model_request_parameters: ModelRequestParameters
653652
) -> OpenAIStreamedResponse:
@@ -674,7 +673,11 @@ async def _process_streamed_response(
674673
)
675674

676675
@property
677-
def _streamed_response_cls(self):
676+
def _streamed_response_cls(self) -> type[OpenAIStreamedResponse]:
677+
"""Returns the `StreamedResponse` type that will be used for streamed responses.
678+
679+
This method may be overridden by subclasses of `OpenAIChatModel` to provide their own `StreamedResponse` type.
680+
"""
678681
return OpenAIStreamedResponse
679682

680683
def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[chat.ChatCompletionToolParam]:
@@ -743,6 +746,10 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess
743746
def _map_finish_reason(
744747
self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call']
745748
) -> FinishReason | None:
749+
"""Hooks that maps a finish reason key to a [FinishReason](pydantic_ai.messages.FinishReason).
750+
751+
This method may be overridden by subclasses of `OpenAIChatModel` to accommodate custom keys.
752+
"""
746753
return _CHAT_FINISH_REASON_MAP.get(key)
747754

748755
async def _map_messages(self, messages: list[ModelMessage]) -> list[chat.ChatCompletionMessageParam]:
@@ -1752,7 +1759,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
17521759
if provider_details := self._map_provider_details(chunk):
17531760
self.provider_details = provider_details
17541761

1755-
for event in self._map_part_delta(chunk):
1762+
for event in self._map_part_delta(choice):
17561763
yield event
17571764

17581765
async def _validate_response(self):
@@ -1765,12 +1772,11 @@ async def _validate_response(self):
17651772
async for chunk in self._response:
17661773
yield chunk
17671774

1768-
def _map_part_delta(self, chunk: ChatCompletionChunk):
1775+
def _map_part_delta(self, choice: Choice):
17691776
"""Hook that determines the sequence of mappings that will be called to produce events.
17701777
17711778
This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping.
17721779
"""
1773-
choice = chunk.choices[0]
17741780
return itertools.chain(
17751781
self._map_thinking_delta(choice), self._map_text_delta(choice), self._map_tool_call_delta(choice)
17761782
)
@@ -1851,6 +1857,10 @@ def _map_usage(self, response: ChatCompletionChunk):
18511857
def _map_finish_reason(
18521858
self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call']
18531859
) -> FinishReason | None:
1860+
"""Hooks that maps a finish reason key to a [FinishReason](pydantic_ai.messages.FinishReason).
1861+
1862+
This method may be overridden by subclasses of `OpenAIChatModel` to accommodate custom keys.
1863+
"""
18541864
return _CHAT_FINISH_REASON_MAP.get(key)
18551865

18561866
@property

pydantic_ai_slim/pydantic_ai/models/openrouter.py

Lines changed: 53 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import json
12
from dataclasses import asdict, dataclass
23
from typing import Any, Literal, cast
34

45
from pydantic import AliasChoices, BaseModel, Field, TypeAdapter
56
from typing_extensions import TypedDict, assert_never, override
67

7-
from .. import _utils
8-
from ..exceptions import ModelHTTPError
8+
from ..exceptions import ModelHTTPError, UnexpectedModelBehavior
99
from ..messages import (
1010
BuiltinToolCallPart,
1111
BuiltinToolReturnPart,
@@ -263,85 +263,72 @@ class OpenRouterError(BaseModel):
263263
message: str
264264

265265

266-
class BaseReasoningDetail(BaseModel):
266+
class _BaseReasoningDetail(BaseModel):
267267
"""Common fields shared across all reasoning detail types."""
268268

269269
id: str | None = None
270270
format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None
271271
index: int | None
272272

273273

274-
class ReasoningSummary(BaseReasoningDetail):
274+
class _ReasoningSummary(_BaseReasoningDetail):
275275
"""Represents a high-level summary of the reasoning process."""
276276

277277
type: Literal['reasoning.summary']
278278
summary: str = Field(validation_alias=AliasChoices('summary', 'content'))
279279

280280

281-
class ReasoningEncrypted(BaseReasoningDetail):
281+
class _ReasoningEncrypted(_BaseReasoningDetail):
282282
"""Represents encrypted reasoning data."""
283283

284284
type: Literal['reasoning.encrypted']
285285
data: str = Field(validation_alias=AliasChoices('data', 'signature'))
286286

287287

288-
class ReasoningText(BaseReasoningDetail):
288+
class _ReasoningText(_BaseReasoningDetail):
289289
"""Represents raw text reasoning."""
290290

291291
type: Literal['reasoning.text']
292292
text: str = Field(validation_alias=AliasChoices('text', 'content'))
293293
signature: str | None = None
294294

295295

296-
OpenRouterReasoningDetail = ReasoningSummary | ReasoningEncrypted | ReasoningText
297-
_reasoning_detail_adapter: TypeAdapter[OpenRouterReasoningDetail] = TypeAdapter(OpenRouterReasoningDetail)
298-
299-
300-
@dataclass(repr=False)
301-
class OpenRouterThinkingPart(ThinkingPart):
302-
"""A special ThinkingPart that includes reasoning attributes specific to OpenRouter."""
303-
304-
type: Literal['reasoning.summary', 'reasoning.encrypted', 'reasoning.text']
305-
index: int | None
306-
format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None
307-
308-
__repr__ = _utils.dataclasses_no_defaults_repr
309-
310-
@classmethod
311-
def from_reasoning_detail(cls, reasoning: OpenRouterReasoningDetail):
312-
provider_name = 'openrouter'
313-
if isinstance(reasoning, ReasoningText):
314-
return cls(
315-
id=reasoning.id,
316-
content=reasoning.text,
317-
signature=reasoning.signature,
318-
provider_name=provider_name,
319-
format=reasoning.format,
320-
type=reasoning.type,
321-
index=reasoning.index,
322-
)
323-
elif isinstance(reasoning, ReasoningSummary):
324-
return cls(
325-
id=reasoning.id,
326-
content=reasoning.summary,
327-
provider_name=provider_name,
328-
format=reasoning.format,
329-
type=reasoning.type,
330-
index=reasoning.index,
331-
)
332-
else:
333-
return cls(
334-
id=reasoning.id,
335-
content='',
336-
signature=reasoning.data,
337-
provider_name=provider_name,
338-
format=reasoning.format,
339-
type=reasoning.type,
340-
index=reasoning.index,
341-
)
342-
343-
def into_reasoning_detail(self):
344-
return _reasoning_detail_adapter.validate_python(asdict(self)).model_dump()
296+
_OpenRouterReasoningDetail = _ReasoningSummary | _ReasoningEncrypted | _ReasoningText
297+
_reasoning_detail_adapter: TypeAdapter[_OpenRouterReasoningDetail] = TypeAdapter(_OpenRouterReasoningDetail)
298+
299+
300+
def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPart:
301+
provider_name = 'openrouter'
302+
reasoning_id = reasoning.model_dump_json(include={'id', 'format', 'index', 'type'})
303+
if isinstance(reasoning, _ReasoningText):
304+
return ThinkingPart(
305+
id=reasoning_id,
306+
content=reasoning.text,
307+
signature=reasoning.signature,
308+
provider_name=provider_name,
309+
)
310+
elif isinstance(reasoning, _ReasoningSummary):
311+
return ThinkingPart(
312+
id=reasoning_id,
313+
content=reasoning.summary,
314+
provider_name=provider_name,
315+
)
316+
else:
317+
return ThinkingPart(
318+
id=reasoning_id,
319+
content='',
320+
signature=reasoning.data,
321+
provider_name=provider_name,
322+
)
323+
324+
325+
def _into_reasoning_detail(thinking_part: ThinkingPart) -> _OpenRouterReasoningDetail:
326+
if thinking_part.id is None: # pragma: lax no cover
327+
raise UnexpectedModelBehavior('OpenRouter thinking part has no ID')
328+
329+
data = asdict(thinking_part)
330+
data.update(json.loads(thinking_part.id))
331+
return _reasoning_detail_adapter.validate_python(data)
345332

346333

347334
class OpenRouterCompletionMessage(chat.ChatCompletionMessage):
@@ -350,7 +337,7 @@ class OpenRouterCompletionMessage(chat.ChatCompletionMessage):
350337
reasoning: str | None = None
351338
"""The reasoning text associated with the message, if any."""
352339

353-
reasoning_details: list[OpenRouterReasoningDetail] | None = None
340+
reasoning_details: list[_OpenRouterReasoningDetail] | None = None
354341
"""The reasoning details associated with the message, if any."""
355342

356343

@@ -481,22 +468,17 @@ def _validate_completion(self, response: chat.ChatCompletion) -> OpenRouterChatC
481468
return response
482469

483470
@override
484-
def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart]:
485-
# We can cast with confidence because response was validated in `_validate_completion`
486-
response = cast(OpenRouterChatCompletion, response)
487-
488-
message = response.choices[0].message
489-
items: list[ThinkingPart] = []
471+
def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[ThinkingPart] | None:
472+
assert isinstance(message, OpenRouterCompletionMessage)
490473

491474
if reasoning_details := message.reasoning_details:
492-
for detail in reasoning_details:
493-
items.append(OpenRouterThinkingPart.from_reasoning_detail(detail))
494-
495-
return items
475+
return [_from_reasoning_detail(detail) for detail in reasoning_details]
476+
else:
477+
return super()._process_thinking(message)
496478

497479
@override
498480
def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]:
499-
response = cast(OpenRouterChatCompletion, response)
481+
assert isinstance(response, OpenRouterChatCompletion)
500482

501483
provider_details = super()._process_provider_details(response)
502484

@@ -514,8 +496,8 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess
514496
if isinstance(item, TextPart):
515497
texts.append(item.content)
516498
elif isinstance(item, ThinkingPart):
517-
if item.provider_name == self.system and isinstance(item, OpenRouterThinkingPart):
518-
reasoning_details.append(item.into_reasoning_detail())
499+
if item.provider_name == self.system:
500+
reasoning_details.append(_into_reasoning_detail(item).model_dump())
519501
else: # pragma: no cover
520502
pass
521503
elif isinstance(item, ToolCallPart):
@@ -555,7 +537,7 @@ class OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta):
555537
reasoning: str | None = None
556538
"""The reasoning text associated with the message, if any."""
557539

558-
reasoning_details: list[OpenRouterReasoningDetail] | None = None
540+
reasoning_details: list[_OpenRouterReasoningDetail] | None = None
559541
"""The reasoning details associated with the message, if any."""
560542

561543

@@ -605,7 +587,7 @@ def _map_thinking_delta(self, choice: chat_completion_chunk.Choice):
605587

606588
if reasoning_details := choice.delta.reasoning_details:
607589
for detail in reasoning_details:
608-
thinking_part = OpenRouterThinkingPart.from_reasoning_detail(detail)
590+
thinking_part = _from_reasoning_detail(detail)
609591
yield self._parts_manager.handle_thinking_delta(
610592
vendor_part_id='reasoning_detail',
611593
id=thinking_part.id,

tests/models/test_openrouter.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,19 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open
110110
assert isinstance(thinking_event_start, PartStartEvent)
111111
assert thinking_event_start.part == snapshot(
112112
ThinkingPart(
113-
content='', id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter'
113+
content='',
114+
id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}',
115+
provider_name='openrouter',
114116
)
115117
)
116118

117119
thinking_event_end = chunks[1]
118120
assert isinstance(thinking_event_end, PartEndEvent)
119121
assert thinking_event_end.part == snapshot(
120122
ThinkingPart(
121-
content='', id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter'
123+
content='',
124+
id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}',
125+
provider_name='openrouter',
122126
)
123127
)
124128

@@ -200,7 +204,7 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_
200204

201205
thinking_part = response.parts[0]
202206
assert isinstance(thinking_part, ThinkingPart)
203-
assert thinking_part.id == snapshot(None)
207+
assert thinking_part.id == snapshot('{"id":null,"format":"unknown","index":0,"type":"reasoning.text"}')
204208
assert thinking_part.content is not None
205209
assert thinking_part.signature is None
206210

0 commit comments

Comments
 (0)