Files
wehub-resource-sync 9201ef759e
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

5589 lines
219 KiB
Python

from __future__ import annotations as _annotations
import base64
import json
import re
import warnings
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Annotated, Any, Literal, cast
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from pydantic import AnyUrl, BaseModel, ConfigDict, Discriminator, Field, Tag
from typing_extensions import NotRequired, TypedDict
from pydantic_ai import (
Agent,
AudioUrl,
BinaryContent,
CachePoint,
DocumentUrl,
ImageUrl,
ModelAPIError,
ModelHTTPError,
ModelProfile,
ModelRequest,
ModelResponse,
ModelRetry,
PartDeltaEvent,
PartEndEvent,
RetryPromptPart,
TextContent,
TextPart,
TextPartDelta,
ThinkingPart,
ToolCallPart,
ToolReturnPart,
UnexpectedModelBehavior,
UserError,
UserPromptPart,
)
from pydantic_ai._json_schema import InlineDefsJsonSchemaTransformer
from pydantic_ai._utils import is_text_like_media_type as _is_text_like_media_type
from pydantic_ai.capabilities import Capability, NativeTool
from pydantic_ai.direct import model_request as direct_model_request
from pydantic_ai.exceptions import ContentFilterError
from pydantic_ai.messages import InstructionPart, SystemPromptPart, UploadedFile, VideoUrl
from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.native_tools import ImageGenerationTool, WebSearchTool
from pydantic_ai.output import NativeOutput, PromptedOutput, TextOutput, ToolOutput
from pydantic_ai.profiles import merge_profile
from pydantic_ai.profiles.openai import OpenAIModelProfile, openai_model_profile
from pydantic_ai.result import RunUsage
from pydantic_ai.settings import ModelSettings
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.usage import RequestUsage
from .._inline_snapshot import snapshot
from ..conftest import IsDatetime, IsNow, IsStr, TestEnv, message, try_import
from .mock_openai import (
MockOpenAI,
MockOpenAIResponses,
completion_message,
get_mock_chat_completion_kwargs,
get_mock_responses_kwargs,
response_message,
)
with try_import() as imports_successful:
from openai import APIConnectionError, APIStatusError, AsyncAzureOpenAI, AsyncOpenAI
from openai.types import chat
from openai.types.chat.chat_completion import ChoiceLogprobs
from openai.types.chat.chat_completion_chunk import (
Choice as ChunkChoice,
ChoiceDelta,
ChoiceDeltaToolCall,
ChoiceDeltaToolCallFunction,
)
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.chat.chat_completion_message_function_tool_call import ChatCompletionMessageFunctionToolCall
from openai.types.chat.chat_completion_message_tool_call import Function
from openai.types.chat.chat_completion_token_logprob import ChatCompletionTokenLogprob
from openai.types.completion_usage import CompletionUsage, PromptTokensDetails
from pydantic_ai.models.google import GoogleModel
from pydantic_ai.models.openai import (
OpenAIChatModel,
OpenAIChatModelSettings,
OpenAIResponsesModel,
OpenAIResponsesModelSettings,
_resolve_openai_image_generation_size, # pyright: ignore[reportPrivateUsage]
)
from pydantic_ai.profiles.openai import OpenAIJsonSchemaTransformer, OpenAISystemPromptRole
from pydantic_ai.providers.azure import AzureProvider
from pydantic_ai.providers.cerebras import CerebrasProvider
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
MockChatCompletion = chat.ChatCompletion | Exception
MockChatCompletionChunk = chat.ChatCompletionChunk | Exception
pytestmark = [
pytest.mark.skipif(not imports_successful(), reason='openai not installed'),
pytest.mark.anyio,
pytest.mark.vcr,
]
def test_init():
provider = OpenAIProvider(api_key='foobar')
m = OpenAIChatModel('gpt-4o', provider=provider)
assert m.base_url == 'https://api.openai.com/v1/'
assert m.client is provider.client
assert m.client.api_key == 'foobar'
assert m.model_name == 'gpt-4o'
@pytest.mark.parametrize(
'aspect_ratio,size,expected',
[
# aspect_ratio is None, various sizes
(None, None, 'auto'),
(None, 'auto', 'auto'),
(None, '1024x1024', '1024x1024'),
(None, '1024x1536', '1024x1536'),
(None, '1536x1024', '1536x1024'),
# Valid aspect_ratios with no size
('1:1', None, '1024x1024'),
('2:3', None, '1024x1536'),
('3:2', None, '1536x1024'),
# Valid aspect_ratios with compatible sizes
('1:1', 'auto', '1024x1024'),
('1:1', '1024x1024', '1024x1024'),
('2:3', '1024x1536', '1024x1536'),
('3:2', '1536x1024', '1536x1024'),
],
)
def test_openai_image_generation_size_valid_combinations(
aspect_ratio: Literal['1:1', '2:3', '3:2'] | None,
size: Literal['auto', '1024x1024', '1024x1536', '1536x1024'] | None,
expected: Literal['auto', '1024x1024', '1024x1536', '1536x1024'],
) -> None:
"""Test valid combinations of aspect_ratio and size for OpenAI image generation."""
tool = ImageGenerationTool(aspect_ratio=aspect_ratio, size=size)
assert _resolve_openai_image_generation_size(tool) == expected
def test_openai_image_generation_tool_aspect_ratio_invalid() -> None:
"""Test that invalid aspect_ratio raises UserError."""
tool = ImageGenerationTool(aspect_ratio='16:9')
with pytest.raises(UserError, match='OpenAI image generation only supports `aspect_ratio` values'):
_resolve_openai_image_generation_size(tool)
async def test_request_simple_success(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(content='world', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('hello')
assert result.output == 'world'
assert result.usage == snapshot(RunUsage(requests=1))
# reset the index so we get the same response again
mock_client.index = 0 # type: ignore
result = await agent.run('hello', message_history=result.new_messages())
assert result.output == 'world'
assert result.usage == snapshot(RunUsage(requests=1))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='world')],
model_name='gpt-4o-123',
timestamp=IsNow(tz=timezone.utc),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='world')],
model_name='gpt-4o-123',
timestamp=IsNow(tz=timezone.utc),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert get_mock_chat_completion_kwargs(mock_client) == [
{
'messages': [{'content': 'hello', 'role': 'user'}],
'model': 'gpt-4o',
'extra_headers': {'User-Agent': IsStr(regex=r'pydantic-ai\/.*')},
'extra_body': None,
},
{
'messages': [
{'content': 'hello', 'role': 'user'},
{'content': 'world', 'role': 'assistant'},
{'content': 'hello', 'role': 'user'},
],
'model': 'gpt-4o',
'extra_headers': {'User-Agent': IsStr(regex=r'pydantic-ai\/.*')},
'extra_body': None,
},
]
async def test_request_simple_usage(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(content='world', role='assistant'),
usage=CompletionUsage(completion_tokens=1, prompt_tokens=2, total_tokens=3),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('Hello')
assert result.output == 'world'
assert result.usage == snapshot(
RunUsage(
requests=1,
input_tokens=2,
output_tokens=1,
)
)
async def test_response_with_created_timestamp_but_no_provider_details(allow_model_requests: None):
class MinimalOpenAIChatModel(OpenAIChatModel):
def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any] | None:
return None
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = MinimalOpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('hello')
assert result.output == 'world'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='world')],
model_name='gpt-4o-123',
timestamp=IsNow(tz=timezone.utc),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_chat_image_detail_vendor_metadata(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(content='done', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(model)
image_url = ImageUrl('https://example.com/image.png', vendor_metadata={'detail': 'high'})
binary_image = BinaryContent(b'\x89PNG', media_type='image/png', vendor_metadata={'detail': 'high'})
await agent.run(['Describe these inputs.', image_url, binary_image])
request_kwargs = get_mock_chat_completion_kwargs(mock_client)
image_parts = [
item['image_url'] for item in request_kwargs[0]['messages'][0]['content'] if item['type'] == 'image_url'
]
assert image_parts
assert all(part['detail'] == 'high' for part in image_parts)
async def test_request_structured_response(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(
content=None,
role='assistant',
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id='123',
function=Function(arguments='{"response": [1, 2, 123]}', name='final_result'),
type='function',
)
],
)
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, output_type=list[int])
result = await agent.run('Hello')
assert result.output == [1, 2, 123]
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"response": [1, 2, 123]}',
tool_call_id='123',
)
],
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='123',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_request_tool_call(allow_model_requests: None):
responses = [
completion_message(
ChatCompletionMessage(
content=None,
role='assistant',
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id='1',
function=Function(arguments='{"loc_name": "San Fransisco"}', name='get_location'),
type='function',
)
],
),
usage=CompletionUsage(
completion_tokens=1,
prompt_tokens=2,
total_tokens=3,
prompt_tokens_details=PromptTokensDetails(cached_tokens=1),
),
),
completion_message(
ChatCompletionMessage(
content=None,
role='assistant',
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id='2',
function=Function(arguments='{"loc_name": "London"}', name='get_location'),
type='function',
)
],
),
usage=CompletionUsage(
completion_tokens=2,
prompt_tokens=3,
total_tokens=6,
prompt_tokens_details=PromptTokensDetails(cached_tokens=2),
),
),
completion_message(ChatCompletionMessage(content='final response', role='assistant')),
]
mock_client = MockOpenAI.create_mock(responses)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, instructions='this is the system prompt')
@agent.tool_plain
async def get_location(loc_name: str) -> str:
if loc_name == 'London':
return json.dumps({'lat': 51, 'lng': 0})
else:
raise ModelRetry('Wrong location, please try again')
result = await agent.run('Hello')
assert result.output == 'final response'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
instructions='this is the system prompt',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='get_location',
args='{"loc_name": "San Fransisco"}',
tool_call_id='1',
)
],
usage=RequestUsage(
input_tokens=2,
cache_read_tokens=1,
output_tokens=1,
),
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Wrong location, please try again',
tool_name='get_location',
tool_call_id='1',
timestamp=IsNow(tz=timezone.utc),
)
],
instructions='this is the system prompt',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='get_location',
args='{"loc_name": "London"}',
tool_call_id='2',
)
],
usage=RequestUsage(
input_tokens=3,
cache_read_tokens=2,
output_tokens=2,
),
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_location',
content='{"lat": 51, "lng": 0}',
tool_call_id='2',
timestamp=IsNow(tz=timezone.utc),
)
],
instructions='this is the system prompt',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='final response')],
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.usage == snapshot(
RunUsage(requests=3, cache_read_tokens=3, input_tokens=5, output_tokens=3, tool_calls=1)
)
FinishReason = Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call']
def chunk(delta: list[ChoiceDelta], finish_reason: FinishReason | None = None) -> chat.ChatCompletionChunk:
return chat.ChatCompletionChunk(
id='123',
choices=[
ChunkChoice(index=index, delta=delta, finish_reason=finish_reason) for index, delta in enumerate(delta)
],
created=1704067200, # 2024-01-01
model='gpt-4o-123',
object='chat.completion.chunk',
usage=CompletionUsage(completion_tokens=1, prompt_tokens=2, total_tokens=3),
)
def text_chunk(text: str, finish_reason: FinishReason | None = None) -> chat.ChatCompletionChunk:
return chunk([ChoiceDelta(content=text, role='assistant')], finish_reason=finish_reason)
async def test_stream_text(allow_model_requests: None):
stream = [text_chunk('hello '), text_chunk('world'), chunk([])]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
async with agent.run_stream('') as result:
assert not result.is_complete
assert [c async for c in result.stream_text(debounce_by=None)] == snapshot(['hello ', 'hello world'])
assert result.is_complete
assert result.usage == snapshot(RunUsage(requests=1, input_tokens=6, output_tokens=3))
def test_run_stream_sync_streams_real_model(allow_model_requests: None, openai_api_key: str):
"""`run_stream_sync` must stream and complete against a real, recorded streaming model.
End-to-end coverage for the portal-based implementation (#3716, refs #3714, #5975): the whole
run -- including a tool call, so the agent graph crosses node boundaries -- executes on a single
dedicated event-loop thread, and text streams incrementally before `get_output()` returns the
final result. This path can't be exercised with `TestModel` or a mock client (no real async
stream), so it's a VCR test.
Note: the pre-portal implementation pumped the stream via repeated
`loop.run_until_complete(anext(...))` calls, each in a different asyncio task. That could raise
`RuntimeError: Attempted to exit cancel scope in a different task than it was entered in`, but the
straddle is timing-dependent and does not reliably reproduce against a fixed cassette (or even a
live model); the OTel dangling-span symptom of the same root cause is covered by
`tests/test_logfire.py::test_run_stream_sync`.
"""
model = OpenAIChatModel('gpt-4o-mini', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(model)
@agent.tool_plain
def get_capital(country: str) -> str:
return 'London'
with agent.run_stream_sync('What is the capital of the UK? Use the tool, then answer.') as result:
chunks = [c for c in result.stream_text(debounce_by=None)]
output = result.get_output()
assert chunks
assert chunks[-1] == output
assert output == snapshot('The capital of the UK is London.')
async def test_stream_text_finish_reason(allow_model_requests: None):
first_chunk = text_chunk('hello ')
# Test that we get the model name from a later chunk if it is not set on the first one, like on Azure OpenAI with content filter enabled.
first_chunk.model = ''
stream = [
first_chunk,
text_chunk('world'),
text_chunk('.', finish_reason='stop'),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
async with agent.run_stream('') as result:
assert not result.is_complete
assert [c async for c in result.stream_text(debounce_by=None)] == snapshot(
['hello ', 'hello world', 'hello world.']
)
assert result.is_complete
async for response in result.stream_response(debounce_by=None):
assert response == snapshot(
ModelResponse(
parts=[TextPart(content='hello world.')],
usage=RequestUsage(input_tokens=6, output_tokens=3),
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
)
)
def struc_chunk(
tool_name: str | None, tool_arguments: str | None, finish_reason: FinishReason | None = None
) -> chat.ChatCompletionChunk:
return chunk(
[
ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0, function=ChoiceDeltaToolCallFunction(name=tool_name, arguments=tool_arguments)
)
]
),
],
finish_reason=finish_reason,
)
class MyTypedDict(TypedDict, total=False):
first: str
second: str
async def test_stream_structured(allow_model_requests: None):
stream = [
chunk([ChoiceDelta()]),
chunk([ChoiceDelta(tool_calls=[])]),
chunk([ChoiceDelta(tool_calls=[ChoiceDeltaToolCall(index=0, function=None)])]),
chunk([ChoiceDelta(tool_calls=[ChoiceDeltaToolCall(index=0, function=None)])]),
struc_chunk('final_result', None),
chunk([ChoiceDelta(tool_calls=[ChoiceDeltaToolCall(index=0, function=None)])]),
struc_chunk(None, '{"first": "One'),
struc_chunk(None, '", "second": "Two"'),
struc_chunk(None, '}'),
chunk([]),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, output_type=MyTypedDict)
async with agent.run_stream('') as result:
assert not result.is_complete
assert [dict(c) async for c in result.stream_output(debounce_by=None)] == snapshot(
[
{},
{'first': 'One'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
]
)
assert result.is_complete
assert result.usage == snapshot(RunUsage(requests=1, input_tokens=20, output_tokens=10))
# double check usage matches stream count
assert result.usage.output_tokens == len(stream)
async def test_stream_structured_finish_reason(allow_model_requests: None):
stream = [
struc_chunk('final_result', None),
struc_chunk(None, '{"first": "One'),
struc_chunk(None, '", "second": "Two"'),
struc_chunk(None, '}'),
struc_chunk(None, None, finish_reason='stop'),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, output_type=MyTypedDict)
async with agent.run_stream('') as result:
assert not result.is_complete
assert [dict(c) async for c in result.stream_output(debounce_by=None)] == snapshot(
[
{'first': 'One'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
]
)
assert result.is_complete
async def test_stream_native_output(allow_model_requests: None):
stream = [
chunk([]),
text_chunk('{"first": "One'),
text_chunk('", "second": "Two"'),
text_chunk('}'),
chunk([]),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, output_type=NativeOutput(MyTypedDict))
async with agent.run_stream('') as result:
assert not result.is_complete
assert [dict(c) async for c in result.stream_output(debounce_by=None)] == snapshot(
[
{'first': 'One'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
]
)
assert result.is_complete
async def test_stream_tool_call_with_empty_text(allow_model_requests: None):
stream = [
chunk(
[
ChoiceDelta(
content='', # Ollama will include an empty text delta even when it's going to call a tool
tool_calls=[
ChoiceDeltaToolCall(
index=0, function=ChoiceDeltaToolCallFunction(name='final_result', arguments=None)
)
],
),
]
),
struc_chunk(None, '{"first": "One'),
struc_chunk(None, '", "second": "Two"'),
struc_chunk(None, '}'),
chunk([]),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-oss:20b', provider=OllamaProvider(openai_client=mock_client))
agent = Agent(m, output_type=[str, MyTypedDict])
async with agent.run_stream('') as result:
assert not result.is_complete
assert [c async for c in result.stream_output(debounce_by=None)] == snapshot(
[
{'first': 'One'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
]
)
assert await result.get_output() == snapshot({'first': 'One', 'second': 'Two'})
async def test_stream_text_empty_think_tag_and_text_before_tool_call(allow_model_requests: None):
# Ollama + Qwen3 will emit `<think>\n</think>\n\n` ahead of tool calls,
# which we don't want to end up treating as a final result.
stream = [
text_chunk('<think>'),
text_chunk('\n'),
text_chunk('</think>'),
text_chunk('\n\n'),
struc_chunk('final_result', None),
struc_chunk(None, '{"first": "One'),
struc_chunk(None, '", "second": "Two"'),
struc_chunk(None, '}'),
chunk([]),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('qwen3', provider=OllamaProvider(openai_client=mock_client))
agent = Agent(m, output_type=[str, MyTypedDict])
async with agent.run_stream('') as result:
assert not result.is_complete
assert [c async for c in result.stream_output(debounce_by=None)] == snapshot(
[
{},
{'first': 'One'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
{'first': 'One', 'second': 'Two'},
]
)
assert await result.get_output() == snapshot({'first': 'One', 'second': 'Two'})
async def test_no_delta(allow_model_requests: None):
stream = [
chunk([]),
text_chunk('hello '),
text_chunk('world'),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
async with agent.run_stream('') as result:
assert not result.is_complete
assert [c async for c in result.stream_text(debounce_by=None)] == snapshot(['hello ', 'hello world'])
assert result.is_complete
assert result.usage == snapshot(RunUsage(requests=1, input_tokens=6, output_tokens=3))
def none_delta_chunk(finish_reason: FinishReason | None = None) -> chat.ChatCompletionChunk:
choice = ChunkChoice(index=0, delta=ChoiceDelta())
# When using Azure OpenAI and an async content filter is enabled, the openai SDK can return None deltas.
choice.delta = None # pyright: ignore[reportAttributeAccessIssue]
return chat.ChatCompletionChunk(
id='123',
choices=[choice],
created=1704067200, # 2024-01-01
model='gpt-4o-123',
object='chat.completion.chunk',
usage=CompletionUsage(completion_tokens=1, prompt_tokens=2, total_tokens=3),
)
async def test_none_delta(allow_model_requests: None):
stream = [
none_delta_chunk(),
text_chunk('hello '),
text_chunk('world'),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
async with agent.run_stream('') as result:
assert not result.is_complete
assert [c async for c in result.stream_text(debounce_by=None)] == snapshot(['hello ', 'hello world'])
assert result.is_complete
assert result.usage == snapshot(RunUsage(requests=1, input_tokens=6, output_tokens=3))
async def test_none_choices(allow_model_requests: None):
# OpenAI-compatible providers can emit malformed chunks with `choices=null`; the openai SDK's
# loose constructor lets them through despite the typed-as-list field declaration.
bad_chunk = text_chunk('')
bad_chunk.choices = None # pyright: ignore[reportAttributeAccessIssue]
mock_client = MockOpenAI.create_mock_stream([bad_chunk, text_chunk('hello '), text_chunk('world')])
agent = Agent(OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client)))
async with agent.run_stream('') as result:
assert [c async for c in result.stream_text(debounce_by=None)] == snapshot(['hello ', 'hello world'])
@pytest.mark.parametrize('system_prompt_role', ['system', 'developer', 'user', None])
async def test_system_prompt_role(
allow_model_requests: None, system_prompt_role: OpenAISystemPromptRole | None
) -> None:
"""Testing the system prompt role for OpenAI models is properly set / inferred."""
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
profile = OpenAIModelProfile(openai_system_prompt_role=system_prompt_role)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
assert m.profile.get('openai_system_prompt_role') == system_prompt_role
agent = Agent(m, system_prompt='some instructions')
result = await agent.run('hello')
assert result.output == 'world'
assert get_mock_chat_completion_kwargs(mock_client) == [
{
'messages': [
{'content': 'some instructions', 'role': system_prompt_role or 'system'},
{'content': 'hello', 'role': 'user'},
],
'model': 'gpt-4o',
'extra_headers': {'User-Agent': IsStr(regex=r'pydantic-ai\/.*')},
'extra_body': None,
}
]
async def test_merge_leading_system_messages(allow_model_requests: None) -> None:
"""When `openai_chat_supports_multiple_system_messages=False`, consecutive system messages at the start are merged."""
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(openai_client=mock_client),
profile=OpenAIModelProfile(openai_chat_supports_multiple_system_messages=False),
)
agent = Agent(m, system_prompt='static prompt', instructions='dynamic instructions')
@agent.system_prompt
def extra_system_prompt() -> str:
return 'extra static prompt'
result = await agent.run('hello')
assert result.output == 'world'
assert get_mock_chat_completion_kwargs(mock_client)[0]['messages'] == [
{'content': 'static prompt\n\nextra static prompt\n\ndynamic instructions', 'role': 'system'},
{'content': 'hello', 'role': 'user'},
]
async def test_merge_leading_system_messages_single_system_message(allow_model_requests: None) -> None:
"""With only one leading system message, the merge flag is a no-op."""
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(openai_client=mock_client),
profile=OpenAIModelProfile(openai_chat_supports_multiple_system_messages=False),
)
agent = Agent(m, system_prompt='only one')
await agent.run('hello')
assert get_mock_chat_completion_kwargs(mock_client)[0]['messages'] == [
{'content': 'only one', 'role': 'system'},
{'content': 'hello', 'role': 'user'},
]
async def test_merge_leading_system_messages_user_role_unchanged(allow_model_requests: None) -> None:
"""When system prompts are sent as `user` role, the merge flag does not collapse them."""
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(openai_client=mock_client),
profile=OpenAIModelProfile(
openai_chat_supports_multiple_system_messages=False,
openai_system_prompt_role='user',
),
)
agent = Agent(m, system_prompt='static prompt')
@agent.system_prompt
def extra_system_prompt() -> str:
return 'extra static prompt'
await agent.run('hello')
assert get_mock_chat_completion_kwargs(mock_client)[0]['messages'] == [
{'content': 'static prompt', 'role': 'user'},
{'content': 'extra static prompt', 'role': 'user'},
{'content': 'hello', 'role': 'user'},
]
async def test_merge_leading_system_messages_disabled_by_default(allow_model_requests: None) -> None:
"""Default behavior is preserved: multiple system messages are sent as separate messages."""
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, system_prompt='static prompt', instructions='dynamic instructions')
@agent.system_prompt
def extra_system_prompt() -> str:
return 'extra static prompt'
result = await agent.run('hello')
assert result.output == 'world'
assert get_mock_chat_completion_kwargs(mock_client)[0]['messages'] == [
{'content': 'static prompt', 'role': 'system'},
{'content': 'extra static prompt', 'role': 'system'},
{'content': 'dynamic instructions', 'role': 'system'},
{'content': 'hello', 'role': 'user'},
]
async def test_system_prompt_role_o1_mini(allow_model_requests: None, openai_api_key: str):
model = OpenAIChatModel('o1-mini', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(model=model, system_prompt='You are a helpful assistant.')
result = await agent.run("What's the capital of France?")
assert result.output == snapshot('The capital of France is **Paris**.')
async def test_openai_pass_custom_system_prompt_role(allow_model_requests: None, openai_api_key: str):
profile = OpenAIModelProfile(openai_system_prompt_role='user', supports_tools=False)
model = OpenAIChatModel('o1-mini', profile=profile, provider=OpenAIProvider(api_key=openai_api_key))
assert model.profile.get('openai_system_prompt_role', None) == 'user'
assert model.profile.get('supports_tools', True) is False
@pytest.mark.parametrize('system_prompt_role', ['system', 'developer'])
async def test_openai_o1_mini_system_role(
allow_model_requests: None,
system_prompt_role: Literal['system', 'developer'],
openai_api_key: str,
) -> None:
profile = OpenAIModelProfile(openai_system_prompt_role=system_prompt_role)
model = OpenAIChatModel('o1-mini', provider=OpenAIProvider(api_key=openai_api_key), profile=profile)
agent = Agent(model=model, system_prompt='You are a helpful assistant.')
with pytest.raises(ModelHTTPError, match=r".*Unsupported value: 'messages\[0\]\.role' does not support.*"):
await agent.run('Hello')
@pytest.mark.parametrize('parallel_tool_calls', [True, False])
async def test_parallel_tool_calls(allow_model_requests: None, parallel_tool_calls: bool) -> None:
c = completion_message(
ChatCompletionMessage(
content=None,
role='assistant',
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id='123',
function=Function(arguments='{"response": [1, 2, 3]}', name='final_result'),
type='function',
)
],
)
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, output_type=list[int], model_settings=ModelSettings(parallel_tool_calls=parallel_tool_calls))
await agent.run('Hello')
assert get_mock_chat_completion_kwargs(mock_client)[0]['parallel_tool_calls'] == parallel_tool_calls
async def test_parallel_tool_calls_not_sent_without_tools(allow_model_requests: None) -> None:
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, model_settings=ModelSettings(parallel_tool_calls=True))
await agent.run('Hello')
assert 'parallel_tool_calls' not in get_mock_chat_completion_kwargs(mock_client)[0]
async def test_image_url_input(allow_model_requests: None):
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run(
[
'hello',
ImageUrl(url='https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg'),
]
)
assert result.output == 'world'
assert get_mock_chat_completion_kwargs(mock_client) == snapshot(
[
{
'model': 'gpt-4o',
'messages': [
{
'role': 'user',
'content': [
{'text': 'hello', 'type': 'text'},
{
'image_url': {
'url': 'https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg'
},
'type': 'image_url',
},
],
}
],
'extra_headers': {'User-Agent': IsStr(regex=r'pydantic-ai\/.*')},
'extra_body': None,
}
]
)
async def test_image_url_input_force_download(
allow_model_requests: None, openai_api_key: str, disable_ssrf_protection_for_vcr: None
):
provider = OpenAIProvider(api_key=openai_api_key)
m = OpenAIChatModel('gpt-4.1-nano', provider=provider)
agent = Agent(m)
result = await agent.run(
[
'What is this vegetable?',
ImageUrl(
force_download=True,
url='https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg',
),
]
)
assert result.output == snapshot('This vegetable is a potato.')
async def test_image_url_input_force_download_response_api(
allow_model_requests: None, openai_api_key: str, disable_ssrf_protection_for_vcr: None
):
provider = OpenAIProvider(api_key=openai_api_key)
m = OpenAIResponsesModel('gpt-4.1-nano', provider=provider)
agent = Agent(m)
result = await agent.run(
[
'What is this vegetable?',
ImageUrl(
force_download=True,
url='https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg',
),
]
)
assert result.output == snapshot('This is a potato.')
async def test_openai_audio_url_input(
allow_model_requests: None, openai_api_key: str, disable_ssrf_protection_for_vcr: None
):
m = OpenAIChatModel('gpt-4o-audio-preview', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
result = await agent.run(['Hello', AudioUrl(url='https://cdn.openai.com/API/docs/audio/alloy.wav')])
assert result.output == snapshot(
'Yes, the phenomenon of the sun rising in the east and setting in the west is due to the rotation of the Earth. The Earth rotates on its axis from west to east, making the sun appear to rise on the eastern horizon and set in the west. This is a daily occurrence and has been a fundamental aspect of human observation and timekeeping throughout history.'
)
assert result.usage == snapshot(
RunUsage(
input_tokens=81,
output_tokens=72,
input_audio_tokens=69,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
'text_tokens': 72,
},
requests=1,
)
)
async def test_document_url_input(
allow_model_requests: None, openai_api_key: str, disable_ssrf_protection_for_vcr: None
):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
document_url = DocumentUrl(url='https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf')
result = await agent.run(['What is the main content on this document?', document_url])
assert result.output == snapshot('The document contains the text "Dummy PDF file" on its single page.')
async def test_document_url_input_response_api(allow_model_requests: None, openai_api_key: str):
"""Test DocumentUrl with Responses API sends URL directly (default behavior)."""
provider = OpenAIProvider(api_key=openai_api_key)
m = OpenAIResponsesModel('gpt-4.1-nano', provider=provider)
agent = Agent(m)
document_url = DocumentUrl(url='https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf')
result = await agent.run(['What is the main content on this document?', document_url])
assert 'Dummy PDF' in result.output
async def test_document_url_input_force_download_response_api(
allow_model_requests: None, openai_api_key: str, disable_ssrf_protection_for_vcr: None
):
"""Test DocumentUrl with force_download=True downloads and sends as file_data."""
provider = OpenAIProvider(api_key=openai_api_key)
m = OpenAIResponsesModel('gpt-4.1-nano', provider=provider)
agent = Agent(m)
document_url = DocumentUrl(
url='https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
force_download=True,
)
result = await agent.run(['What is the main content on this document?', document_url])
assert 'Dummy PDF' in result.output
async def test_image_url_force_download_chat() -> None:
"""Test that force_download=True calls download_item for ImageUrl in OpenAIChatModel."""
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='test-key'))
with patch('pydantic_ai.models.openai.download_item', new_callable=AsyncMock) as mock_download:
mock_download.return_value = {
'data': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'content_type': 'image/png',
}
messages = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Test image',
ImageUrl(
url='https://example.com/image.png',
media_type='image/png',
force_download=True,
),
]
)
]
)
]
await m._map_messages(messages, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage]
mock_download.assert_called_once()
assert mock_download.call_args[0][0].url == 'https://example.com/image.png'
async def test_image_url_no_force_download_chat() -> None:
"""Test that force_download=False does not call download_item for ImageUrl in OpenAIChatModel."""
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='test-key'))
with patch('pydantic_ai.models.openai.download_item', new_callable=AsyncMock) as mock_download:
messages = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Test image',
ImageUrl(
url='https://example.com/image.png',
media_type='image/png',
force_download=False,
),
]
)
]
)
]
await m._map_messages(messages, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage]
mock_download.assert_not_called()
async def test_document_url_force_download_responses() -> None:
"""Test that force_download=True calls download_item for DocumentUrl in OpenAIResponsesModel."""
m = OpenAIResponsesModel('gpt-4.5-nano', provider=OpenAIProvider(api_key='test-key'))
with patch('pydantic_ai.models.openai.download_item', new_callable=AsyncMock) as mock_download:
mock_download.return_value = {
'data': 'data:application/pdf;base64,JVBERi0xLjQK',
'data_type': 'pdf',
}
messages = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Test PDF',
DocumentUrl(
url='https://example.com/doc.pdf',
media_type='application/pdf',
force_download=True,
),
]
)
]
)
]
await m._map_messages(messages, {}, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage,reportArgumentType]
mock_download.assert_called_once()
assert mock_download.call_args[0][0].url == 'https://example.com/doc.pdf'
async def test_document_url_no_force_download_responses() -> None:
"""Test that force_download=False does not call download_item for DocumentUrl in OpenAIResponsesModel."""
m = OpenAIResponsesModel('gpt-4.5-nano', provider=OpenAIProvider(api_key='test-key'))
with patch('pydantic_ai.models.openai.download_item', new_callable=AsyncMock) as mock_download:
messages = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Test document',
DocumentUrl(
url='https://example.com/doc.pdf',
media_type='application/pdf',
force_download=False,
),
]
)
]
)
]
await m._map_messages(messages, {}, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage,reportArgumentType]
mock_download.assert_not_called()
async def test_audio_url_force_download_responses() -> None:
"""Test that force_download=True calls download_item for AudioUrl in OpenAIResponsesModel."""
m = OpenAIResponsesModel('gpt-4.5-nano', provider=OpenAIProvider(api_key='test-key'))
with patch('pydantic_ai.models.openai.download_item', new_callable=AsyncMock) as mock_download:
mock_download.return_value = {
'data': 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2',
'data_type': 'mp3',
}
messages = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Test audio',
AudioUrl(
url='https://example.com/audio.mp3',
media_type='audio/mp3',
force_download=True,
),
]
)
]
)
]
await m._map_messages(messages, {}, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage,reportArgumentType]
mock_download.assert_called_once()
assert mock_download.call_args[0][0].url == 'https://example.com/audio.mp3'
@pytest.mark.vcr()
async def test_image_url_tool_response(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
@agent.tool_plain
async def get_image() -> ImageUrl:
return ImageUrl(url='https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg')
result = await agent.run(['What food is in the image you can get from the get_image tool?'])
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content=['What food is in the image you can get from the get_image tool?'],
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='get_image', args='{}', tool_call_id='call_4hrT4QP9jfojtK69vGiFCFjG')],
usage=RequestUsage(
input_tokens=46,
output_tokens=11,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 4, 29, 21, 7, 59, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BRmTHlrARTzAHK1na9s80xDlQGYPX',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_image',
content=ImageUrl(
url='https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg'
),
tool_call_id='call_4hrT4QP9jfojtK69vGiFCFjG',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='The image shows a potato.')],
usage=RequestUsage(
input_tokens=503,
output_tokens=8,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 4, 29, 21, 8, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BRmTI0Y2zmkGw27kLarhsmiFQTGxR',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_image_as_binary_content_input(
allow_model_requests: None, image_content: BinaryContent, openai_api_key: str
):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
result = await agent.run(['What fruit is in the image?', image_content])
assert result.output == snapshot('The fruit in the image is a kiwi.')
async def test_audio_as_binary_content_input(
allow_model_requests: None, audio_content: BinaryContent, openai_api_key: str
):
m = OpenAIChatModel('gpt-4o-audio-preview', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
result = await agent.run(['Whose name is mentioned in the audio?', audio_content])
assert result.output == snapshot('The name mentioned in the audio is Marcelo.')
assert result.usage == snapshot(
RunUsage(
input_tokens=64,
output_tokens=9,
input_audio_tokens=44,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
'text_tokens': 9,
},
requests=1,
)
)
async def test_document_as_binary_content_input(
allow_model_requests: None, document_content: BinaryContent, openai_api_key: str
):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
result = await agent.run(['What is the main content on this document?', document_content])
assert result.output == snapshot('The main content of the document is "Dummy PDF file."')
async def test_text_document_url_input(
allow_model_requests: None, openai_api_key: str, disable_ssrf_protection_for_vcr: None
):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
document_url = DocumentUrl(url='https://www.w3.org/TR/2003/REC-PNG-20031110/iso_8859-1.txt')
result = await agent.run(['What is the main content on this document, in one sentence?', document_url])
assert result.output == snapshot(
'The document lists the graphical characters defined by ISO 8859-1 (1987) with their hexadecimal codes and descriptions.'
)
async def test_text_document_as_binary_content_input(
allow_model_requests: None, text_document_content: BinaryContent, openai_api_key: str
):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
result = await agent.run(['What is the main content on this document?', text_document_content])
assert result.output == snapshot(
'The main content of the document is simply the text "Dummy TXT file." It does not appear to contain any other detailed information.'
)
async def test_yaml_document_as_binary_content_input(allow_model_requests: None, openai_api_key: str):
yaml_content = BinaryContent(
data=b'version: "3"\nservices:\n web:\n image: nginx', media_type='application/yaml'
)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
result = await agent.run(['What type of configuration is this?', yaml_content])
assert result.output == snapshot(
'The configuration you provided is a YAML file for Docker Compose. Docker Compose is a tool used for defining and running multi-container Docker applications. In this specific configuration, the YAML file is specifying a single service called `web`, which uses the `nginx` Docker image. The file starts with specifying the Compose file version as "3", indicating the format version used for composing the services.'
)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content=[
'What type of configuration is this?',
BinaryContent(
data=b'version: "3"\nservices:\n web:\n image: nginx', media_type='application/yaml'
),
],
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content='The configuration you provided is a YAML file for Docker Compose. Docker Compose is a tool used for defining and running multi-container Docker applications. In this specific configuration, the YAML file is specifying a single service called `web`, which uses the `nginx` Docker image. The file starts with specifying the Compose file version as "3", indicating the format version used for composing the services.'
)
],
usage=RequestUsage(
input_tokens=55,
output_tokens=77,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'stop', 'timestamp': IsDatetime()},
provider_response_id='chatcmpl-D1Fb52cAhS0I5T514KLWFLTvsJHYv',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_x_yaml_document_as_binary_content_input(allow_model_requests: None, openai_api_key: str):
x_yaml_content = BinaryContent(data=b'name: test\nversion: 1.0.0', media_type='application/x-yaml')
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
result = await agent.run(['What does this YAML describe?', x_yaml_content])
assert result.output == snapshot(
"""\
The provided YAML snippet is a basic descriptor for something labeled with the name "test" and a version number "1.0.0". Without additional context or accompanying fields, it's difficult to definitively say what specific application or resource this is describing. In a general sense, such a YAML configuration could be used for various purposes, including but not limited to:
1. **Software/Application:** It could describe a software application or component called "test" with version 1.0.0.
2. **Configuration Management:** It might be a part of a configuration management system for managing different versions of a service or application.
3. **Package Information:** If used in a package management context, it might represent metadata for a package or library.
4. **Service Definition:** It could represent a service or microservice within a larger system.
Each of these interpretations would depend on the broader context in which this YAML file is used. Further fields in the YAML file would provide more specificity about its purpose and functionality.\
"""
)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content=[
'What does this YAML describe?',
BinaryContent(data=b'name: test\nversion: 1.0.0', media_type='application/x-yaml'),
],
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content="""\
The provided YAML snippet is a basic descriptor for something labeled with the name "test" and a version number "1.0.0". Without additional context or accompanying fields, it's difficult to definitively say what specific application or resource this is describing. In a general sense, such a YAML configuration could be used for various purposes, including but not limited to:
1. **Software/Application:** It could describe a software application or component called "test" with version 1.0.0.
2. **Configuration Management:** It might be a part of a configuration management system for managing different versions of a service or application.
3. **Package Information:** If used in a package management context, it might represent metadata for a package or library.
4. **Service Definition:** It could represent a service or microservice within a larger system.
Each of these interpretations would depend on the broader context in which this YAML file is used. Further fields in the YAML file would provide more specificity about its purpose and functionality.\
"""
)
],
usage=RequestUsage(
input_tokens=57,
output_tokens=202,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'stop', 'timestamp': IsDatetime()},
provider_response_id='chatcmpl-D1Hu5C2mqc2CPw07SQa6U7Ki9PF7X',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_yaml_document_url_input(
allow_model_requests: None, openai_api_key: str, disable_ssrf_protection_for_vcr: None
):
"""Test that YAML files are treated as text-like and get inlined (not sent as file attachments)."""
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
document_url = DocumentUrl(url='https://raw.githubusercontent.com/pydantic/pydantic-ai/main/mkdocs.yml')
result = await agent.run(['What is the site_name in this YAML configuration?', document_url])
assert result.output == snapshot('The `site_name` in the provided YAML configuration is `"Pydantic AI"`.')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content=[
'What is the site_name in this YAML configuration?',
DocumentUrl(url='https://raw.githubusercontent.com/pydantic/pydantic-ai/main/mkdocs.yml'),
],
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='The `site_name` in the provided YAML configuration is `"Pydantic AI"`.')],
usage=RequestUsage(
input_tokens=3152,
output_tokens=18,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'stop', 'timestamp': IsDatetime()},
provider_response_id='chatcmpl-D9Y2SGcIahjmc95USEBhPxjrIEW8c',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_is_text_like_media_type():
"""Test is_text_like_media_type for branch coverage."""
assert _is_text_like_media_type('text/plain') is True
assert _is_text_like_media_type('text/html') is True
assert _is_text_like_media_type('application/json') is True
assert _is_text_like_media_type('application/xml') is True
assert _is_text_like_media_type('application/yaml') is True
assert _is_text_like_media_type('application/x-yaml') is True
assert _is_text_like_media_type('application/ld+json') is True
assert _is_text_like_media_type('application/soap+xml') is True
assert _is_text_like_media_type('application/pdf') is False
assert _is_text_like_media_type('image/png') is False
async def test_video_url_not_supported(allow_model_requests: None):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='test'))
agent = Agent(m)
with pytest.raises(NotImplementedError, match='VideoUrl is not supported in OpenAI Chat Completions user prompts'):
await agent.run(['Describe this video', VideoUrl(url='https://example.com/video.mp4')])
@pytest.mark.parametrize(
'content',
[
pytest.param(
DocumentUrl(url='https://example.com/test.pdf'),
id='document_url',
),
pytest.param(
BinaryContent(data=b'%PDF-1.4 test', media_type='application/pdf'),
id='binary_content',
),
],
)
async def test_document_input_not_supported(
allow_model_requests: None,
content: DocumentUrl | BinaryContent,
) -> None:
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(api_key='test'),
profile=OpenAIModelProfile(openai_chat_supports_document_input=False),
)
agent = Agent(m)
with pytest.raises(
UserError,
match="'openai' provider does not support document input via the Chat Completions API",
):
await agent.run(['Summarize this document', content])
async def test_document_as_binary_content_input_with_tool(
allow_model_requests: None, document_content: BinaryContent, openai_api_key: str
):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
@agent.tool_plain
async def get_upper_case(text: str) -> str:
return text.upper()
result = await agent.run(
[
'What is the main content on this document? Use the get_upper_case tool to get the upper case of the text.',
document_content,
]
)
assert result.output == snapshot('The main content of the document is "DUMMY PDF FILE" in uppercase.')
async def test_uploaded_file_chat_model(allow_model_requests: None) -> None:
"""Test that UploadedFile is correctly mapped in OpenAIChatModel."""
c = completion_message(
ChatCompletionMessage(content='The file contains important data.', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run(['Analyze this file', UploadedFile(file_id='file-abc123', provider_name='openai')])
assert result.output == 'The file contains important data.'
completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0]
messages = completion_kwargs['messages']
assert messages == snapshot(
[
{
'role': 'user',
'content': [
{'text': 'Analyze this file', 'type': 'text'},
{'file': {'file_id': 'file-abc123'}, 'type': 'file'},
],
}
]
)
async def test_uploaded_file_responses_model(allow_model_requests: None) -> None:
"""Test that UploadedFile is correctly mapped in OpenAIResponsesModel."""
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
output_item = ResponseOutputMessage(
id='msg_123',
type='message',
role='assistant',
status='completed',
content=[ResponseOutputText(text='The document says hello.', type='output_text', annotations=[])],
)
r = response_message([output_item])
mock_client = MockOpenAIResponses.create_mock(r)
m = OpenAIResponsesModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run(
['What does this document say?', UploadedFile(file_id='file-xyz789', provider_name='openai')]
)
assert result.output == 'The document says hello.'
responses_kwargs = get_mock_responses_kwargs(mock_client)[0]
input_content = responses_kwargs['input']
assert input_content == snapshot(
[
{
'role': 'user',
'content': [
{'text': 'What does this document say?', 'type': 'input_text'},
{'file_id': 'file-xyz789', 'type': 'input_file'},
],
}
]
)
async def test_uploaded_file_wrong_provider_chat(allow_model_requests: None) -> None:
"""Test that UploadedFile with wrong provider raises an error in OpenAIChatModel."""
c = completion_message(
ChatCompletionMessage(content='Should not reach here.', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(UserError, match=r"provider_name='anthropic'.*cannot be used with OpenAIChatModel"):
await agent.run(['Analyze this file', UploadedFile(file_id='file-abc123', provider_name='anthropic')])
async def test_uploaded_file_wrong_provider_responses(allow_model_requests: None) -> None:
"""Test that UploadedFile with wrong provider raises an error in OpenAIResponsesModel."""
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
output_item = ResponseOutputMessage(
id='msg_123',
type='message',
role='assistant',
status='completed',
content=[ResponseOutputText(text='Should not reach here.', type='output_text', annotations=[])],
)
r = response_message([output_item])
mock_client = MockOpenAIResponses.create_mock(r)
m = OpenAIResponsesModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(UserError, match=r"provider_name='anthropic'.*cannot be used with OpenAIResponsesModel"):
await agent.run(['Analyze this file', UploadedFile(file_id='file-xyz789', provider_name='anthropic')])
async def test_text_content_input(allow_model_requests: None):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='test-key'))
res = await m._map_user_prompt( # pyright: ignore[reportPrivateUsage]
part=UserPromptPart(content=['hello', TextContent(content='world', metadata={'id': 1})])
)
assert res == snapshot(
{'role': 'user', 'content': [{'text': 'hello', 'type': 'text'}, {'text': 'world', 'type': 'text'}]}
)
def test_model_status_error(allow_model_requests: None) -> None:
mock_client = MockOpenAI.create_mock(
APIStatusError(
'test error',
response=httpx.Response(status_code=500, request=httpx.Request('POST', 'https://example.com/v1')),
body={'error': 'test error'},
)
)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
agent.run_sync('hello')
assert str(exc_info.value) == snapshot("status_code: 500, model_name: gpt-4o, body: {'error': 'test error'}")
def test_model_connection_error(allow_model_requests: None) -> None:
mock_client = MockOpenAI.create_mock(
APIConnectionError(
message='Connection to http://localhost:11434/v1 timed out',
request=httpx.Request('POST', 'http://localhost:11434/v1'),
)
)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelAPIError) as exc_info:
agent.run_sync('hello')
assert exc_info.value.model_name == 'gpt-4o'
assert 'Connection to http://localhost:11434/v1 timed out' in str(exc_info.value.message)
def test_responses_model_connection_error(allow_model_requests: None) -> None:
mock_client = MockOpenAIResponses.create_mock(
APIConnectionError(
message='Connection to http://localhost:11434/v1 timed out',
request=httpx.Request('POST', 'http://localhost:11434/v1'),
)
)
m = OpenAIResponsesModel('o3-mini', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelAPIError) as exc_info:
agent.run_sync('hello')
assert exc_info.value.model_name == 'o3-mini'
assert 'Connection to http://localhost:11434/v1 timed out' in str(exc_info.value.message)
@pytest.mark.parametrize('model_name', ['o3-mini', 'gpt-4o-mini', 'gpt-4.5-preview'])
async def test_max_completion_tokens(allow_model_requests: None, model_name: str, openai_api_key: str):
m = OpenAIChatModel(model_name, provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m, model_settings=ModelSettings(max_tokens=100))
result = await agent.run('hello')
assert result.output == IsStr()
@pytest.mark.parametrize(
'supports_max_completion_tokens,sent_field,omitted_field',
[
(True, 'max_completion_tokens', 'max_tokens'),
(False, 'max_tokens', 'max_completion_tokens'),
],
)
async def test_max_tokens_field_routed_by_profile(
allow_model_requests: None,
supports_max_completion_tokens: bool,
sent_field: str,
omitted_field: str,
) -> None:
"""The `max_tokens` setting maps to whichever API field the profile selects.
OpenAI (and o-series) use `max_completion_tokens`; many compatible providers (e.g. OpenRouter)
only accept `max_tokens`. This is a unit test rather than VCR because our cassette matchers ignore
the request body, so a VCR test would still pass green if `max_tokens` were routed to the wrong key.
"""
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(openai_client=mock_client),
profile=OpenAIModelProfile(openai_chat_supports_max_completion_tokens=supports_max_completion_tokens),
)
agent = Agent(m, model_settings=ModelSettings(max_tokens=100))
await agent.run('Hello')
kwargs = get_mock_chat_completion_kwargs(mock_client)[0]
assert kwargs[sent_field] == 100
assert omitted_field not in kwargs
async def test_multiple_agent_tool_calls(allow_model_requests: None, gemini_api_key: str, openai_api_key: str):
gemini_model = GoogleModel('gemini-2.0-flash-exp', provider=GoogleProvider(api_key=gemini_api_key))
openai_model = OpenAIChatModel('gpt-4o-mini', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(model=gemini_model)
@agent.tool_plain
async def get_capital(country: str) -> str:
"""Get the capital of a country.
Args:
country: The country name.
"""
if country == 'France':
return 'Paris'
elif country == 'England':
return 'London'
else:
raise ValueError(f'Country {country} not supported.') # pragma: no cover
result = await agent.run('What is the capital of France?')
assert result.output == snapshot('The capital of France is Paris.\n')
result = await agent.run(
'What is the capital of England?', model=openai_model, message_history=result.all_messages()
)
assert result.output == snapshot('The capital of England is London.')
async def test_message_history_can_start_with_model_response(allow_model_requests: None, openai_api_key: str):
"""Test that an agent run with message_history starting with ModelResponse is executed correctly."""
openai_model = OpenAIChatModel('gpt-4.1-mini', provider=OpenAIProvider(api_key=openai_api_key))
message_history = [ModelResponse(parts=[TextPart('Where do you want to go today?')])]
agent = Agent(model=openai_model)
result = await agent.run('Answer in 5 words only. Who is Tux?', message_history=message_history)
assert result.output == snapshot('Linux mascot, a penguin character.')
assert result.all_messages() == snapshot(
[
ModelResponse(
parts=[TextPart(content='Where do you want to go today?')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(
content='Answer in 5 words only. Who is Tux?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Linux mascot, a penguin character.')],
usage=RequestUsage(
input_tokens=31,
output_tokens=8,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4.1-mini-2025-04-14',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 11, 22, 10, 1, 40, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-Ceeiy4ivEE0hcL1EX5ZfLuW5xNUXB',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_extra_headers(allow_model_requests: None, openai_api_key: str):
# This test doesn't do anything, it's just here to ensure that calls with `extra_headers` don't cause errors, including type.
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m, model_settings=OpenAIChatModelSettings(extra_headers={'Extra-Header-Key': 'Extra-Header-Value'}))
await agent.run('hello')
async def test_openai_store_false(allow_model_requests: None):
"""Test that openai_store=False is correctly passed to the OpenAI API."""
c = completion_message(ChatCompletionMessage(content='hello', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, model_settings=OpenAIChatModelSettings(openai_store=False))
result = await agent.run('test')
assert result.output == 'hello'
# Verify the store parameter was passed to the mock
kwargs = get_mock_chat_completion_kwargs(mock_client)[0]
assert kwargs.get('store') is False
async def test_openai_store_true(allow_model_requests: None):
"""Test that openai_store=True is correctly passed to the OpenAI API."""
c = completion_message(ChatCompletionMessage(content='hello', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m, model_settings=OpenAIChatModelSettings(openai_store=True))
result = await agent.run('test')
assert result.output == 'hello'
# Verify the store parameter was passed to the mock
kwargs = get_mock_chat_completion_kwargs(mock_client)[0]
assert kwargs.get('store') is True
async def test_user_id(allow_model_requests: None, openai_api_key: str):
# This test doesn't do anything, it's just here to ensure that calls with `user` don't cause errors, including type.
# Since we use VCR, creating tests with an `httpx.Transport` is not possible.
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m, model_settings=OpenAIChatModelSettings(openai_user='user_id'))
await agent.run('hello')
@dataclass
class MyDefaultDc:
x: int = 1
class MyEnum(Enum):
a = 'a'
b = 'b'
@dataclass
class MyRecursiveDc:
field: MyRecursiveDc | None
my_enum: MyEnum = Field(description='my enum')
@dataclass
class MyDefaultRecursiveDc:
field: MyDefaultRecursiveDc | None = None
class MyModel(BaseModel):
foo: str
class MyDc(BaseModel):
foo: str
class MyOptionalDc(BaseModel):
foo: str | None
bar: str
class MyExtrasDc(BaseModel, extra='allow'):
foo: str
class MyNormalTypedDict(TypedDict):
foo: str
class MyOptionalTypedDict(TypedDict):
foo: NotRequired[str]
bar: str
class MyPartialTypedDict(TypedDict, total=False):
foo: str
class MyExtrasModel(BaseModel, extra='allow'):
pass
def strict_compatible_tool(x: int) -> str:
return str(x) # pragma: no cover
def tool_with_default(x: int = 1) -> str:
return f'{x}' # pragma: no cover
def tool_with_datetime(x: datetime) -> str:
return f'{x}' # pragma: no cover
def tool_with_decimal(x: Decimal) -> str:
return f'{x}' # pragma: no cover
def tool_with_url(x: AnyUrl) -> str:
return f'{x}' # pragma: no cover
def tool_with_recursion(x: MyRecursiveDc, y: MyDefaultRecursiveDc):
return f'{x} {y}' # pragma: no cover
def tool_with_model(x: MyModel) -> str:
return f'{x}' # pragma: no cover
def tool_with_dataclass(x: MyDc) -> str:
return f'{x}' # pragma: no cover
def tool_with_optional_dataclass(x: MyOptionalDc) -> str:
return f'{x}' # pragma: no cover
def tool_with_dataclass_with_extras(x: MyExtrasDc) -> str:
return f'{x}' # pragma: no cover
def tool_with_typed_dict(x: MyNormalTypedDict) -> str:
return f'{x}' # pragma: no cover
def tool_with_optional_typed_dict(x: MyOptionalTypedDict) -> str:
return f'{x}' # pragma: no cover
def tool_with_partial_typed_dict(x: MyPartialTypedDict) -> str:
return f'{x}' # pragma: no cover
def tool_with_model_with_extras(x: MyExtrasModel) -> str:
return f'{x}' # pragma: no cover
def tool_with_kwargs(x: int, **kwargs: Any) -> str:
return f'{x} {kwargs}' # pragma: no cover
def tool_with_typed_kwargs(x: int, **kwargs: int) -> str:
return f'{x} {kwargs}' # pragma: no cover
def tool_with_union(x: int | MyDefaultDc) -> str:
return f'{x}' # pragma: no cover
def tool_with_discriminated_union(
x: Annotated[
Annotated[int, Tag('int')] | Annotated[MyDefaultDc, Tag('MyDefaultDc')],
Discriminator(lambda x: type(x).__name__),
],
) -> str:
return f'{x}' # pragma: no cover
def tool_with_lists(x: list[int], y: list[MyDefaultDc]) -> str:
return f'{x} {y}' # pragma: no cover
def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str:
return f'{x} {y}' # pragma: no cover
@pytest.mark.parametrize(
'tool,tool_strict,expected_params,expected_strict',
[
(
strict_compatible_tool,
False,
snapshot(
{
'additionalProperties': False,
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_default,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'x': {'default': 1, 'type': 'integer'}},
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_datetime,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'x': {'format': 'date-time', 'type': 'string'}},
'required': ['x'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_decimal,
None,
snapshot(
{
'additionalProperties': False,
'properties': {
'x': {
'anyOf': [
{'type': 'number'},
{
'pattern': '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$',
'type': 'string',
},
]
}
},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_decimal,
True,
snapshot(
{
'additionalProperties': False,
'properties': {
'x': {
'anyOf': [
{'type': 'number'},
{
'type': 'string',
'description': 'pattern=^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$',
},
]
}
},
'required': ['x'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_url,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'x': {'format': 'uri', 'minLength': 1, 'type': 'string'}},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_url,
True,
snapshot(
{
'additionalProperties': False,
'properties': {'x': {'type': 'string', 'description': 'minLength=1, format=uri'}},
'required': ['x'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_recursion,
None,
snapshot(
{
'$defs': {
'MyDefaultRecursiveDc': {
'properties': {
'field': {
'anyOf': [{'$ref': '#/$defs/MyDefaultRecursiveDc'}, {'type': 'null'}],
'default': None,
}
},
'type': 'object',
'additionalProperties': False,
},
'MyEnum': {'enum': ['a', 'b'], 'type': 'string'},
'MyRecursiveDc': {
'properties': {
'field': {'anyOf': [{'$ref': '#/$defs/MyRecursiveDc'}, {'type': 'null'}]},
'my_enum': {'description': 'my enum', 'anyOf': [{'$ref': '#/$defs/MyEnum'}]},
},
'required': ['field', 'my_enum'],
'type': 'object',
'additionalProperties': False,
},
},
'additionalProperties': False,
'properties': {
'x': {'$ref': '#/$defs/MyRecursiveDc'},
'y': {'$ref': '#/$defs/MyDefaultRecursiveDc'},
},
'required': ['x', 'y'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_recursion,
True,
snapshot(
{
'$defs': {
'MyDefaultRecursiveDc': {
'properties': {
'field': {'anyOf': [{'$ref': '#/$defs/MyDefaultRecursiveDc'}, {'type': 'null'}]}
},
'type': 'object',
'additionalProperties': False,
'required': ['field'],
},
'MyEnum': {'enum': ['a', 'b'], 'type': 'string'},
'MyRecursiveDc': {
'properties': {
'field': {'anyOf': [{'$ref': '#/$defs/MyRecursiveDc'}, {'type': 'null'}]},
'my_enum': {'description': 'my enum', 'anyOf': [{'$ref': '#/$defs/MyEnum'}]},
},
'type': 'object',
'additionalProperties': False,
'required': ['field', 'my_enum'],
},
},
'additionalProperties': False,
'properties': {
'x': {'$ref': '#/$defs/MyRecursiveDc'},
'y': {'$ref': '#/$defs/MyDefaultRecursiveDc'},
},
'required': ['x', 'y'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_model,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'foo': {'type': 'string'}},
'required': ['foo'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_dataclass,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'foo': {'type': 'string'}},
'required': ['foo'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_optional_dataclass,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'foo': {'anyOf': [{'type': 'string'}, {'type': 'null'}]}, 'bar': {'type': 'string'}},
'required': ['foo', 'bar'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_dataclass_with_extras,
None,
snapshot(
{
'additionalProperties': True,
'properties': {'foo': {'type': 'string'}},
'required': ['foo'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_typed_dict,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'foo': {'type': 'string'}},
'required': ['foo'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_optional_typed_dict,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'foo': {'type': 'string'}, 'bar': {'type': 'string'}},
'required': ['bar'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_partial_typed_dict,
None,
snapshot(
{
'additionalProperties': False,
'properties': {'foo': {'type': 'string'}},
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_model_with_extras,
None,
snapshot(
{
'additionalProperties': True,
'properties': {},
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_model_with_extras,
True,
snapshot(
{
'additionalProperties': False,
'properties': {},
'required': [],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_kwargs,
None,
snapshot(
{
'additionalProperties': True,
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_kwargs,
True,
snapshot(
{
'additionalProperties': False,
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_typed_kwargs,
None,
snapshot(
{
'additionalProperties': {'type': 'integer'},
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_union,
None,
snapshot(
{
'$defs': {
'MyDefaultDc': {
'properties': {'x': {'default': 1, 'type': 'integer'}},
'type': 'object',
'additionalProperties': False,
}
},
'additionalProperties': False,
'properties': {'x': {'anyOf': [{'type': 'integer'}, {'$ref': '#/$defs/MyDefaultDc'}]}},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_union,
True,
snapshot(
{
'$defs': {
'MyDefaultDc': {
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
'type': 'object',
'additionalProperties': False,
}
},
'additionalProperties': False,
'properties': {'x': {'anyOf': [{'type': 'integer'}, {'$ref': '#/$defs/MyDefaultDc'}]}},
'required': ['x'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_discriminated_union,
None,
snapshot(
{
'$defs': {
'MyDefaultDc': {
'properties': {'x': {'default': 1, 'type': 'integer'}},
'type': 'object',
'additionalProperties': False,
}
},
'additionalProperties': False,
'properties': {'x': {'oneOf': [{'type': 'integer'}, {'$ref': '#/$defs/MyDefaultDc'}]}},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_discriminated_union,
True,
snapshot(
{
'$defs': {
'MyDefaultDc': {
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
'type': 'object',
'additionalProperties': False,
}
},
'additionalProperties': False,
'properties': {'x': {'anyOf': [{'type': 'integer'}, {'$ref': '#/$defs/MyDefaultDc'}]}},
'required': ['x'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_lists,
None,
snapshot(
{
'$defs': {
'MyDefaultDc': {
'properties': {'x': {'default': 1, 'type': 'integer'}},
'type': 'object',
'additionalProperties': False,
}
},
'additionalProperties': False,
'properties': {
'x': {'items': {'type': 'integer'}, 'type': 'array'},
'y': {'items': {'$ref': '#/$defs/MyDefaultDc'}, 'type': 'array'},
},
'required': ['x', 'y'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_lists,
True,
snapshot(
{
'$defs': {
'MyDefaultDc': {
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
'type': 'object',
'additionalProperties': False,
}
},
'additionalProperties': False,
'properties': {
'x': {'items': {'type': 'integer'}, 'type': 'array'},
'y': {'items': {'$ref': '#/$defs/MyDefaultDc'}, 'type': 'array'},
},
'required': ['x', 'y'],
'type': 'object',
}
),
snapshot(True),
),
(
tool_with_tuples,
None,
snapshot(
{
'additionalProperties': False,
'properties': {
'x': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'integer'}], 'type': 'array'},
'y': {
'default': ['abc'],
'maxItems': 1,
'minItems': 1,
'prefixItems': [{'type': 'string'}],
'type': 'array',
},
},
'required': ['x'],
'type': 'object',
}
),
snapshot(None),
),
(
tool_with_tuples,
True,
snapshot(
{
'additionalProperties': False,
'properties': {
'x': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'integer'}], 'type': 'array'},
'y': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'string'}], 'type': 'array'},
},
'required': ['x', 'y'],
'type': 'object',
}
),
snapshot(True),
),
# (tool, None, snapshot({}), snapshot({})),
# (tool, True, snapshot({}), snapshot({})),
],
)
async def test_strict_mode_cannot_infer_strict(
allow_model_requests: None,
tool: Callable[..., Any],
tool_strict: bool | None,
expected_params: dict[str, Any],
expected_strict: bool | None,
):
"""Test that strict mode settings are properly passed to OpenAI and respect precedence rules."""
# Create a mock completion for testing
c = completion_message(ChatCompletionMessage(content='world', role='assistant'))
async def assert_strict(expected_strict: bool | None, profile: ModelProfile | None = None):
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
agent = Agent(m)
agent.tool_plain(strict=tool_strict)(tool)
await agent.run('hello')
kwargs = get_mock_chat_completion_kwargs(mock_client)[0]
assert 'tools' in kwargs, kwargs
assert kwargs['tools'][0]['function']['parameters'] == expected_params
actual_strict = kwargs['tools'][0]['function'].get('strict')
assert actual_strict == expected_strict
if actual_strict is None:
# If strict is included, it should be non-None
assert 'strict' not in kwargs['tools'][0]['function']
await assert_strict(expected_strict)
# If the model profile says strict is not supported, we never pass strict
await assert_strict(
None,
profile=merge_profile(
OpenAIModelProfile(openai_supports_strict_tool_definition=False), openai_model_profile('test-model')
),
)
def test_strict_schema():
class Apple(BaseModel):
kind: Literal['apple'] = 'apple'
class Banana(BaseModel):
kind: Literal['banana'] = 'banana'
class MyModel(BaseModel):
# We have all these different crazy fields to achieve coverage
my_recursive: MyModel | None = None
my_patterns: dict[Annotated[str, Field(pattern='^my-pattern$')], str]
my_tuple: tuple[int]
my_list: list[float]
my_discriminated_union: Annotated[Apple | Banana, Discriminator('kind')]
assert OpenAIJsonSchemaTransformer(MyModel.model_json_schema(), strict=True).walk() == snapshot(
{
'$defs': {
'Apple': {
'additionalProperties': False,
'properties': {'kind': {'const': 'apple', 'type': 'string'}},
'required': ['kind'],
'type': 'object',
},
'Banana': {
'additionalProperties': False,
'properties': {'kind': {'const': 'banana', 'type': 'string'}},
'required': ['kind'],
'type': 'object',
},
'MyModel': {
'additionalProperties': False,
'properties': {
'my_discriminated_union': {'anyOf': [{'$ref': '#/$defs/Apple'}, {'$ref': '#/$defs/Banana'}]},
'my_list': {'items': {'type': 'number'}, 'type': 'array'},
'my_patterns': {
'additionalProperties': False,
'description': "patternProperties={'^my-pattern$': {'type': 'string'}}",
'type': 'object',
'properties': {},
'required': [],
},
'my_recursive': {'anyOf': [{'$ref': '#'}, {'type': 'null'}]},
'my_tuple': {
'maxItems': 1,
'minItems': 1,
'prefixItems': [{'type': 'integer'}],
'type': 'array',
},
},
'required': ['my_recursive', 'my_patterns', 'my_tuple', 'my_list', 'my_discriminated_union'],
'type': 'object',
},
},
'properties': {
'my_recursive': {'anyOf': [{'$ref': '#'}, {'type': 'null'}]},
'my_patterns': {
'type': 'object',
'description': "patternProperties={'^my-pattern$': {'type': 'string'}}",
'additionalProperties': False,
'properties': {},
'required': [],
},
'my_tuple': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'integer'}], 'type': 'array'},
'my_list': {'items': {'type': 'number'}, 'type': 'array'},
'my_discriminated_union': {'anyOf': [{'$ref': '#/$defs/Apple'}, {'$ref': '#/$defs/Banana'}]},
},
'required': ['my_recursive', 'my_patterns', 'my_tuple', 'my_list', 'my_discriminated_union'],
'type': 'object',
'additionalProperties': False,
}
)
def test_native_output_strict_mode(allow_model_requests: None):
class CityLocation(BaseModel):
city: str
country: str
c = completion_message(
ChatCompletionMessage(content='{"city": "Mexico City", "country": "Mexico"}', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
# Explicit strict=True
agent = Agent(model, output_type=NativeOutput(CityLocation, strict=True))
agent.run_sync('What is the capital of Mexico?')
assert get_mock_chat_completion_kwargs(mock_client)[-1]['response_format']['json_schema']['strict'] is True
# Explicit strict=False
agent = Agent(model, output_type=NativeOutput(CityLocation, strict=False))
agent.run_sync('What is the capital of Mexico?')
assert get_mock_chat_completion_kwargs(mock_client)[-1]['response_format']['json_schema']['strict'] is False
# Strict-compatible
agent = Agent(model, output_type=NativeOutput(CityLocation))
agent.run_sync('What is the capital of Mexico?')
assert get_mock_chat_completion_kwargs(mock_client)[-1]['response_format']['json_schema']['strict'] is True
# Strict-incompatible
CityLocation.model_config = ConfigDict(extra='allow')
agent = Agent(model, output_type=NativeOutput(CityLocation))
agent.run_sync('What is the capital of Mexico?')
assert get_mock_chat_completion_kwargs(mock_client)[-1]['response_format']['json_schema']['strict'] is False
async def test_openai_instructions(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m, instructions='You are a helpful assistant.')
result = await agent.run('What is the capital of France?')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='What is the capital of France?', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='The capital of France is Paris.')],
usage=RequestUsage(
input_tokens=24,
output_tokens=8,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 4, 7, 16, 30, 56, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BJjf61mLb9z5H45ClJzbx0UWKwjo1',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_model_without_system_prompt(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('o3-mini', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m, system_prompt='You are a potato.')
result = await agent.run()
assert result.output == snapshot(
"That's right—I am a potato! A spud of many talents, here to help you out. How can this humble potato be of service today?"
)
async def test_openai_instructions_with_tool_calls_keep_instructions(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4.1-mini', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m, instructions='You are a helpful assistant.')
@agent.tool_plain
async def get_temperature(city: str) -> float:
return 20.0
result = await agent.run('What is the temperature in Tokyo?')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='What is the temperature in Tokyo?', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='get_temperature', args='{"city":"Tokyo"}', tool_call_id=IsStr())],
usage=RequestUsage(
input_tokens=50,
output_tokens=15,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4.1-mini-2025-04-14',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 4, 16, 13, 37, 14, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BMxEwRA0p0gJ52oKS7806KAlfMhqq',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_temperature', content=20.0, tool_call_id=IsStr(), timestamp=IsDatetime()
)
],
timestamp=IsDatetime(),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='The temperature in Tokyo is currently 20.0 degrees Celsius.')],
usage=RequestUsage(
input_tokens=75,
output_tokens=15,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4.1-mini-2025-04-14',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 4, 16, 13, 37, 15, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BMxEx6B8JEj6oDC45MOWKp0phg8UP',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_model_thinking_part(allow_model_requests: None, openai_api_key: str):
provider = OpenAIProvider(api_key=openai_api_key)
responses_model = OpenAIResponsesModel('o3-mini', provider=provider)
settings = OpenAIResponsesModelSettings(openai_reasoning_effort='high', openai_reasoning_summary='detailed')
agent = Agent(responses_model, model_settings=settings)
result = await agent.run('How do I cross the street?')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='How do I cross the street?', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ThinkingPart(
content=IsStr(),
id='rs_68c1fa166e9c81979ff56b16882744f1093f57e27128848a',
signature=IsStr(),
provider_name='openai',
),
ThinkingPart(
content=IsStr(),
id='rs_68c1fa166e9c81979ff56b16882744f1093f57e27128848a',
provider_name='openai',
),
TextPart(
content=IsStr(),
id='msg_68c1fa1ec9448197b5c8f78a90999360093f57e27128848a',
provider_name='openai',
),
],
usage=RequestUsage(input_tokens=13, output_tokens=1915, details={'reasoning_tokens': 1600}),
model_name='o3-mini-2025-01-31',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'completed',
'timestamp': datetime(2025, 9, 10, 22, 21, 57, tzinfo=timezone.utc),
},
provider_response_id='resp_68c1fa0523248197888681b898567bde093f57e27128848a',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
result = await agent.run(
'Considering the way to cross the street, analogously, how do I cross the river?',
model=OpenAIChatModel('o3-mini', provider=provider),
message_history=result.all_messages(),
)
assert result.new_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Considering the way to cross the street, analogously, how do I cross the river?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content=IsStr())],
usage=RequestUsage(
input_tokens=577,
output_tokens=2320,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 1792,
'rejected_prediction_tokens': 0,
},
),
model_name='o3-mini-2025-01-31',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 9, 10, 22, 22, 24, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-CENUmtwDD0HdvTUYL6lUeijDtxrZL',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_instructions_with_logprobs(allow_model_requests: None):
# Create a mock response with logprobs
c = completion_message(
ChatCompletionMessage(content='world', role='assistant'),
logprobs=ChoiceLogprobs(
content=[
ChatCompletionTokenLogprob(
token='world', logprob=-0.6931, top_logprobs=[], bytes=[119, 111, 114, 108, 100]
)
],
),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(openai_client=mock_client),
)
agent = Agent(m, instructions='You are a helpful assistant.')
result = await agent.run(
'What is the capital of Minas Gerais?',
model_settings=OpenAIChatModelSettings(openai_logprobs=True),
)
messages = result.all_messages()
response = cast(Any, messages[1])
assert response.provider_details is not None
assert response.provider_details['logprobs'] == [
{
'token': 'world',
'logprob': -0.6931,
'bytes': [119, 111, 114, 108, 100],
'top_logprobs': [],
}
]
async def test_openai_instructions_with_responses_logprobs(allow_model_requests: None, openai_api_key: str):
m = OpenAIResponsesModel(
'gpt-4o-mini',
provider=OpenAIProvider(api_key=openai_api_key),
)
agent = Agent(m, instructions='You are a helpful assistant.')
result = await agent.run(
'What is the capital of Minas Gerais?',
model_settings=OpenAIResponsesModelSettings(openai_logprobs=True),
)
messages = result.all_messages()
response = cast(Any, messages[1])
text_part = response.parts[0]
assert hasattr(text_part, 'provider_details')
assert text_part.provider_details is not None
assert 'logprobs' in text_part.provider_details
assert text_part.provider_details['logprobs'] == [
{'token': 'The', 'logprob': -0.0, 'bytes': [84, 104, 101], 'top_logprobs': []},
{'token': ' capital', 'logprob': 0.0, 'bytes': [32, 99, 97, 112, 105, 116, 97, 108], 'top_logprobs': []},
{'token': ' of', 'logprob': 0.0, 'bytes': [32, 111, 102], 'top_logprobs': []},
{'token': ' Minas', 'logprob': -0.0, 'bytes': [32, 77, 105, 110, 97, 115], 'top_logprobs': []},
{'token': ' Gerais', 'logprob': -0.0, 'bytes': [32, 71, 101, 114, 97, 105, 115], 'top_logprobs': []},
{'token': ' is', 'logprob': -5.2e-05, 'bytes': [32, 105, 115], 'top_logprobs': []},
{'token': ' Belo', 'logprob': -4.3e-05, 'bytes': [32, 66, 101, 108, 111], 'top_logprobs': []},
{
'token': ' Horizonte',
'logprob': -2.0e-06,
'bytes': [32, 72, 111, 114, 105, 122, 111, 110, 116, 101],
'top_logprobs': [],
},
{'token': '.', 'logprob': -0.0, 'bytes': [46], 'top_logprobs': []},
]
async def test_openai_instructions_with_responses_logprobs_streaming(allow_model_requests: None, openai_api_key: str):
m = OpenAIResponsesModel(
'gpt-4o-mini',
provider=OpenAIProvider(api_key=openai_api_key),
)
agent = Agent(m, instructions='You are a helpful assistant.')
async with agent.run_stream_events(
'What is the capital of Minas Gerais?',
model_settings=OpenAIResponsesModelSettings(openai_logprobs=True),
) as event_stream:
events = [event async for event in event_stream]
logprob_events = [
event
for event in events
if isinstance(event, PartDeltaEvent)
and isinstance(event.delta, TextPartDelta)
and event.delta.provider_details
and 'logprobs' in event.delta.provider_details
]
assert len(logprob_events) > 0
first_logprob = cast(Any, logprob_events[0].delta).provider_details['logprobs']
assert isinstance(first_logprob, list)
assert all(isinstance(lp, dict) and 'token' in lp and 'logprob' in lp for lp in cast(list[Any], first_logprob))
part_end_events = [
event
for event in events
if isinstance(event, PartEndEvent)
and isinstance(event.part, TextPart)
and event.part.provider_details
and 'logprobs' in event.part.provider_details
]
assert len(part_end_events) == 1
assert cast(Any, part_end_events[0].part).provider_details['logprobs'] == first_logprob
async def test_openai_web_search_tool_model_not_supported(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(
m,
instructions='You are a helpful assistant.',
capabilities=[NativeTool(WebSearchTool(search_context_size='low'))],
)
with pytest.raises(
UserError,
match=r"WebSearchTool is not supported with `OpenAIChatModel` and model 'gpt-4o'.*OpenAIResponsesModel",
):
await agent.run('What day is today?')
async def test_openai_web_search_tool(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o-search-preview', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(
m,
instructions='You are a helpful assistant.',
capabilities=[NativeTool(WebSearchTool(search_context_size='low'))],
)
result = await agent.run('What day is today?')
assert result.output == snapshot('May 14, 2025, 8:51:29 AM ')
async def test_openai_web_search_tool_with_user_location(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o-search-preview', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(
m,
instructions='You are a helpful assistant.',
capabilities=[NativeTool(WebSearchTool(user_location={'city': 'Utrecht', 'country': 'NL'}))],
)
result = await agent.run('What is the current temperature?')
assert result.output == snapshot("""\
Het is momenteel zonnig in Utrecht met een temperatuur van 22°C.
## Weer voor Utrecht, Nederland:
Huidige omstandigheden: Zonnig, 72°F (22°C)
Dagvoorspelling:
* woensdag, mei 14: minimum: 48°F (9°C), maximum: 71°F (22°C), beschrijving: Afnemende bewolking
* donderdag, mei 15: minimum: 43°F (6°C), maximum: 67°F (20°C), beschrijving: Na een bewolkt begin keert de zon terug
* vrijdag, mei 16: minimum: 45°F (7°C), maximum: 64°F (18°C), beschrijving: Overwegend zonnig
* zaterdag, mei 17: minimum: 47°F (9°C), maximum: 68°F (20°C), beschrijving: Overwegend zonnig
* zondag, mei 18: minimum: 47°F (8°C), maximum: 68°F (20°C), beschrijving: Deels zonnig
* maandag, mei 19: minimum: 49°F (9°C), maximum: 70°F (21°C), beschrijving: Deels zonnig
* dinsdag, mei 20: minimum: 49°F (10°C), maximum: 72°F (22°C), beschrijving: Zonnig tot gedeeltelijk bewolkt
\
""")
async def test_reasoning_model_with_temperature(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('o3-mini', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m, model_settings=OpenAIChatModelSettings(temperature=0.5))
with pytest.warns(UserWarning, match='Sampling parameters.*temperature.*not supported when reasoning is enabled'):
result = await agent.run('What is the capital of Mexico?')
assert result.output == snapshot(
'The capital of Mexico is Mexico City. It is not only the seat of the federal government but also a major cultural, political, and economic center in the country.'
)
def test_openai_model_profile():
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='foobar'))
assert isinstance(m.profile, dict)
def test_openai_model_profile_custom():
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(api_key='foobar'),
profile=ModelProfile(json_schema_transformer=InlineDefsJsonSchemaTransformer),
)
assert isinstance(m.profile, dict)
assert m.profile.get('json_schema_transformer', None) is InlineDefsJsonSchemaTransformer
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(api_key='foobar'),
profile=OpenAIModelProfile(openai_supports_strict_tool_definition=False),
)
assert isinstance(m.profile, dict)
assert m.profile.get('openai_supports_strict_tool_definition', True) is False
def test_openai_model_profile_callable():
"""The user `profile=` kwarg accepts a `Callable[[ModelProfile], ModelProfile]` that receives the resolved default and returns the final profile."""
def override(default: ModelProfile) -> ModelProfile:
return merge_profile(default, ModelProfile(json_schema_transformer=InlineDefsJsonSchemaTransformer))
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='foobar'), profile=override)
assert isinstance(m.profile, dict)
assert m.profile.get('json_schema_transformer', None) is InlineDefsJsonSchemaTransformer
# The callable can also fully replace the default.
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='foobar'), profile=lambda _default: ModelProfile())
assert isinstance(m.profile, dict)
assert m.profile.get('json_schema_transformer', None) is None
def test_openai_model_profile_from_provider():
class CustomProvider(OpenAIProvider):
@staticmethod
def model_profile(model_name: str) -> ModelProfile:
return ModelProfile(
json_schema_transformer=InlineDefsJsonSchemaTransformer if model_name == 'gpt-4o' else None
)
m = OpenAIChatModel('gpt-4o', provider=CustomProvider(api_key='foobar'))
assert isinstance(m.profile, dict)
assert m.profile.get('json_schema_transformer', None) is InlineDefsJsonSchemaTransformer
m = OpenAIChatModel('gpt-4o-mini', provider=CustomProvider(api_key='foobar'))
assert isinstance(m.profile, dict)
assert m.profile.get('json_schema_transformer', None) is None
def test_model_profile_strict_not_supported():
model_settings = ModelSettings()
my_tool = ToolDefinition(
name='my_tool',
description='This is my tool',
parameters_json_schema={'type': 'object', 'title': 'Result', 'properties': {'spam': {'type': 'number'}}},
strict=True,
)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key='foobar'))
tool_param = m._map_tool_definition(my_tool, model_settings) # type: ignore[reportPrivateUsage]
assert tool_param == snapshot(
{
'type': 'function',
'function': {
'name': 'my_tool',
'description': 'This is my tool',
'parameters': {'type': 'object', 'title': 'Result', 'properties': {'spam': {'type': 'number'}}},
'strict': True,
},
}
)
# Some models don't support strict tool definitions
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(api_key='foobar'),
profile=OpenAIModelProfile(openai_supports_strict_tool_definition=False),
)
tool_param = m._map_tool_definition(my_tool, model_settings) # type: ignore[reportPrivateUsage]
assert tool_param == snapshot(
{
'type': 'function',
'function': {
'name': 'my_tool',
'description': 'This is my tool',
'parameters': {'type': 'object', 'title': 'Result', 'properties': {'spam': {'type': 'number'}}},
},
}
)
async def test_compatible_api_with_tool_calls_without_id(allow_model_requests: None, gemini_api_key: str):
provider = OpenAIProvider(
openai_client=AsyncOpenAI(
base_url='https://generativelanguage.googleapis.com/v1beta/openai/',
api_key=gemini_api_key,
)
)
model = OpenAIChatModel('gemini-2.5-pro-preview-05-06', provider=provider)
agent = Agent(model)
@agent.tool_plain
def get_current_time() -> str:
"""Get the current time."""
return 'Noon'
response = await agent.run('What is the current time?')
assert response.output == snapshot('The current time is Noon.')
def test_openai_response_timestamp_milliseconds(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(content='world', role='assistant'),
)
# Some models on OpenRouter return timestamps in milliseconds rather than seconds
# https://github.com/pydantic/pydantic-ai/issues/1877
c.created = 1748747268000
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = agent.run_sync('Hello')
response = cast(ModelResponse, result.all_messages()[-1])
assert response.timestamp == IsNow(tz=timezone.utc)
assert response.provider_name == 'openai'
assert response.provider_details == snapshot(
{'finish_reason': 'stop', 'timestamp': datetime(2025, 6, 1, 3, 7, 48, tzinfo=timezone.utc)}
)
async def test_openai_tool_output(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
class CityLocation(BaseModel):
city: str
country: str
agent = Agent(m, output_type=ToolOutput(CityLocation))
@agent.tool_plain
async def get_user_country() -> str:
return 'Mexico'
result = await agent.run('What is the largest city in the user country?')
assert result.output == snapshot(CityLocation(city='Mexico City', country='Mexico'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the largest city in the user country?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='get_user_country', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(
input_tokens=68,
output_tokens=12,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 5, 1, 23, 36, 24, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BSXk0dWkG4hfPt0lph4oFO35iT73I',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_user_country',
content='Mexico',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City", "country": "Mexico"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(
input_tokens=89,
output_tokens=36,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 5, 1, 23, 36, 25, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BSXk1xGHYzbhXgUkSutK08bdoNv5s',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_text_output_function(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
def upcase(text: str) -> str:
return text.upper()
agent = Agent(m, output_type=TextOutput(upcase))
@agent.tool_plain
async def get_user_country() -> str:
return 'Mexico'
result = await agent.run('What is the largest city in the user country?')
assert result.output == snapshot('THE LARGEST CITY IN MEXICO IS MEXICO CITY.')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the largest city in the user country?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='get_user_country', args='{}', tool_call_id='call_J1YabdC7G7kzEZNbbZopwenH')
],
usage=RequestUsage(
input_tokens=42,
output_tokens=11,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 6, 9, 21, 20, 53, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BgeDFS85bfHosRFEEAvq8reaCPCZ8',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_user_country',
content='Mexico',
tool_call_id='call_J1YabdC7G7kzEZNbbZopwenH',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='The largest city in Mexico is Mexico City.')],
usage=RequestUsage(
input_tokens=63,
output_tokens=10,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 6, 9, 21, 20, 54, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BgeDGX9eDyVrEI56aP2vtIHahBzFH',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_native_output(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
class CityLocation(BaseModel):
"""A city and its country."""
city: str
country: str
agent = Agent(m, output_type=NativeOutput(CityLocation))
@agent.tool_plain
async def get_user_country() -> str:
return 'Mexico'
result = await agent.run('What is the largest city in the user country?')
assert result.output == snapshot(CityLocation(city='Mexico City', country='Mexico'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the largest city in the user country?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='get_user_country', args='{}', tool_call_id='call_PkRGedQNRFUzJp2R7dO7avWR')
],
usage=RequestUsage(
input_tokens=71,
output_tokens=12,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 5, 1, 23, 36, 22, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BSXjyBwGuZrtuuSzNCeaWMpGv2MZ3',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_user_country',
content='Mexico',
tool_call_id='call_PkRGedQNRFUzJp2R7dO7avWR',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"city":"Mexico City","country":"Mexico"}')],
usage=RequestUsage(
input_tokens=92,
output_tokens=15,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 5, 1, 23, 36, 23, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-BSXjzYGu67dhTy5r8KmjJvQ4HhDVO',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_responses_native_output_decimal_strict(allow_model_requests: None, openai_api_key: str):
m = OpenAIResponsesModel('gpt-5.4-mini', provider=OpenAIProvider(api_key=openai_api_key))
class Payment(BaseModel):
amount: Decimal
agent = Agent(m, output_type=NativeOutput(Payment, strict=True))
result = await agent.run('Return exactly this payment amount: 12.34')
assert result.output == snapshot(Payment(amount=Decimal('12.34')))
async def test_openai_native_output_multiple(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
class CityLocation(BaseModel):
city: str
country: str
class CountryLanguage(BaseModel):
country: str
language: str
agent = Agent(m, output_type=NativeOutput([CityLocation, CountryLanguage]))
@agent.tool_plain
async def get_user_country() -> str:
return 'Mexico'
result = await agent.run('What is the largest city in the user country?')
assert result.output == snapshot(CityLocation(city='Mexico City', country='Mexico'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the largest city in the user country?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='get_user_country', args='{}', tool_call_id='call_SIttSeiOistt33Htj4oiHOOX')
],
usage=RequestUsage(
input_tokens=160,
output_tokens=11,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 6, 9, 23, 21, 26, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-Bgg5utuCSXMQ38j0n2qgfdQKcR9VD',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_user_country',
content='Mexico',
tool_call_id='call_SIttSeiOistt33Htj4oiHOOX',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content='{"result":{"kind":"CityLocation","data":{"city":"Mexico City","country":"Mexico"}}}'
)
],
usage=RequestUsage(
input_tokens=181,
output_tokens=25,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 6, 9, 23, 21, 27, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-Bgg5vrxUtCDlvgMreoxYxPaKxANmd',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_prompted_output(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
class CityLocation(BaseModel):
city: str
country: str
agent = Agent(m, output_type=PromptedOutput(CityLocation))
@agent.tool_plain
async def get_user_country() -> str:
return 'Mexico'
result = await agent.run('What is the largest city in the user country?')
assert result.output == snapshot(CityLocation(city='Mexico City', country='Mexico'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the largest city in the user country?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='get_user_country', args='{}', tool_call_id='call_s7oT9jaLAsEqTgvxZTmFh0wB')
],
usage=RequestUsage(
input_tokens=109,
output_tokens=11,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 6, 10, 0, 21, 35, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-Bgh27PeOaFW6qmF04qC5uI2H9mviw',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_user_country',
content='Mexico',
tool_call_id='call_s7oT9jaLAsEqTgvxZTmFh0wB',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"city":"Mexico City","country":"Mexico"}')],
usage=RequestUsage(
input_tokens=130,
output_tokens=11,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 6, 10, 0, 21, 36, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-Bgh28advCSFhGHPnzUevVS6g6Uwg0',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_openai_prompted_output_multiple(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
class CityLocation(BaseModel):
city: str
country: str
class CountryLanguage(BaseModel):
country: str
language: str
agent = Agent(m, output_type=PromptedOutput([CityLocation, CountryLanguage]))
@agent.tool_plain
async def get_user_country() -> str:
return 'Mexico'
result = await agent.run('What is the largest city in the user country?')
assert result.output == snapshot(CityLocation(city='Mexico City', country='Mexico'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the largest city in the user country?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='get_user_country', args='{}', tool_call_id='call_wJD14IyJ4KKVtjCrGyNCHO09')
],
usage=RequestUsage(
input_tokens=273,
output_tokens=11,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'tool_calls',
'timestamp': datetime(2025, 6, 10, 0, 21, 38, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-Bgh2AW2NXGgMc7iS639MJXNRgtatR',
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_user_country',
content='Mexico',
tool_call_id='call_wJD14IyJ4KKVtjCrGyNCHO09',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content='{"result":{"kind":"CityLocation","data":{"city":"Mexico City","country":"Mexico"}}}'
)
],
usage=RequestUsage(
input_tokens=294,
output_tokens=21,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2025, 6, 10, 0, 21, 39, tzinfo=timezone.utc),
},
provider_response_id='chatcmpl-Bgh2BthuopRnSqCuUgMbBnOqgkDHC',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_valid_response(env: TestEnv, allow_model_requests: None):
"""VCR recording is of a valid response."""
env.set('OPENAI_API_KEY', 'foobar')
agent = Agent('openai-chat:gpt-4o')
result = await agent.run('What is the capital of France?')
assert result.output == snapshot('The capital of France is Paris.')
async def test_invalid_response(allow_model_requests: None):
"""VCR recording is of an invalid JSON response."""
m = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(
api_key='foobar', base_url='https://demo-endpoints.pydantic.workers.dev/bin/content-type/application/json'
),
)
agent = Agent(m)
with pytest.raises(UnexpectedModelBehavior) as exc_info:
await agent.run('What is the capital of France?')
assert exc_info.value.message.startswith(
'Invalid response from openai chat completions endpoint: 4 validation errors for ChatCompletion'
)
async def test_text_response(allow_model_requests: None):
"""VCR recording is of a text response."""
m = OpenAIChatModel(
'gpt-4o', provider=OpenAIProvider(api_key='foobar', base_url='https://demo-endpoints.pydantic.workers.dev/bin/')
)
agent = Agent(m)
with pytest.raises(UnexpectedModelBehavior) as exc_info:
await agent.run('What is the capital of France?')
assert exc_info.value.message == snapshot(
'Invalid response from openai chat completions endpoint, expected JSON data'
)
async def test_empty_response_skipped_in_history(allow_model_requests: None):
"""Empty `ModelResponse(parts=[])` from a previous turn must not be sent back as an assistant
message with `content=None`, which the Chat Completions API rejects with a 400 error.
The agent graph (see `_agent_graph.py`) retries empty responses by emitting a `RetryPromptPart`
that tells the model which kinds of output are valid, while relying on the model adapter to omit
the empty response from the API payload.
"""
responses = [
completion_message(ChatCompletionMessage(content=None, role='assistant')),
completion_message(ChatCompletionMessage(content='hello back', role='assistant')),
]
mock_client = MockOpenAI.create_mock(responses)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('hello')
assert result.output == 'hello back'
# The empty response is omitted from the payload (no `content=None` assistant message that would
# trigger a 400); a retry prompt is appended instead so the model can self-correct.
second_call_messages = get_mock_chat_completion_kwargs(mock_client)[1]['messages']
assert not any(message['role'] == 'assistant' for message in second_call_messages)
assert second_call_messages == [
{'content': 'hello', 'role': 'user'},
{
'role': 'user',
'content': 'Validation feedback:\nPlease return text.\n\nFix the errors and try again.',
},
]
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='hello back')],
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={
'finish_reason': 'stop',
'timestamp': datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
},
provider_response_id='123',
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.parametrize('send_mode', ['field', 'auto'])
async def test_thinking_only_response_skipped_in_history(
allow_model_requests: None, send_mode: Literal['field', 'auto']
):
"""A thinking-only response has no Chat Completions-valid assistant payload.
Custom reasoning fields like `reasoning_content` do not satisfy Chat Completions'
requirement that assistant messages have `content` unless `tool_calls` is populated.
"""
responses = [
completion_message(
ChatCompletionMessage.model_construct(
content=None, reasoning_content='Let me think about this...', role='assistant'
)
),
completion_message(ChatCompletionMessage(content='hello back', role='assistant')),
]
mock_client = MockOpenAI.create_mock(responses)
m = OpenAIChatModel(
'deepseek-reasoner',
provider=OpenAIProvider(openai_client=mock_client),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning_content',
openai_chat_send_back_thinking_parts=send_mode,
),
)
agent = Agent(m)
await agent.run('hello')
second_call_messages = get_mock_chat_completion_kwargs(mock_client)[1]['messages']
assistant_messages = [message for message in second_call_messages if message.get('role') == 'assistant']
assert assistant_messages == []
async def test_process_response_no_created_timestamp(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(content='world', role='assistant'),
)
c.created = None # type: ignore
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('Hello')
messages = result.all_messages()
response_message = message(messages, ModelResponse, index=1)
assert response_message.timestamp == IsNow(tz=timezone.utc)
async def test_process_response_no_finish_reason(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(content='world', role='assistant'),
)
c.choices[0].finish_reason = None # type: ignore
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('Hello')
messages = result.all_messages()
response_message = message(messages, ModelResponse, index=1)
assert response_message.finish_reason == 'stop'
async def test_openai_unified_service_tier(allow_model_requests: None):
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='hello', role='assistant')))
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
await agent.run('Hello', model_settings=ModelSettings(service_tier='flex'))
assert get_mock_chat_completion_kwargs(mock_client)[0]['service_tier'] == 'flex'
async def test_service_tier_non_standard_value(allow_model_requests: None):
"""OpenAI-compatible providers can return service_tier values outside the OpenAI Literal."""
c = completion_message(ChatCompletionMessage(content='hello', role='assistant'))
c.service_tier = 'standard' # type: ignore # simulate provider returning non-OpenAI value
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-5.2', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('Hello')
assert result.output == 'hello'
async def test_tool_choice_fallback(allow_model_requests: None) -> None:
profile = merge_profile(
OpenAIModelProfile(openai_supports_tool_choice_required=False), openai_model_profile('stub')
)
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel('stub', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
params = ModelRequestParameters(function_tools=[ToolDefinition(name='x')], allow_text_output=False)
await model._completions_create( # pyright: ignore[reportPrivateUsage]
messages=[],
stream=False,
model_settings={},
model_request_parameters=params,
)
assert get_mock_chat_completion_kwargs(mock_client)[0]['tool_choice'] == 'auto'
async def test_tool_choice_fallback_response_api(allow_model_requests: None) -> None:
"""Ensure tool_choice falls back to 'auto' for Responses API when 'required' unsupported."""
profile = merge_profile(
OpenAIModelProfile(openai_supports_tool_choice_required=False), openai_model_profile('stub')
)
mock_client = MockOpenAIResponses.create_mock(response_message([]))
model = OpenAIResponsesModel('openai/gpt-oss', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
params = ModelRequestParameters(function_tools=[ToolDefinition(name='x')], allow_text_output=False)
await model._responses_create( # pyright: ignore[reportPrivateUsage]
messages=[],
stream=False,
model_settings={},
model_request_parameters=params,
)
assert get_mock_responses_kwargs(mock_client)[0]['tool_choice'] == 'auto'
async def test_openai_model_settings_temperature_ignored_on_gpt_5(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-5', provider=OpenAIProvider(api_key=openai_api_key))
agent = Agent(m)
with pytest.warns(UserWarning, match='Sampling parameters.*temperature.*not supported when reasoning is enabled'):
result = await agent.run('What is the capital of France?', model_settings=ModelSettings(temperature=0.0))
assert result.output == snapshot('Paris.')
async def test_openai_gpt_5_2_temperature_allowed_by_default(allow_model_requests: None):
"""GPT-5.2 allows temperature by default (reasoning_effort defaults to 'none')."""
c = completion_message(ChatCompletionMessage(content='Paris.', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-5.2', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
# No warning should be raised when using temperature without reasoning enabled
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter('always')
await agent.run('What is the capital of France?', model_settings=ModelSettings(temperature=0.5))
# Check no UserWarning about sampling params was raised
sampling_warnings = [x for x in w if 'Sampling parameters' in str(x.message)]
assert len(sampling_warnings) == 0
# Verify temperature was passed to the API
assert get_mock_chat_completion_kwargs(mock_client)[0]['temperature'] == 0.5
async def test_openai_gpt_5_2_temperature_warns_when_reasoning_enabled(allow_model_requests: None):
"""GPT-5.2 warns and filters temperature when reasoning_effort is set."""
c = completion_message(ChatCompletionMessage(content='Paris.', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-5.2', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.warns(UserWarning, match='Sampling parameters.*temperature.*not supported when reasoning is enabled'):
await agent.run(
'What is the capital of France?',
model_settings=OpenAIChatModelSettings(temperature=0.5, openai_reasoning_effort='medium'),
)
# Verify temperature was NOT passed to the API (filtered out)
assert 'temperature' not in get_mock_chat_completion_kwargs(mock_client)[0]
async def test_openai_model_cerebras_provider(allow_model_requests: None, cerebras_api_key: str):
m = OpenAIChatModel('llama3.3-70b', provider=CerebrasProvider(api_key=cerebras_api_key))
agent = Agent(m)
result = await agent.run('What is the capital of France?')
assert result.output == snapshot('The capital of France is Paris.')
async def test_openai_model_cerebras_provider_qwen_3_coder(allow_model_requests: None, cerebras_api_key: str):
class Location(TypedDict):
city: str
country: str
m = OpenAIChatModel('qwen-3-coder-480b', provider=CerebrasProvider(api_key=cerebras_api_key))
agent = Agent(m, output_type=Location)
result = await agent.run('What is the capital of France?')
assert result.output == snapshot({'city': 'Paris', 'country': 'France'})
async def test_openai_model_cerebras_provider_harmony(allow_model_requests: None, cerebras_api_key: str):
m = OpenAIChatModel('gpt-oss-120b', provider=CerebrasProvider(api_key=cerebras_api_key))
agent = Agent(m)
result = await agent.run('What is the capital of France?')
assert result.output == snapshot('The capital of France is **Paris**.')
async def test_cache_point_filtering(allow_model_requests: None):
"""Test that CachePoint is filtered out in OpenAI Chat Completions requests."""
c = completion_message(ChatCompletionMessage(content='response', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
# Test the instance method directly to trigger line 864
msg = await m._map_user_prompt(UserPromptPart(content=['text before', CachePoint(), 'text after'])) # pyright: ignore[reportPrivateUsage]
# CachePoint should be filtered out, only text content should remain
assert msg['role'] == 'user'
assert len(msg['content']) == 2 # type: ignore[reportUnknownArgumentType]
assert msg['content'][0]['text'] == 'text before' # type: ignore[reportUnknownArgumentType]
assert msg['content'][1]['text'] == 'text after' # type: ignore[reportUnknownArgumentType]
async def test_cache_point_filtering_responses_model():
"""Test that CachePoint is filtered out in OpenAI Responses API requests."""
m = OpenAIResponsesModel('gpt-4.1-nano', provider=OpenAIProvider(api_key='test-key'))
# Test the instance method directly to ensure CachePoint filtering
msg = await m._map_user_prompt( # pyright: ignore[reportPrivateUsage]
UserPromptPart(content=['text before', CachePoint(), 'text after'])
)
# CachePoint should be filtered out, only text content should remain
assert msg['role'] == 'user'
assert len(msg['content']) == 2
assert msg['content'][0]['text'] == 'text before' # type: ignore[reportUnknownArgumentType]
assert msg['content'][1]['text'] == 'text after' # type: ignore[reportUnknownArgumentType]
async def test_openai_custom_reasoning_field_sending_back_in_thinking_tags(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning_content',
openai_chat_send_back_thinking_parts='tags',
),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
{
'role': 'assistant',
'content': """\
<think>
reasoning
</think>
response\
""",
}
)
def test_openai_send_back_thinking_field_requires_thinking_field():
"""`openai_chat_send_back_thinking_parts='field'` requires `openai_chat_thinking_field` to be set."""
with pytest.raises(UserError, match='`openai_chat_thinking_field` must be set to a non-None value'):
OpenAIChatModel(
'foobar',
provider=OpenAIProvider(api_key='dummy'),
profile=OpenAIModelProfile(openai_chat_send_back_thinking_parts='field'),
)
async def test_openai_custom_reasoning_field_sending_back_in_custom_field(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning_content',
openai_chat_send_back_thinking_parts='field',
),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
{'role': 'assistant', 'reasoning_content': 'reasoning', 'content': 'response'}
)
@pytest.mark.parametrize('thinking', [True, False])
@pytest.mark.parametrize(
'thinking_field',
[
pytest.param('reasoning_content', id='deepseek'),
pytest.param('reasoning', id='openrouter'),
pytest.param('reasoning_content', id='moonshotai'),
],
)
async def test_field_mode_thinking_backfill_on_synthetic_tool_search_turn(
allow_model_requests: None, thinking_field: str, thinking: bool
):
"""Regression test for #5829: deterministic per-CI guard for the wire mapping.
Loading a deferred capability injects a framework-synthesized `search_tools` assistant turn
with tool calls but no thinking. A `'field'`-mode provider (one that round-trips thinking in a
custom field) 400s if such a turn omits that field while the run is thinking, so it must be sent
empty when thinking is active and left off otherwise. Parametrized over the three providers that
use this mode — DeepSeek and MoonshotAI (`reasoning_content`) and OpenRouter (`reasoning`) — to
pin that the backfill is field-name-agnostic (it reads `openai_chat_thinking_field`). The
real-API counterpart is `test_deepseek.py::test_deepseek_deferred_capability_with_thinking`.
"""
load_capability_message = ChatCompletionMessage.model_construct(
content=None,
role='assistant',
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id='call_load',
type='function',
function=Function(name='load_capability', arguments=json.dumps({'id': 'DICE_ROLL'})),
)
],
)
roll_dice_message = ChatCompletionMessage.model_construct(
content=None,
role='assistant',
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id='call_roll', type='function', function=Function(name='roll_dice', arguments='{}')
)
],
)
if thinking:
# The provider returns its reasoning in the profile's custom field; the model parses it into a
# `ThinkingPart`, which makes the run thinking-active so the synthetic turn gets backfilled.
setattr(load_capability_message, thinking_field, 'I should load the dice capability.')
setattr(roll_dice_message, thinking_field, 'Now I can roll.')
load_capability_turn = completion_message(load_capability_message)
roll_dice_turn = completion_message(roll_dice_message)
final_turn = completion_message(ChatCompletionMessage.model_construct(content='You win!', role='assistant'))
mock = MockOpenAI.create_mock([load_capability_turn, roll_dice_turn, final_turn])
model = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=mock),
profile=OpenAIModelProfile(
openai_chat_thinking_field=thinking_field,
openai_chat_send_back_thinking_parts='field',
),
)
def roll_dice() -> str:
return '4'
agent = Agent(model, capabilities=[Capability(id='DICE_ROLL', tools=[roll_dice], defer_loading=True)])
await agent.run('My guess is 4')
# The second request is the one made after `load_capability` returned; it carries the
# synthetic `search_tools` assistant turn that the load injected into history.
second_request_messages = get_mock_chat_completion_kwargs(mock)[1]['messages']
synthetic_turn = next(
message
for message in second_request_messages
if message.get('role') == 'assistant'
and any(call['function']['name'] == 'search_tools' for call in message.get('tool_calls', ()))
)
assert synthetic_turn.get(thinking_field) == ('' if thinking else None)
async def test_openai_custom_reasoning_field_not_sending(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning_content',
openai_chat_send_back_thinking_parts=False,
),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
{'role': 'assistant', 'content': 'response'}
)
async def test_openai_reasoning_in_thinking_tags(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage.model_construct(content='<think>reasoning</think>response', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
profile=OpenAIModelProfile(openai_chat_send_back_thinking_parts='tags'),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
{
'role': 'assistant',
'content': """\
<think>
reasoning
</think>
response\
""",
}
)
async def test_openai_auto_mode_reasoning_field_sends_back_in_field(allow_model_requests: None):
"""Test that auto mode detects reasoning from field and sends back in field."""
c = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning_content='reasoning', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning_content',
openai_chat_send_back_thinking_parts='auto', # Auto mode
),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
# Auto mode should detect thinking came from 'reasoning_content' field and send back in same field
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
{'role': 'assistant', 'reasoning_content': 'reasoning', 'content': 'response'}
)
async def test_openai_auto_mode_reasoning_field_different_provider_uses_tags(allow_model_requests: None):
"""Test that auto mode falls back to tags when provider_name doesn't match."""
# This test verifies behavior by checking that when thinking comes from a different provider, auto mode falls back to tags.
c1 = completion_message(ChatCompletionMessage.model_construct(content='response2', role='assistant'))
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c1)),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning_content',
openai_chat_send_back_thinking_parts='auto',
),
)
messages = [
ModelRequest(parts=[UserPromptPart(content='question')]),
ModelResponse(
parts=[
ThinkingPart(
content='reasoning from different provider',
id='reasoning_content',
provider_name='different-provider',
),
]
),
]
settings = ModelSettings()
params = ModelRequestParameters()
await m.request(messages=messages, model_settings=settings, model_request_parameters=params)
mapped = m._map_model_response(messages[1]) # type: ignore[reportPrivateUsage]
assert mapped == snapshot(
{
'role': 'assistant',
'content': """<think>
reasoning from different provider
</think>""",
}
)
async def test_openai_auto_mode_tags_sends_back_in_tags(allow_model_requests: None):
"""Test that auto mode detects reasoning from tags and sends back in tags."""
c = completion_message(
ChatCompletionMessage.model_construct(content='<think>reasoning</think>response', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
profile=OpenAIModelProfile(openai_chat_send_back_thinking_parts='auto'),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
assert m._map_model_response(resp) == snapshot( # type: ignore[reportPrivateUsage]
{
'role': 'assistant',
'content': """\
<think>
reasoning
</think>
response\
""",
}
)
async def test_openai_auto_mode_no_thinking_field_uses_default_fields(allow_model_requests: None):
"""Test that auto mode with no thinking_field set checks default reasoning and reasoning_content fields."""
c1 = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning='thought', role='assistant')
)
m1 = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c1)),
profile=OpenAIModelProfile(
openai_chat_send_back_thinking_parts='auto',
),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp1 = await m1.request(messages=[], model_settings=settings, model_request_parameters=params)
thinking_parts = [p for p in resp1.parts if isinstance(p, ThinkingPart)]
assert len(thinking_parts) == 1
assert thinking_parts[0].id == 'reasoning'
mapped1 = m1._map_model_response(resp1) # type: ignore[reportPrivateUsage]
assert mapped1 == snapshot({'role': 'assistant', 'reasoning': 'thought', 'content': 'response'})
c2 = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning_content='thought', role='assistant')
)
m2 = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c2)),
profile=OpenAIModelProfile(
openai_chat_send_back_thinking_parts='auto',
),
)
resp2 = await m2.request(messages=[], model_settings=settings, model_request_parameters=params)
thinking_parts = [p for p in resp2.parts if isinstance(p, ThinkingPart)]
assert len(thinking_parts) == 1
assert thinking_parts[0].id == 'reasoning_content'
mapped2 = m2._map_model_response(resp2) # type: ignore[reportPrivateUsage]
assert mapped2 == snapshot({'role': 'assistant', 'reasoning_content': 'thought', 'content': 'response'})
async def test_openai_non_string_reasoning_content_warns(allow_model_requests: None):
"""Malformed OpenAI-compatible responses where `reasoning_content` is a dict (e.g. via a buggy gateway)
should not crash; they should emit a warning and be skipped."""
dict_reasoning = {'reasoningContent': {'reasoningText': {'text': '', 'signature': 'CoAI...'}}}
c = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning_content=dict_reasoning, role='assistant')
)
m = OpenAIChatModel('foobar', provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)))
settings = ModelSettings()
params = ModelRequestParameters()
with pytest.warns(UserWarning, match=r"Unexpected non-string value for 'reasoning_content': dict"):
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
assert [p for p in resp.parts if isinstance(p, ThinkingPart)] == []
async def test_openai_non_string_reasoning_content_warns_stream(allow_model_requests: None):
"""Streaming equivalent: dict `reasoning_content` deltas should warn instead of crashing."""
dict_reasoning = {'reasoningContent': {'reasoningText': {'text': '', 'signature': 'CoAI...'}}}
stream = [
chunk([ChoiceDelta.model_construct(role='assistant', reasoning_content=dict_reasoning)]),
chunk([ChoiceDelta(content='response')]),
chunk([ChoiceDelta()], finish_reason='stop'),
]
m = OpenAIChatModel('foobar', provider=OpenAIProvider(openai_client=MockOpenAI.create_mock_stream(stream)))
agent = Agent(m)
with pytest.warns(UserWarning, match=r"Unexpected non-string value for 'reasoning_content': dict"):
async with agent.run_stream('') as result:
await result.get_output()
messages = result.all_messages()
response = next(m for m in messages if isinstance(m, ModelResponse))
assert [p for p in response.parts if isinstance(p, ThinkingPart)] == []
async def test_openai_auto_mode_mismatched_field_uses_tags(allow_model_requests: None):
"""Test that auto mode falls back to tags when configured field doesn't match where reasoning comes from."""
# Configure thinking_field as 'reasoning_content', but reasoning comes in 'reasoning'
c = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning='thought', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c)),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning_content',
openai_chat_send_back_thinking_parts='auto',
),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
thinking_parts = [p for p in resp.parts if isinstance(p, ThinkingPart)]
assert len(thinking_parts) == 1
assert thinking_parts[0].id == 'reasoning'
# But when sending back, since id='reasoning' doesn't match configured 'reasoning_content', it should fall back to tags
mapped = m._map_model_response(resp) # type: ignore[reportPrivateUsage]
assert mapped == snapshot(
{
'role': 'assistant',
'content': """<think>
thought
</think>
response""",
}
)
async def test_openai_auto_mode_multiple_thinking_parts_same_field(allow_model_requests: None):
"""Test that auto mode correctly groups multiple thinking parts from the same field."""
c1 = completion_message(
ChatCompletionMessage.model_construct(content='response', reasoning='first thought', role='assistant')
)
m = OpenAIChatModel(
'foobar',
provider=OpenAIProvider(openai_client=MockOpenAI.create_mock(c1)),
profile=OpenAIModelProfile(
openai_chat_thinking_field='reasoning',
openai_chat_send_back_thinking_parts='auto',
),
)
settings = ModelSettings()
params = ModelRequestParameters()
resp1 = await m.request(messages=[], model_settings=settings, model_request_parameters=params)
thinking_parts = [p for p in resp1.parts if isinstance(p, ThinkingPart)]
assert len(thinking_parts) == 1
assert thinking_parts[0].id == 'reasoning'
assert thinking_parts[0].provider_name == 'openai'
mapped = m._map_model_response(resp1) # type: ignore[reportPrivateUsage]
assert mapped == snapshot({'role': 'assistant', 'reasoning': 'first thought', 'content': 'response'})
def test_azure_prompt_filter_error(allow_model_requests: None) -> None:
body = {
'error': {
'code': 'content_filter',
'message': 'The content was filtered.',
'innererror': {
'code': 'ResponsibleAIPolicyViolation',
'content_filter_result': {
'hate': {'filtered': True, 'severity': 'high'},
'self_harm': {'filtered': False, 'severity': 'safe'},
'sexual': {'filtered': False, 'severity': 'safe'},
'violence': {'filtered': False, 'severity': 'medium'},
'jailbreak': {'filtered': False, 'detected': False},
'profanity': {'filtered': False, 'detected': True},
},
},
}
}
mock_client = MockOpenAI.create_mock(
APIStatusError(
'content filter',
response=httpx.Response(status_code=400, request=httpx.Request('POST', 'https://example.com/v1')),
body=body,
)
)
m = OpenAIChatModel('gpt-5-mini', provider=AzureProvider(openai_client=cast(AsyncAzureOpenAI, mock_client)))
agent = Agent(m)
with pytest.raises(
ContentFilterError, match=r"Content filter triggered. Finish reason: 'content_filter'"
) as exc_info:
agent.run_sync('bad prompt')
assert exc_info.value.body is not None
assert json.loads(exc_info.value.body) == snapshot(
[
{
'parts': [],
'usage': {
'input_tokens': 0,
'cache_write_tokens': 0,
'cache_read_tokens': 0,
'output_tokens': 0,
'input_audio_tokens': 0,
'cache_audio_read_tokens': 0,
'output_audio_tokens': 0,
'details': {},
},
'model_name': 'gpt-5-mini',
'timestamp': IsStr(),
'kind': 'response',
'provider_name': 'azure',
'provider_url': None,
'provider_details': {
'finish_reason': 'content_filter',
'content_filter_result': {
'hate': {'filtered': True, 'severity': 'high'},
'self_harm': {'filtered': False, 'severity': 'safe'},
'sexual': {'filtered': False, 'severity': 'safe'},
'violence': {'filtered': False, 'severity': 'medium'},
'jailbreak': {'filtered': False, 'detected': False},
'profanity': {'filtered': False, 'detected': True},
},
},
'provider_response_id': None,
'finish_reason': 'content_filter',
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
}
]
)
def test_responses_azure_prompt_filter_error(allow_model_requests: None) -> None:
mock_client = MockOpenAIResponses.create_mock(
APIStatusError(
'content filter',
response=httpx.Response(status_code=400, request=httpx.Request('POST', 'https://example.com/v1')),
body={'error': {'code': 'content_filter', 'message': 'The content was filtered.'}},
)
)
m = OpenAIResponsesModel('gpt-5-mini', provider=AzureProvider(openai_client=cast(AsyncAzureOpenAI, mock_client)))
agent = Agent(m)
with pytest.raises(ContentFilterError, match=r"Content filter triggered. Finish reason: 'content_filter'"):
agent.run_sync('bad prompt')
async def test_openai_response_filter_error(allow_model_requests: None):
c = completion_message(
ChatCompletionMessage(content=None, role='assistant'),
)
c.choices[0].finish_reason = 'content_filter'
c.model = 'gpt-5-mini'
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-5-mini', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ContentFilterError, match=r"Content filter triggered. Finish reason: 'content_filter'"):
await agent.run('hello')
async def test_openai_response_filter_with_partial_content(allow_model_requests: None):
"""Test that NO exception is raised if content is returned, even if finish_reason is content_filter."""
c = completion_message(
ChatCompletionMessage(content='Partial', role='assistant'),
)
c.choices[0].finish_reason = 'content_filter'
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-5-mini', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
result = await agent.run('hello')
assert result.output == 'Partial'
def test_azure_400_non_content_filter(allow_model_requests: None) -> None:
"""Test a 400 error from Azure that is NOT a content filter (different code)."""
mock_client = MockOpenAI.create_mock(
APIStatusError(
'Bad Request',
response=httpx.Response(status_code=400, request=httpx.Request('POST', 'https://example.com/v1')),
body={'error': {'code': 'invalid_parameter', 'message': 'Invalid param.'}},
)
)
m = OpenAIChatModel('gpt-5-mini', provider=AzureProvider(openai_client=cast(AsyncAzureOpenAI, mock_client)))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
agent.run_sync('hello')
assert exc_info.value.status_code == 400
def test_azure_400_non_dict_body(allow_model_requests: None) -> None:
"""Test a 400 error from Azure where the body is not a dictionary."""
mock_client = MockOpenAI.create_mock(
APIStatusError(
'Bad Request',
response=httpx.Response(status_code=400, request=httpx.Request('POST', 'https://example.com/v1')),
body='Raw string body',
)
)
m = OpenAIChatModel('gpt-5-mini', provider=AzureProvider(openai_client=cast(AsyncAzureOpenAI, mock_client)))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
agent.run_sync('hello')
assert exc_info.value.status_code == 400
def test_azure_400_malformed_error(allow_model_requests: None) -> None:
"""Test a 400 error from Azure where body matches dict but error structure is wrong."""
mock_client = MockOpenAI.create_mock(
APIStatusError(
'Bad Request',
response=httpx.Response(status_code=400, request=httpx.Request('POST', 'https://example.com/v1')),
body={'something_else': 'foo'}, # No 'error' key
)
)
m = OpenAIChatModel('gpt-5-mini', provider=AzureProvider(openai_client=cast(AsyncAzureOpenAI, mock_client)))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
agent.run_sync('hello')
assert exc_info.value.status_code == 400
async def test_openai_chat_instructions_after_system_prompts(allow_model_requests: None):
"""Test that instructions are inserted after all system prompts in mapped messages."""
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
messages: list[ModelRequest | ModelResponse] = [
ModelRequest(
parts=[
SystemPromptPart(content='System prompt 1'),
SystemPromptPart(content='System prompt 2'),
UserPromptPart(content='Hello'),
],
),
]
model_request_parameters = ModelRequestParameters(
instruction_parts=[InstructionPart(content='Instructions content')],
)
openai_messages = await model._map_messages(messages, model_request_parameters) # pyright: ignore[reportPrivateUsage]
# Verify order: system1, system2, instructions, user
assert len(openai_messages) == 4
assert openai_messages == snapshot(
[
{'role': 'system', 'content': 'System prompt 1'},
{'role': 'system', 'content': 'System prompt 2'},
{'content': 'Instructions content', 'role': 'system'},
{'role': 'user', 'content': 'Hello'},
]
)
async def test_openai_chat_instructions_after_only_system_prompts(allow_model_requests: None):
"""Test that instructions are appended after a history made entirely of system prompts."""
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
messages: list[ModelRequest | ModelResponse] = [
ModelRequest(
parts=[
SystemPromptPart(content='System prompt 1'),
SystemPromptPart(content='System prompt 2'),
],
),
]
model_request_parameters = ModelRequestParameters(
instruction_parts=[InstructionPart(content='Instructions content')],
)
openai_messages = await model._map_messages(messages, model_request_parameters) # pyright: ignore[reportPrivateUsage]
assert openai_messages == snapshot(
[
{'role': 'system', 'content': 'System prompt 1'},
{'role': 'system', 'content': 'System prompt 2'},
{'content': 'Instructions content', 'role': 'system'},
]
)
async def test_openai_chat_instructions_with_no_mapped_messages(allow_model_requests: None):
"""Test that instructions are inserted even when there are no mapped messages yet."""
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
messages: list[ModelRequest | ModelResponse] = [
ModelRequest(parts=[]),
]
model_request_parameters = ModelRequestParameters(
instruction_parts=[InstructionPart(content='Instructions content')],
)
openai_messages = await model._map_messages(messages, model_request_parameters) # pyright: ignore[reportPrivateUsage]
assert openai_messages == snapshot(
[
{'content': 'Instructions content', 'role': 'system'},
]
)
async def test_openai_chat_instructions_do_not_split_tool_call_history(allow_model_requests: None):
"""Test that instructions are inserted before tool-call history when a later system prompt exists."""
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
messages: list[ModelRequest | ModelResponse] = [
ModelResponse(parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call_abc123')]),
ModelRequest(parts=[ToolReturnPart(tool_name='my_tool', content='result', tool_call_id='call_abc123')]),
ModelResponse(parts=[TextPart('Done.')]),
ModelRequest(parts=[UserPromptPart(content='Next question?')]),
ModelResponse(parts=[TextPart('Answer.')]),
ModelRequest(parts=[SystemPromptPart(content='CONVERSATION SUMMARY:\n...')]),
ModelRequest(
parts=[UserPromptPart(content='New user message')],
),
]
model_request_parameters = ModelRequestParameters(
instruction_parts=[InstructionPart(content='You are a helpful assistant.')],
)
openai_messages = await model._map_messages(messages, model_request_parameters) # pyright: ignore[reportPrivateUsage]
assert [message['role'] for message in openai_messages] == [
'system',
'assistant',
'tool',
'assistant',
'user',
'assistant',
'system',
'user',
]
assistant_message = cast(chat.ChatCompletionAssistantMessageParam, openai_messages[1])
tool_message = cast(dict[str, Any], openai_messages[2])
tool_calls = assistant_message.get('tool_calls')
assert tool_calls is not None
first_tool_call = cast(dict[str, Any], next(iter(tool_calls)))
assert openai_messages[0] == {'role': 'system', 'content': 'You are a helpful assistant.'}
assert first_tool_call['id'] == 'call_abc123'
assert tool_message['tool_call_id'] == 'call_abc123'
assert openai_messages[6] == {'role': 'system', 'content': 'CONVERSATION SUMMARY:\n...'}
async def test_openai_chat_instructions_with_developer_role(allow_model_requests: None):
"""Test that instruction parts use the developer role when configured."""
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(openai_client=mock_client),
profile=OpenAIModelProfile(openai_system_prompt_role='developer'),
)
messages: list[ModelRequest | ModelResponse] = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
]
model_request_parameters = ModelRequestParameters(
instruction_parts=[InstructionPart(content='Be helpful')],
)
openai_messages = await model._map_messages(messages, model_request_parameters) # pyright: ignore[reportPrivateUsage]
assert openai_messages == snapshot(
[
{'role': 'developer', 'content': 'Be helpful'},
{'role': 'user', 'content': 'Hello'},
]
)
async def test_openai_chat_instructions_with_user_role(allow_model_requests: None):
"""Test that instruction parts use the user role when configured (e.g. o1-mini)."""
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel(
'o1-mini',
provider=OpenAIProvider(openai_client=mock_client),
profile=OpenAIModelProfile(openai_system_prompt_role='user'),
)
messages: list[ModelRequest | ModelResponse] = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
]
model_request_parameters = ModelRequestParameters(
instruction_parts=[InstructionPart(content='Be helpful')],
)
openai_messages = await model._map_messages(messages, model_request_parameters) # pyright: ignore[reportPrivateUsage]
# When role is 'user', instruction parts are inserted after all leading 'user' messages
assert openai_messages == snapshot(
[
{'role': 'user', 'content': 'Hello'},
{'role': 'user', 'content': 'Be helpful'},
]
)
async def test_openai_chat_instructions_fallback_with_tool_return(allow_model_requests: None):
"""When the last ModelRequest has only tool-return parts, instructions from the previous request are used."""
mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant')))
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
messages: list[ModelRequest | ModelResponse] = [
ModelRequest(parts=[UserPromptPart(content='Hello')], instructions='Be helpful.'),
ModelResponse(parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call_1')]),
ModelRequest(parts=[ToolReturnPart(tool_name='my_tool', content='result', tool_call_id='call_1')]),
]
# No instruction_parts on MRP — model should fall back to message history
openai_messages = await model._map_messages(messages, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage]
# Instructions from the first request should appear as a system message
assert openai_messages[0] == snapshot({'content': 'Be helpful.', 'role': 'system'})
def test_openai_chat_audio_default_base64(allow_model_requests: None):
c = completion_message(ChatCompletionMessage(content='success', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
model = OpenAIChatModel('gpt-4o-audio-preview', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(model)
# BinaryContent
audio_data = b'fake_audio_data'
binary_audio = BinaryContent(audio_data, media_type='audio/wav')
agent.run_sync(['Process this audio', binary_audio])
request_kwargs = get_mock_chat_completion_kwargs(mock_client)
messages = request_kwargs[0]['messages']
user_message = messages[0]
# Find the input_audio part
audio_part = next(part for part in user_message['content'] if part['type'] == 'input_audio')
# Expect raw base64
expected_data = base64.b64encode(audio_data).decode('utf-8')
assert audio_part['input_audio']['data'] == expected_data
assert audio_part['input_audio']['format'] == 'wav'
def test_openai_chat_audio_uri_encoding(allow_model_requests: None):
c = completion_message(ChatCompletionMessage(content='success', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
# Set profile to use URI encoding
profile = OpenAIModelProfile(openai_chat_audio_input_encoding='uri')
model = OpenAIChatModel('gpt-4o-audio-preview', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
agent = Agent(model)
# BinaryContent
audio_data = b'fake_audio_data'
binary_audio = BinaryContent(audio_data, media_type='audio/wav')
agent.run_sync(['Process this audio', binary_audio])
request_kwargs = get_mock_chat_completion_kwargs(mock_client)
messages = request_kwargs[0]['messages']
user_message = messages[0]
# Find the input_audio part
audio_part = next(part for part in user_message['content'] if part['type'] == 'input_audio')
# Expect Data URI
expected_data = f'data:audio/wav;base64,{base64.b64encode(audio_data).decode("utf-8")}'
assert audio_part['input_audio']['data'] == expected_data
assert audio_part['input_audio']['format'] == 'wav'
async def test_openai_chat_audio_url_default_base64(allow_model_requests: None):
c = completion_message(ChatCompletionMessage(content='success', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
model = OpenAIChatModel('gpt-4o-audio-preview', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(model)
audio_url = AudioUrl('https://example.com/audio.mp3')
# Mock download_item to return base64 data
fake_base64_data = base64.b64encode(b'fake_downloaded_audio').decode('utf-8')
with patch('pydantic_ai.models.openai.download_item') as mock_download:
mock_download.return_value = {'data': fake_base64_data, 'data_type': 'mp3'}
await agent.run(['Process this audio url', audio_url])
request_kwargs = get_mock_chat_completion_kwargs(mock_client)
messages = request_kwargs[0]['messages']
user_message = messages[0]
# Find the input_audio part
audio_part = next(part for part in user_message['content'] if part['type'] == 'input_audio')
# Expect raw base64 (which is what download_item returns in this mock)
assert audio_part['input_audio']['data'] == fake_base64_data
assert audio_part['input_audio']['format'] == 'mp3'
async def test_openai_chat_audio_url_uri_encoding(allow_model_requests: None):
c = completion_message(ChatCompletionMessage(content='success', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
# Set profile to use URI encoding
profile = OpenAIModelProfile(openai_chat_audio_input_encoding='uri')
model = OpenAIChatModel('gpt-4o-audio-preview', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
agent = Agent(model)
audio_url = AudioUrl('https://example.com/audio.mp3')
# Mock download_item to return Data URI (since we're calling with data_format='base64_uri')
fake_base64_data = base64.b64encode(b'fake_downloaded_audio').decode('utf-8')
data_uri = f'data:audio/mpeg;base64,{fake_base64_data}'
with patch('pydantic_ai.models.openai.download_item') as mock_download:
mock_download.return_value = {'data': data_uri, 'data_type': 'mp3'}
await agent.run(['Process this audio url', audio_url])
request_kwargs = get_mock_chat_completion_kwargs(mock_client)
messages = request_kwargs[0]['messages']
user_message = messages[0]
# Find the input_audio part
audio_part = next(part for part in user_message['content'] if part['type'] == 'input_audio')
# Expect Data URI with correct MIME type for mp3
assert audio_part['input_audio']['data'] == data_uri
assert audio_part['input_audio']['format'] == 'mp3'
async def test_openai_tool_choice_required_unsupported_raises_error(allow_model_requests: None):
"""OpenAI's `_support_tool_forcing` raises when the model profile disables tool forcing.
Goes via `direct.model_request` so the agent-level baseline validator is bypassed and
we actually exercise the OpenAI-specific error path.
"""
c = completion_message(ChatCompletionMessage(content='result', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
profile = OpenAIModelProfile(openai_supports_tool_choice_required=False)
model = OpenAIChatModel('custom-model', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
tool_def = ToolDefinition(name='get_weather', parameters_json_schema={'type': 'object', 'properties': {}})
mrp = ModelRequestParameters(function_tools=[tool_def], allow_text_output=True)
settings: ModelSettings = {'tool_choice': 'required'}
with pytest.raises(
UserError,
match=re.escape("tool_choice='required' is not supported by model 'custom-model'"),
):
await direct_model_request(
model,
[ModelRequest(parts=[UserPromptPart(content='What is the weather?')])],
model_settings=settings,
model_request_parameters=mrp,
)
async def test_openai_chat_tool_choice_list_unsupported_raises_error(allow_model_requests: None):
"""tuple-resolved forcing (e.g. `tool_choice=['x']` with extra tools) must also be checked against `_support_tool_forcing`.
Regression for https://github.com/pydantic/pydantic-ai/pull/3611#discussion_r3127128012 — the tuple
branch in `_get_tool_choice` previously sent the forced tool choice without consulting the model
profile, which would push an unsupported parameter to the API for models that have
`openai_supports_tool_choice_required=False`. Registers two tools so `resolve_tool_choice` returns
`('required', {chosen})` rather than collapsing to scalar `'required'`.
"""
c = completion_message(ChatCompletionMessage(content='result', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
profile = OpenAIModelProfile(openai_supports_tool_choice_required=False)
model = OpenAIChatModel('custom-model', provider=OpenAIProvider(openai_client=mock_client), profile=profile)
tools = [
ToolDefinition(name='get_weather', parameters_json_schema={'type': 'object', 'properties': {}}),
ToolDefinition(name='get_time', parameters_json_schema={'type': 'object', 'properties': {}}),
]
mrp = ModelRequestParameters(function_tools=tools, allow_text_output=True)
settings: ModelSettings = {'tool_choice': ['get_weather']}
with pytest.raises(
UserError,
match=re.escape("tool_choice=['get_weather'] is not supported by model 'custom-model'"),
):
await direct_model_request(
model,
[ModelRequest(parts=[UserPromptPart(content='What is the weather?')])],
model_settings=settings,
model_request_parameters=mrp,
)
def test_transformer_adds_properties_to_object_schemas():
"""OpenAI drops object schemas without a 'properties' key. The transformer must add it."""
schema = {'type': 'object', 'additionalProperties': {'type': 'string'}}
result = OpenAIJsonSchemaTransformer(schema, strict=None).walk()
assert result['properties'] == {}
def chunk_with_usage(
delta: list[ChoiceDelta],
finish_reason: FinishReason | None = None,
completion_tokens: int = 1,
prompt_tokens: int = 2,
total_tokens: int = 3,
) -> chat.ChatCompletionChunk:
"""Create a chunk with configurable usage stats for testing continuous_usage_stats."""
return chat.ChatCompletionChunk(
id='123',
choices=[
ChunkChoice(index=index, delta=delta, finish_reason=finish_reason) for index, delta in enumerate(delta)
],
created=1704067200, # 2024-01-01
model='gpt-4o-123',
object='chat.completion.chunk',
usage=CompletionUsage(
completion_tokens=completion_tokens, prompt_tokens=prompt_tokens, total_tokens=total_tokens
),
)
async def test_stream_with_continuous_usage_stats(allow_model_requests: None):
"""Test that continuous_usage_stats replaces usage instead of accumulating.
When continuous_usage_stats=True, each chunk contains cumulative usage, not incremental.
The final usage should equal the last chunk's usage, not the sum of all chunks.
We verify that usage is correctly updated at each step via stream_response.
"""
# Simulate cumulative usage: each chunk has higher tokens (cumulative, not incremental)
stream = [
chunk_with_usage(
[ChoiceDelta(content='hello ', role='assistant')],
completion_tokens=5,
prompt_tokens=10,
total_tokens=15,
),
chunk_with_usage([ChoiceDelta(content='world')], completion_tokens=10, prompt_tokens=10, total_tokens=20),
chunk_with_usage([ChoiceDelta(content='!')], completion_tokens=15, prompt_tokens=10, total_tokens=25),
chunk_with_usage([], finish_reason='stop', completion_tokens=15, prompt_tokens=10, total_tokens=25),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
settings = cast(OpenAIChatModelSettings, {'openai_continuous_usage_stats': True})
async with agent.run_stream('', model_settings=settings) as result:
# Verify usage is updated at each step via stream_response
usage_at_each_step: list[RequestUsage] = []
async for response in result.stream_response(debounce_by=None):
usage_at_each_step.append(response.usage)
# Each step should have the cumulative usage from that chunk (not accumulated)
# The stream emits responses for each content chunk plus final
assert usage_at_each_step == snapshot(
[
RequestUsage(input_tokens=10, output_tokens=5),
RequestUsage(input_tokens=10, output_tokens=10),
RequestUsage(input_tokens=10, output_tokens=15),
RequestUsage(input_tokens=10, output_tokens=15),
RequestUsage(input_tokens=10, output_tokens=15),
]
)
# Final usage should be from the last chunk (15 output tokens)
# NOT the sum of all chunks (5+10+15+15 = 45 output tokens)
assert result.usage == snapshot(RunUsage(requests=1, input_tokens=10, output_tokens=15))
async def test_openai_chat_refusal_non_streaming(allow_model_requests: None):
"""Test that a refusal field on ChatCompletionMessage triggers ContentFilterError."""
c = completion_message(
ChatCompletionMessage(content=None, refusal="I'm sorry, I can't help with that.", role='assistant'),
)
c.model = 'gpt-4o'
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(
ContentFilterError,
match=re.escape('Content filter triggered. Refusal: "I\'m sorry, I can\'t help with that."'),
) as exc_info:
await agent.run('harmful prompt')
assert exc_info.value.body is not None
body_json = json.loads(exc_info.value.body)
response_msg = body_json[0]
assert response_msg['parts'] == []
assert response_msg['finish_reason'] == 'content_filter'
assert response_msg['provider_details']['refusal'] == "I'm sorry, I can't help with that."
async def test_openai_chat_refusal_streaming(allow_model_requests: None):
"""Test that refusal deltas in streaming trigger ContentFilterError."""
stream = [
chunk([ChoiceDelta(refusal="I'm sorry, ", role='assistant')]),
chunk([ChoiceDelta(refusal="I can't help with that.")]),
chunk([ChoiceDelta()], finish_reason='stop'),
]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ContentFilterError, match='Content filter triggered') as exc_info:
async with agent.run_stream('harmful prompt'):
pass
assert exc_info.value.body is not None
body_json = json.loads(exc_info.value.body)
response_msg = body_json[0]
assert response_msg['parts'] == []
assert response_msg['finish_reason'] == 'content_filter'
assert response_msg['provider_details']['refusal'] == "I'm sorry, I can't help with that."
async def test_stream_cancel(allow_model_requests: None):
stream = [text_chunk('hello '), text_chunk('world'), chunk([])]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
async with agent.run_stream('') as result:
async for _ in result.stream_text(delta=True, debounce_by=None): # pragma: no branch
break
await result.cancel()
await result.cancel() # double cancel is a no-op
assert result.cancelled
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='hello ')],
usage=RequestUsage(input_tokens=2, output_tokens=1),
model_name='gpt-4o-123',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1',
provider_details={'timestamp': IsDatetime()},
provider_response_id='123',
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)