Skip to content

Commit ebc8d08

Browse files
committed
add tool calling test
1 parent 89ef9a8 commit ebc8d08

File tree

3 files changed

+157
-2
lines changed

3 files changed

+157
-2
lines changed

pydantic_ai_slim/pydantic_ai/models/openrouter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,10 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess
466466
if isinstance(item, TextPart):
467467
texts.append(item.content)
468468
elif isinstance(item, ThinkingPart):
469-
if isinstance(item, OpenRouterThinkingPart) and item.provider_name == self.system:
469+
if item.provider_name == self.system and isinstance(item, OpenRouterThinkingPart):
470470
reasoning_details.append(item.into_reasoning_detail())
471+
else: # pragma: no cover
472+
pass
471473
elif isinstance(item, ToolCallPart):
472474
tool_calls.append(self._map_tool_call(item))
473475
elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
interactions:
2+
- request:
3+
headers:
4+
accept:
5+
- application/json
6+
accept-encoding:
7+
- gzip, deflate
8+
connection:
9+
- keep-alive
10+
content-length:
11+
- '514'
12+
content-type:
13+
- application/json
14+
host:
15+
- openrouter.ai
16+
method: POST
17+
parsed_body:
18+
messages:
19+
- content: What is 123 / 456?
20+
role: user
21+
model: mistralai/mistral-small
22+
stream: false
23+
tool_choice: auto
24+
tools:
25+
- function:
26+
description: Divide two numbers.
27+
name: divide
28+
parameters:
29+
additionalProperties: false
30+
description: Divide two numbers.
31+
properties:
32+
denominator:
33+
type: number
34+
numerator:
35+
type: number
36+
on_inf:
37+
default: infinity
38+
enum:
39+
- error
40+
- infinity
41+
type: string
42+
required:
43+
- numerator
44+
- denominator
45+
type: object
46+
type: function
47+
uri: https://openrouter.ai/api/v1/chat/completions
48+
response:
49+
headers:
50+
access-control-allow-origin:
51+
- '*'
52+
connection:
53+
- keep-alive
54+
content-length:
55+
- '585'
56+
content-type:
57+
- application/json
58+
permissions-policy:
59+
- payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" "https://js.stripe.com" "https://*.js.stripe.com"
60+
"https://hooks.stripe.com")
61+
referrer-policy:
62+
- no-referrer, strict-origin-when-cross-origin
63+
transfer-encoding:
64+
- chunked
65+
vary:
66+
- Accept-Encoding
67+
parsed_body:
68+
choices:
69+
- finish_reason: tool_calls
70+
index: 0
71+
logprobs: null
72+
message:
73+
content: ''
74+
reasoning: null
75+
refusal: null
76+
role: assistant
77+
tool_calls:
78+
- function:
79+
arguments: '{"numerator": 123, "denominator": 456, "on_inf": "infinity"}'
80+
name: divide
81+
id: 3sniiMddS
82+
index: 0
83+
type: function
84+
native_finish_reason: tool_calls
85+
created: 1762047030
86+
id: gen-1762047030-dJUcJW4ildNGqK4UV6iJ
87+
model: mistralai/mistral-small
88+
object: chat.completion
89+
provider: Mistral
90+
usage:
91+
completion_tokens: 43
92+
prompt_tokens: 134
93+
total_tokens: 177
94+
status:
95+
code: 200
96+
message: OK
97+
version: 1

tests/models/test_openrouter.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from collections.abc import Sequence
2-
from typing import cast
2+
from typing import Literal, cast
33

44
import pytest
55
from inline_snapshot import snapshot
6+
from pydantic import BaseModel
67

78
from pydantic_ai import (
89
Agent,
@@ -11,9 +12,12 @@
1112
ModelRequest,
1213
TextPart,
1314
ThinkingPart,
15+
ToolCallPart,
16+
ToolDefinition,
1417
UnexpectedModelBehavior,
1518
)
1619
from pydantic_ai.direct import model_request
20+
from pydantic_ai.models import ModelRequestParameters
1721

1822
from ..conftest import try_import
1923

@@ -69,6 +73,58 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro
6973
assert response.provider_details['native_finish_reason'] == 'stop'
7074

7175

76+
async def test_openrouter_tool_calling(allow_model_requests: None, openrouter_api_key: str) -> None:
77+
provider = OpenRouterProvider(api_key=openrouter_api_key)
78+
79+
class Divide(BaseModel):
80+
"""Divide two numbers."""
81+
82+
numerator: float
83+
denominator: float
84+
on_inf: Literal['error', 'infinity'] = 'infinity'
85+
86+
model = OpenRouterModel('mistralai/mistral-small', provider=provider)
87+
response = await model_request(
88+
model,
89+
[ModelRequest.user_text_prompt('What is 123 / 456?')],
90+
model_request_parameters=ModelRequestParameters(
91+
function_tools=[
92+
ToolDefinition(
93+
name=Divide.__name__.lower(),
94+
description=Divide.__doc__,
95+
parameters_json_schema=Divide.model_json_schema(),
96+
)
97+
],
98+
allow_text_output=True, # Allow model to either use tools or respond directly
99+
),
100+
)
101+
102+
assert len(response.parts) == 1
103+
104+
tool_call_part = response.parts[0]
105+
assert isinstance(tool_call_part, ToolCallPart)
106+
assert tool_call_part.tool_call_id == snapshot('3sniiMddS')
107+
assert tool_call_part.tool_name == 'divide'
108+
assert tool_call_part.args == snapshot('{"numerator": 123, "denominator": 456, "on_inf": "infinity"}')
109+
110+
mapped_messages = await model._map_messages([response]) # type: ignore[reportPrivateUsage]
111+
tool_call_message = mapped_messages[0]
112+
assert tool_call_message['role'] == 'assistant'
113+
assert tool_call_message.get('content') is None
114+
assert tool_call_message.get('tool_calls') == snapshot(
115+
[
116+
{
117+
'id': '3sniiMddS',
118+
'type': 'function',
119+
'function': {
120+
'name': 'divide',
121+
'arguments': '{"numerator": 123, "denominator": 456, "on_inf": "infinity"}',
122+
},
123+
}
124+
]
125+
)
126+
127+
72128
async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_api_key: str) -> None:
73129
provider = OpenRouterProvider(api_key=openrouter_api_key)
74130
request = ModelRequest.user_text_prompt(

0 commit comments

Comments
 (0)