diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 3a959fbef6..92207ca12c 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -763,7 +763,10 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: content_items = reasoning_item.get("content", []) encrypted_content = reasoning_item.get("encrypted_content") - item_provider_data: dict[str, Any] = reasoning_item.get("provider_data", {}) # type: ignore[assignment] + raw_provider_data = reasoning_item.get("provider_data") + item_provider_data: dict[str, Any] = ( + raw_provider_data if isinstance(raw_provider_data, dict) else {} + ) item_model = item_provider_data.get("model", "") should_replay = False diff --git a/tests/models/test_chatcmpl_reasoning_provider_data_none.py b/tests/models/test_chatcmpl_reasoning_provider_data_none.py new file mode 100644 index 0000000000..8782daafd0 --- /dev/null +++ b/tests/models/test_chatcmpl_reasoning_provider_data_none.py @@ -0,0 +1,97 @@ +"""Regression test for chatcmpl_converter handling of reasoning items +with ``provider_data`` explicitly set to ``None``. + +JSON roundtripping (and some external producers) can store a reasoning item +where ``provider_data`` is the literal ``None`` rather than missing or a dict. +``Converter.items_to_messages`` previously assumed the field was always either +absent or a dict and called ``.get("model", "")`` directly on it, which raised +``AttributeError: 'NoneType' object has no attribute 'get'`` for these +otherwise valid items. +""" + +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest + +from agents.items import TResponseInputItem +from agents.models.chatcmpl_converter import Converter + + +def _reasoning_item_dict_with_provider_data_none() -> dict[str, Any]: + return { + "type": "reasoning", + "id": "__fake_id__", + "summary": [{"type": "summary_text", "text": "thinking"}], + "content": [{"type": "reasoning_text", "text": "step"}], + "encrypted_content": None, + "status": None, + "provider_data": None, + } + + +def test_items_to_messages_handles_provider_data_none() -> None: + """Converter must not crash when a reasoning item has provider_data=None.""" + items = [cast(TResponseInputItem, _reasoning_item_dict_with_provider_data_none())] + + # The bug was a hard AttributeError raised at conversion time. The exact + # contents of the resulting messages are not the focus here — we only need + # to assert that conversion completes for both Claude and non-Claude + # targets and returns a list. + messages_claude = Converter.items_to_messages( + items, + model="claude-sonnet-4", + preserve_thinking_blocks=True, + ) + assert isinstance(messages_claude, list) + + messages_gpt = Converter.items_to_messages( + items, + model="gpt-4o", + preserve_thinking_blocks=False, + ) + assert isinstance(messages_gpt, list) + + +def test_items_to_messages_handles_provider_data_none_after_json_roundtrip() -> None: + """JSON serialization preserves a None value, exercising the same path.""" + item_dict = _reasoning_item_dict_with_provider_data_none() + roundtripped: dict[str, Any] = json.loads(json.dumps(item_dict)) + + # Sanity check: the None survives the roundtrip. + assert roundtripped["provider_data"] is None + + messages = Converter.items_to_messages( + [cast(TResponseInputItem, roundtripped)], + model="claude-sonnet-4", + preserve_thinking_blocks=True, + ) + assert isinstance(messages, list) + + +@pytest.mark.parametrize( + "bogus_provider_data", + [123, "not-a-dict", ["model", "x"]], +) +def test_items_to_messages_handles_non_dict_provider_data( + bogus_provider_data: object, +) -> None: + """Non-dict provider_data values are treated as missing rather than crashing.""" + item_dict: dict[str, Any] = { + "type": "reasoning", + "id": "__fake_id__", + "summary": [{"type": "summary_text", "text": "thinking"}], + "content": [{"type": "reasoning_text", "text": "step"}], + "encrypted_content": None, + "status": None, + "provider_data": bogus_provider_data, + } + + messages = Converter.items_to_messages( + [cast(TResponseInputItem, item_dict)], + model="claude-sonnet-4", + preserve_thinking_blocks=True, + ) + assert isinstance(messages, list)