1+ import json
12from dataclasses import asdict , dataclass
23from typing import Any , Literal , cast
34
45from pydantic import AliasChoices , BaseModel , Field , TypeAdapter
56from typing_extensions import TypedDict , assert_never , override
67
7- from .. import _utils
8- from ..exceptions import ModelHTTPError
8+ from ..exceptions import ModelHTTPError , UnexpectedModelBehavior
99from ..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
347334class 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 ,
0 commit comments