Skip to content

Commit 776bcb4

Browse files
committed
refactor reasoning detail conversion
1 parent faec3ed commit 776bcb4

File tree

9 files changed

+70
-83
lines changed

9 files changed

+70
-83
lines changed

docs/models/openrouter.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
## Install
44

5-
To use `OpenRouterModel`, you need to either install `pydantic-ai`, or install `pydantic-ai-slim` with the `openai` optional group:
5+
To use `OpenRouterModel`, you need to either install `pydantic-ai`, or install `pydantic-ai-slim` with the `openrouter` optional group:
66

77
```bash
8-
pip/uv-add "pydantic-ai-slim[openai]"
8+
pip/uv-add "pydantic-ai-slim[openrouter]"
99
```
1010

1111
## Configuration
@@ -36,16 +36,19 @@ agent = Agent(model)
3636
...
3737
```
3838

39-
You can set the `x_title` and `http_referer` parameters in the provider to enable [app attribution](https://openrouter.ai/docs/app-attribution):
39+
## App Attribution
4040

41+
OpenRouter has an [app attribution](https://openrouter.ai/docs/app-attribution) feature to track your application in their public ranking and analytics.
42+
43+
You can pass in an 'app_url' and 'app_title' when initializing the provider to enable app attribution.
4144

4245
```python
4346
from pydantic_ai.providers.openrouter import OpenRouterProvider
4447

4548
provider=OpenRouterProvider(
4649
api_key='your-openrouter-api-key',
47-
http_referer='https://your-app.com',
48-
x_title='Your App',
50+
app_url='https://your-app.com',
51+
app_title='Your App',
4952
),
5053
...
5154
```

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,12 @@ class ThinkingPart:
10421042
part_kind: Literal['thinking'] = 'thinking'
10431043
"""Part type identifier, this is available on all parts as a discriminator."""
10441044

1045+
provider_details: dict[str, Any] | None = None
1046+
"""Additional provider-specific details in a serializable format.
1047+
1048+
This allows storing selected vendor-specific data that isn't mapped to standard ThinkingPart fields.
1049+
"""
1050+
10451051
def has_content(self) -> bool:
10461052
"""Return `True` if the thinking content is non-empty."""
10471053
return bool(self.content)

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ def _get_web_search_options(self, model_request_parameters: ModelRequestParamete
708708
)
709709

710710
@dataclass
711-
class _MapModelResposeContext:
711+
class _MapModelResponseContext:
712712
"""Context object for mapping a `ModelResponse` to OpenAI chat completion parameters.
713713
714714
This class is designed to be subclassed to add new fields for custom logic,
@@ -739,15 +739,15 @@ def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam:
739739
message_param['tool_calls'] = self.tool_calls
740740
return message_param
741741

742-
def _map_response_text_part(self, ctx: _MapModelResposeContext, item: TextPart) -> None:
742+
def _map_response_text_part(self, ctx: _MapModelResponseContext, item: TextPart) -> None:
743743
"""Maps a `TextPart` to the response context.
744744
745745
This method serves as a hook that can be overridden by subclasses
746746
to implement custom logic for handling text parts.
747747
"""
748748
ctx.texts.append(item.content)
749749

750-
def _map_response_thinking_part(self, ctx: _MapModelResposeContext, item: ThinkingPart) -> None:
750+
def _map_response_thinking_part(self, ctx: _MapModelResponseContext, item: ThinkingPart) -> None:
751751
"""Maps a `ThinkingPart` to the response context.
752752
753753
This method serves as a hook that can be overridden by subclasses
@@ -759,7 +759,7 @@ def _map_response_thinking_part(self, ctx: _MapModelResposeContext, item: Thinki
759759
start_tag, end_tag = self.profile.thinking_tags
760760
ctx.texts.append('\n'.join([start_tag, item.content, end_tag]))
761761

762-
def _map_response_tool_call_part(self, ctx: _MapModelResposeContext, item: ToolCallPart) -> None:
762+
def _map_response_tool_call_part(self, ctx: _MapModelResponseContext, item: ToolCallPart) -> None:
763763
"""Maps a `ToolCallPart` to the response context.
764764
765765
This method serves as a hook that can be overridden by subclasses
@@ -768,7 +768,7 @@ def _map_response_tool_call_part(self, ctx: _MapModelResposeContext, item: ToolC
768768
ctx.tool_calls.append(self._map_tool_call(item))
769769

770770
def _map_response_builtin_part(
771-
self, ctx: _MapModelResposeContext, item: BuiltinToolCallPart | BuiltinToolReturnPart
771+
self, ctx: _MapModelResponseContext, item: BuiltinToolCallPart | BuiltinToolReturnPart
772772
) -> None:
773773
"""Maps a built-in tool call or return part to the response context.
774774
@@ -778,7 +778,7 @@ def _map_response_builtin_part(
778778
# OpenAI doesn't return built-in tool calls
779779
pass
780780

781-
def _map_response_file_part(self, ctx: _MapModelResposeContext, item: FilePart) -> None:
781+
def _map_response_file_part(self, ctx: _MapModelResponseContext, item: FilePart) -> None:
782782
"""Maps a `FilePart` to the response context.
783783
784784
This method serves as a hook that can be overridden by subclasses
@@ -792,7 +792,7 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess
792792
793793
Subclasses of `OpenAIChatModel` may override this method to provide their own mapping logic.
794794
"""
795-
ctx = self._MapModelResposeContext()
795+
ctx = self._MapModelResponseContext()
796796
for item in message.parts:
797797
if isinstance(item, TextPart):
798798
self._map_response_text_part(ctx, item)

pydantic_ai_slim/pydantic_ai/models/openrouter.py

Lines changed: 27 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from __future__ import annotations as _annotations
22

33
from collections.abc import Iterable
4-
from dataclasses import dataclass, field
5-
from typing import Any, Literal, cast
4+
from dataclasses import asdict, dataclass, field
5+
from typing import Annotated, Any, Literal, cast
66

7-
from pydantic import BaseModel
7+
from pydantic import AliasChoices, BaseModel, Field, TypeAdapter
88
from typing_extensions import TypedDict, assert_never, override
99

1010
from ..exceptions import ModelHTTPError, UnexpectedModelBehavior
@@ -246,95 +246,69 @@ class _BaseReasoningDetail(BaseModel, frozen=True):
246246
id: str | None = None
247247
format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None
248248
index: int | None
249-
type: Literal['reasoning.text', 'reasoning.summary', 'reasoning.encrypted']
250249

251250

252251
class _ReasoningSummary(_BaseReasoningDetail, frozen=True):
253252
"""Represents a high-level summary of the reasoning process."""
254253

255254
type: Literal['reasoning.summary']
256-
summary: str
255+
summary: str = Field(validation_alias=AliasChoices('summary', 'content'))
257256

258257

259258
class _ReasoningEncrypted(_BaseReasoningDetail, frozen=True):
260259
"""Represents encrypted reasoning data."""
261260

262261
type: Literal['reasoning.encrypted']
263-
data: str
262+
data: str = Field(validation_alias=AliasChoices('data', 'signature'))
264263

265264

266265
class _ReasoningText(_BaseReasoningDetail, frozen=True):
267266
"""Represents raw text reasoning."""
268267

269268
type: Literal['reasoning.text']
270-
text: str
269+
text: str = Field(validation_alias=AliasChoices('text', 'content'))
271270
signature: str | None = None
272271

273272

274-
_OpenRouterReasoningDetail = _ReasoningSummary | _ReasoningEncrypted | _ReasoningText
273+
_OpenRouterReasoningDetail = Annotated[
274+
_ReasoningSummary | _ReasoningEncrypted | _ReasoningText, Field(discriminator='type')
275+
]
276+
_openrouter_reasoning_detail_adapter: TypeAdapter[_OpenRouterReasoningDetail] = TypeAdapter(_OpenRouterReasoningDetail)
275277

276278

277279
def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPart:
278280
provider_name = 'openrouter'
279-
reasoning_id = reasoning.model_dump_json(include={'id', 'format', 'index', 'type'})
281+
provider_details = reasoning.model_dump(include={'format', 'index', 'type'})
280282
if isinstance(reasoning, _ReasoningText):
281283
return ThinkingPart(
282-
id=reasoning_id,
284+
id=reasoning.id,
283285
content=reasoning.text,
284286
signature=reasoning.signature,
285287
provider_name=provider_name,
288+
provider_details=provider_details,
286289
)
287290
elif isinstance(reasoning, _ReasoningSummary):
288291
return ThinkingPart(
289-
id=reasoning_id,
290-
content=reasoning.summary,
291-
provider_name=provider_name,
292+
id=reasoning.id, content=reasoning.summary, provider_name=provider_name, provider_details=provider_details
292293
)
293294
elif isinstance(reasoning, _ReasoningEncrypted):
294295
return ThinkingPart(
295-
id=reasoning_id,
296+
id=reasoning.id,
296297
content='',
297298
signature=reasoning.data,
298299
provider_name=provider_name,
300+
provider_details=provider_details,
299301
)
300302
else:
301303
assert_never(reasoning)
302304

303305

304306
def _into_reasoning_detail(thinking_part: ThinkingPart) -> _OpenRouterReasoningDetail:
305-
if thinking_part.id is None: # pragma: lax no cover
306-
raise UnexpectedModelBehavior('OpenRouter thinking part has no ID')
307-
308-
data = _BaseReasoningDetail.model_validate_json(thinking_part.id)
309-
310-
if data.type == 'reasoning.text':
311-
return _ReasoningText(
312-
type=data.type,
313-
id=data.id,
314-
format=data.format,
315-
index=data.index,
316-
text=thinking_part.content,
317-
signature=thinking_part.signature,
318-
)
319-
elif data.type == 'reasoning.summary':
320-
return _ReasoningSummary(
321-
type=data.type,
322-
id=data.id,
323-
format=data.format,
324-
index=data.index,
325-
summary=thinking_part.content,
326-
)
327-
elif data.type == 'reasoning.encrypted':
328-
assert thinking_part.signature is not None
329-
return _ReasoningEncrypted(
330-
type=data.type,
331-
id=data.id,
332-
format=data.format,
333-
index=data.index,
334-
data=thinking_part.signature,
335-
)
336-
else:
337-
assert_never(data.type)
307+
if thinking_part.provider_details is None: # pragma: lax no cover
308+
raise UnexpectedModelBehavior('OpenRouter thinking part has no provider_details')
309+
thinking_part_dict = asdict(thinking_part)
310+
thinking_part_dict.update(thinking_part_dict.pop('provider_details'))
311+
return _openrouter_reasoning_detail_adapter.validate_python(thinking_part_dict)
338312

339313

340314
class _OpenRouterCompletionMessage(chat.ChatCompletionMessage):
@@ -363,7 +337,8 @@ class _OpenRouterChoice(chat_completion.Choice):
363337
"""A wrapped chat completion message with OpenRouter specific attributes."""
364338

365339

366-
class _OpenRouterCostDetails(BaseModel):
340+
@dataclass
341+
class _OpenRouterCostDetails:
367342
"""OpenRouter specific cost details."""
368343

369344
upstream_inference_cost: int | None = None
@@ -417,7 +392,7 @@ def _map_openrouter_provider_details(
417392
provider_details: dict[str, Any] = {}
418393

419394
provider_details['downstream_provider'] = response.provider
420-
provider_details['native_finish_reason'] = response.choices[0].native_finish_reason
395+
provider_details['finish_reason'] = response.choices[0].native_finish_reason
421396

422397
if usage := response.usage:
423398
if cost := usage.cost:
@@ -517,7 +492,7 @@ def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str,
517492
return provider_details
518493

519494
@dataclass
520-
class _MapModelResposeContext(OpenAIChatModel._MapModelResposeContext): # type: ignore[reportPrivateUsage]
495+
class _MapModelResponseContext(OpenAIChatModel._MapModelResponseContext): # type: ignore[reportPrivateUsage]
521496
reasoning_details: list[dict[str, Any]] = field(default_factory=list)
522497

523498
def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam:
@@ -527,8 +502,8 @@ def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam:
527502
return message_param
528503

529504
@override
530-
def _map_response_thinking_part(self, ctx: OpenAIChatModel._MapModelResposeContext, item: ThinkingPart) -> None:
531-
assert isinstance(ctx, self._MapModelResposeContext)
505+
def _map_response_thinking_part(self, ctx: OpenAIChatModel._MapModelResponseContext, item: ThinkingPart) -> None:
506+
assert isinstance(ctx, self._MapModelResponseContext)
532507
if item.provider_name == self.system:
533508
ctx.reasoning_details.append(_into_reasoning_detail(item).model_dump())
534509
elif content := item.content: # pragma: lax no cover

pydantic_ai_slim/pydantic_ai/providers/openrouter.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ def __init__(self, *, api_key: str) -> None: ...
8282
def __init__(self, *, api_key: str, http_client: httpx.AsyncClient) -> None: ...
8383

8484
@overload
85-
def __init__(self, *, api_key: str, http_referer: str, x_title: str) -> None: ...
85+
def __init__(self, *, api_key: str, app_url: str, app_title: str) -> None: ...
8686

8787
@overload
88-
def __init__(self, *, api_key: str, http_referer: str, x_title: str, http_client: httpx.AsyncClient) -> None: ...
88+
def __init__(self, *, api_key: str, app_url: str, app_title: str, http_client: httpx.AsyncClient) -> None: ...
8989

9090
@overload
9191
def __init__(self, *, http_client: httpx.AsyncClient) -> None: ...
@@ -97,8 +97,8 @@ def __init__(
9797
self,
9898
*,
9999
api_key: str | None = None,
100-
http_referer: str | None = None,
101-
x_title: str | None = None,
100+
app_url: str | None = None,
101+
app_title: str | None = None,
102102
openai_client: AsyncOpenAI | None = None,
103103
http_client: httpx.AsyncClient | None = None,
104104
) -> None:
@@ -107,10 +107,10 @@ def __init__(
107107
Args:
108108
api_key: OpenRouter API key. Falls back to ``OPENROUTER_API_KEY``
109109
when omitted and required unless ``openai_client`` is provided.
110-
http_referer: Optional attribution header, falling back to
111-
``OPENROUTER_HTTP_REFERER``.
112-
x_title: Optional attribution header, falling back to
113-
``OPENROUTER_X_TITLE``.
110+
app_url: Optional url for app attribution. Falls back to
111+
``OPENROUTER_APP_URL`` when omitted.
112+
app_title: Optional title for app attribution. Falls back to
113+
``OPENROUTER_APP_TITLE`` when omitted.
114114
openai_client: Existing ``AsyncOpenAI`` client to reuse instead of
115115
creating one internally.
116116
http_client: Custom ``httpx.AsyncClient`` to pass into the
@@ -128,9 +128,9 @@ def __init__(
128128
)
129129

130130
attribution_headers: dict[str, str] = {}
131-
if http_referer := http_referer or os.getenv('OPENROUTER_HTTP_REFERER'):
131+
if http_referer := app_url or os.getenv('OPENROUTER_APP_URL'):
132132
attribution_headers['HTTP-Referer'] = http_referer
133-
if x_title := x_title or os.getenv('OPENROUTER_X_TITLE'):
133+
if x_title := app_title or os.getenv('OPENROUTER_APP_TITLE'):
134134
attribution_headers['X-Title'] = x_title
135135

136136
if openai_client is not None:

pydantic_ai_slim/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ vertexai = ["google-auth>=2.36.0", "requests>=2.32.2"]
7373
google = ["google-genai>=1.51.0"]
7474
anthropic = ["anthropic>=0.70.0"]
7575
groq = ["groq>=0.25.0"]
76+
openrouter = ["openai>=2.8.0"]
7677
mistral = ["mistralai>=1.9.10"]
7778
bedrock = ["boto3>=1.40.14"]
7879
huggingface = ["huggingface-hub[inference]>=0.33.5"]

tests/models/test_openrouter.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro
7474
)
7575
assert response.provider_details is not None
7676
assert response.provider_details['downstream_provider'] == 'xAI'
77-
assert response.provider_details['native_finish_reason'] == 'stop'
77+
assert response.provider_details['finish_reason'] == 'stop'
7878

7979

8080
async def test_openrouter_stream_with_native_options(allow_model_requests: None, openrouter_api_key: str) -> None:
@@ -95,9 +95,7 @@ async def test_openrouter_stream_with_native_options(allow_model_requests: None,
9595

9696
_ = [chunk async for chunk in stream]
9797

98-
assert stream.provider_details == snapshot(
99-
{'finish_reason': 'stop', 'downstream_provider': 'xAI', 'native_finish_reason': 'completed'}
100-
)
98+
assert stream.provider_details == snapshot({'finish_reason': 'completed', 'downstream_provider': 'xAI'})
10199
assert stream.finish_reason == snapshot('stop')
102100

103101

@@ -113,7 +111,7 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open
113111
assert thinking_event_start.part == snapshot(
114112
ThinkingPart(
115113
content='',
116-
id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}',
114+
id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f',
117115
provider_name='openrouter',
118116
)
119117
)
@@ -123,7 +121,7 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open
123121
assert thinking_event_end.part == snapshot(
124122
ThinkingPart(
125123
content='',
126-
id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}',
124+
id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f',
127125
provider_name='openrouter',
128126
)
129127
)
@@ -206,7 +204,7 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_
206204

207205
thinking_part = response.parts[0]
208206
assert isinstance(thinking_part, ThinkingPart)
209-
assert thinking_part.id == snapshot('{"id":null,"format":"unknown","index":0,"type":"reasoning.text"}')
207+
assert thinking_part.id == snapshot(None)
210208
assert thinking_part.content is not None
211209
assert thinking_part.signature is None
212210

tests/providers/test_openrouter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def test_openrouter_provider():
4545

4646

4747
def test_openrouter_provider_with_app_attribution():
48-
provider = OpenRouterProvider(api_key='api-key', http_referer='test.com', x_title='test')
48+
provider = OpenRouterProvider(api_key='api-key', app_url='test.com', app_title='test')
4949
assert provider.name == 'openrouter'
5050
assert provider.base_url == 'https://openrouter.ai/api/v1'
5151
assert isinstance(provider.client, openai.AsyncOpenAI)

0 commit comments

Comments
 (0)