Repository: googleapis/python-genai

Issue:
Getting `AttributeError: 'dict' object has no attribute 'role'` when trying to use chats interface
When trying to use chat interface with populated chat history, I get an error saying `AttributeError: 'dict' object has no attribute 'role'` which the error suddenly appears, and this hasn't been an issue before, this should normally work but it just suddenly appears now.

#### Environment details

  - Programming language: Python 3.12
  - OS: Ubuntu 22.04
  - Language runtime version: Python 3.12
  - Package version: `google-genai==1.5.0`

#### Steps to reproduce

Code to reproduce:
```python
import google.genai
import os

client = google.genai.Client(api_key=os.environ['API_KEY'])
sample_chat_history = [{'parts': [{'text': 'Hi'}], 'role': 'user'}, {'parts': [{'text': "Heyo! 👋 I'm Jakey, the Discord Bot! What's up? 😎 Let me know if you need anything, I'm here to help or just chill! 😉"}], 'role': 'model'}]

chat_session = client.chats.create(
    model='gemini-2.0-flash-001',
    history=sample_chat_history
)

x = chat_session.send_message('Hello!')
print(x)
```

However I got an error saying

![Image](https://github.com/user-attachments/assets/7d57e6af-cbc9-41db-a176-a054bb1589df)

Discussion:

Patch:
diff --git a/google/genai/chats.py b/google/genai/chats.py
index a0f2b3c..5e6ce5a 100644
--- a/google/genai/chats.py
+++ b/google/genai/chats.py
@@ -19,7 +19,7 @@ from typing import AsyncIterator, Awaitable, Optional, Union, get_args
 from . import _transformers as t
 from . import types
 from .models import AsyncModels, Models
-from .types import Content, GenerateContentConfigOrDict, GenerateContentResponse, Part, PartUnionDict
+from .types import Content, ContentOrDict, GenerateContentConfigOrDict, GenerateContentResponse, Part, PartUnionDict
 
 
 if sys.version_info >= (3, 10):
@@ -116,14 +116,21 @@ class _BaseChat:
       *,
       model: str,
       config: Optional[GenerateContentConfigOrDict] = None,
-      history: list[Content],
+      history: list[ContentOrDict],
   ):
     self._model = model
     self._config = config
-    self._comprehensive_history = history
+    content_models = []
+    for content in history:
+      if not isinstance(content, Content):
+        content_model = Content.model_validate(content)
+      else:
+        content_model = content
+      content_models.append(content_model)
+    self._comprehensive_history = content_models
     """Comprehensive history is the full history of the chat, including turns of the invalid contents from the model and their associated inputs.
     """
-    self._curated_history = _extract_curated_history(history)
+    self._curated_history = _extract_curated_history(content_models)
     """Curated history is the set of valid turns that will be used in the subsequent send requests.
     """
 
@@ -210,7 +217,7 @@ class Chat(_BaseChat):
       modules: Models,
       model: str,
       config: Optional[GenerateContentConfigOrDict] = None,
-      history: list[Content],
+      history: list[ContentOrDict],
   ):
     self._modules = modules
     super().__init__(
@@ -344,7 +351,7 @@ class Chats:
       *,
       model: str,
       config: Optional[GenerateContentConfigOrDict] = None,
-      history: Optional[list[Content]] = None,
+      history: Optional[list[ContentOrDict]] = None,
   ) -> Chat:
     """Creates a new chat session.
 
@@ -373,7 +380,7 @@ class AsyncChat(_BaseChat):
       modules: AsyncModels,
       model: str,
       config: Optional[GenerateContentConfigOrDict] = None,
-      history: list[Content],
+      history: list[ContentOrDict],
   ):
     self._modules = modules
     super().__init__(
@@ -501,7 +508,7 @@ class AsyncChats:
       *,
       model: str,
       config: Optional[GenerateContentConfigOrDict] = None,
-      history: Optional[list[Content]] = None,
+      history: Optional[list[ContentOrDict]] = None,
   ) -> AsyncChat:
     """Creates a new chat session.
 


Test patch:
diff --git a/google/genai/tests/chats/test_get_history.py b/google/genai/tests/chats/test_get_history.py
index 40df837..8606b8d 100644
--- a/google/genai/tests/chats/test_get_history.py
+++ b/google/genai/tests/chats/test_get_history.py
@@ -15,13 +15,14 @@
 
 
 from unittest import mock
-from ... import types
-from ... import client
-from ... import models
-from ... import chats
 
 import pytest
 
+from ... import chats
+from ... import client
+from ... import models
+from ... import types
+
 AFC_HISTORY = [
     types.Content(
         role='user',
@@ -40,7 +41,7 @@ AFC_HISTORY = [
 ]
 
 
-pytest_plugins = ('pytest_asyncio')
+pytest_plugins = 'pytest_asyncio'
 
 
 @pytest.fixture
@@ -56,95 +57,111 @@ def mock_api_client(vertexai=False):
 @pytest.fixture
 def mock_generate_content_invalid_content():
   with mock.patch.object(
-    models.Models, 'generate_content'
-    ) as mock_generate_content:
-      mock_generate_content.return_value = types.GenerateContentResponse(
-          candidates=[types.Candidate(content=types.Content(
-              role='model',
-              parts=[types.Part(text='')],
-          ))]
-      )
-      yield mock_generate_content
+      models.Models, 'generate_content'
+  ) as mock_generate_content:
+    mock_generate_content.return_value = types.GenerateContentResponse(
+        candidates=[
+            types.Candidate(
+                content=types.Content(
+                    role='model',
+                    parts=[types.Part(text='')],
+                )
+            )
+        ]
+    )
+    yield mock_generate_content
 
 
 @pytest.fixture
 def mock_generate_content_empty_content():
   with mock.patch.object(
-    models.Models, 'generate_content'
-    ) as mock_generate_content:
-      mock_generate_content.return_value = types.GenerateContentResponse(
-          candidates=[]
-      )
-      yield mock_generate_content
+      models.Models, 'generate_content'
+  ) as mock_generate_content:
+    mock_generate_content.return_value = types.GenerateContentResponse(
+        candidates=[]
+    )
+    yield mock_generate_content
 
 
 @pytest.fixture
 def mock_generate_content_stream_invalid_content():
   with mock.patch.object(
       models.Models, 'generate_content_stream'
-    ) as mock_generate_content:
-      mock_generate_content.return_value = [types.GenerateContentResponse(
-          candidates=[types.Candidate(content=types.Content(
-              role='model',
-              parts=[types.Part(text='')],
-          ))]
-      )]
-      yield mock_generate_content
+  ) as mock_generate_content:
+    mock_generate_content.return_value = [
+        types.GenerateContentResponse(
+            candidates=[
+                types.Candidate(
+                    content=types.Content(
+                        role='model',
+                        parts=[types.Part(text='')],
+                    )
+                )
+            ]
+        )
+    ]
+    yield mock_generate_content
 
 
 @pytest.fixture
 def mock_generate_content_stream_empty_content():
   with mock.patch.object(
       models.Models, 'generate_content_stream'
-    ) as mock_generate_content:
-      mock_generate_content.return_value = [types.GenerateContentResponse(
-          candidates=[]
-      )]
-      yield mock_generate_content
+  ) as mock_generate_content:
+    mock_generate_content.return_value = [
+        types.GenerateContentResponse(candidates=[])
+    ]
+    yield mock_generate_content
 
 
 @pytest.fixture
 def mock_generate_content_afc_history():
   with mock.patch.object(
-    models.Models, 'generate_content'
-    ) as mock_generate_content:
-      mock_generate_content.return_value = types.GenerateContentResponse(
-          candidates=[types.Candidate(content=types.Content(
-              role='model',
-              parts=[types.Part.from_text(text='afc output')],
-          ))],
-          automatic_function_calling_history=AFC_HISTORY,
-      )
-      yield mock_generate_content
+      models.Models, 'generate_content'
+  ) as mock_generate_content:
+    mock_generate_content.return_value = types.GenerateContentResponse(
+        candidates=[
+            types.Candidate(
+                content=types.Content(
+                    role='model',
+                    parts=[types.Part.from_text(text='afc output')],
+                )
+            )
+        ],
+        automatic_function_calling_history=AFC_HISTORY,
+    )
+    yield mock_generate_content
 
 
 @pytest.fixture
 def mock_generate_content_stream_afc_history():
   with mock.patch.object(
-    models.Models, 'generate_content_stream'
-    ) as mock_generate_content:
-      mock_generate_content.return_value = [types.GenerateContentResponse(
-          candidates=[types.Candidate(content=types.Content(
-              role='model',
-              parts=[types.Part.from_text(text='afc output')],
-          ),
-          finish_reason=types.FinishReason.STOP
-          )],
-          automatic_function_calling_history=AFC_HISTORY,
-      )]
-      yield mock_generate_content
+      models.Models, 'generate_content_stream'
+  ) as mock_generate_content:
+    mock_generate_content.return_value = [
+        types.GenerateContentResponse(
+            candidates=[
+                types.Candidate(
+                    content=types.Content(
+                        role='model',
+                        parts=[types.Part.from_text(text='afc output')],
+                    ),
+                    finish_reason=types.FinishReason.STOP,
+                )
+            ],
+            automatic_function_calling_history=AFC_HISTORY,
+        )
+    ]
+    yield mock_generate_content
 
 
 def test_history_start_with_model_content():
   history = [
       types.Content(
           role='model',
-          parts=[types.Part.from_text(text='Hello there! how can I help you?')]
-      ),
-      types.Content(
-          role='user',
-          parts=[types.Part.from_text(text='Hello')]
+          parts=[types.Part.from_text(text='Hello there! how can I help you?')],
       ),
+      types.Content(role='user', parts=[types.Part.from_text(text='Hello')]),
   ]
 
   models_module = models.Models(mock_api_client)
@@ -157,13 +174,10 @@ def test_history_start_with_model_content():
 
 def test_unrecognized_role_in_history():
   history = [
-      types.Content(
-          role='user',
-          parts=[types.Part.from_text(text='Hello')]
-      ),
+      types.Content(role='user', parts=[types.Part.from_text(text='Hello')]),
       types.Content(
           role='invalid_role',
-          parts=[types.Part.from_text(text='Hello there! how can I help you?')]
+          parts=[types.Part.from_text(text='Hello there! how can I help you?')],
       ),
   ]
 
@@ -177,7 +191,9 @@ def test_unrecognized_role_in_history():
 
 def test_sync_chat_create():
   history = [
-      types.Content(role='user', parts=[types.Part.from_text(text='user input turn 1')]),
+      types.Content(
+          role='user', parts=[types.Part.from_text(text='user input turn 1')]
+      ),
       types.Content(
           role='model',
           parts=[types.Part.from_text(text='model output turn 1')],
@@ -202,7 +218,9 @@ def test_sync_chat_create():
 
 def test_async_chat_create():
   history = [
-      types.Content(role='user', parts=[types.Part.from_text(text='user input turn 1')]),
+      types.Content(
+          role='user', parts=[types.Part.from_text(text='user input turn 1')]
+      ),
       types.Content(
           role='model',
           parts=[types.Part.from_text(text='model output turn 1')],
@@ -229,6 +247,68 @@ def test_async_chat_create():
   assert chat.get_history(curated=True) == history
 
 
+def test_sync_chat_create_with_history_dict():
+  history = [
+      {'role': 'user', 'parts': [{'text': 'user input turn 1'}]},
+      {'role': 'model', 'parts': [{'text': 'model output turn 1'}]},
+      {'role': 'user', 'parts': [{'text': 'user input turn 2'}]},
+      {'role': 'model', 'parts': [{'text': 'model output turn 2'}]},
+  ]
+  models_module = models.Models(mock_api_client)
+  chats_module = chats.Chats(modules=models_module)
+  chat = chats_module.create(model='gemini-1.5-flash', history=history)
+
+  expected_history = [
+      types.Content(
+          role='user', parts=[types.Part.from_text(text='user input turn 1')]
+      ),
+      types.Content(
+          role='model',
+          parts=[types.Part.from_text(text='model output turn 1')],
+      ),
+      types.Content(
+          role='user', parts=[types.Part.from_text(text='user input turn 2')]
+      ),
+      types.Content(
+          role='model',
+          parts=[types.Part.from_text(text='model output turn 2')],
+      ),
+  ]
+  assert chat.get_history() == expected_history
+  assert chat.get_history(curated=True) == expected_history
+
+
+def test_async_chat_create_with_history_dict():
+  history = [
+      {'role': 'user', 'parts': [{'text': 'user input turn 1'}]},
+      {'role': 'model', 'parts': [{'text': 'model output turn 1'}]},
+      {'role': 'user', 'parts': [{'text': 'user input turn 2'}]},
+      {'role': 'model', 'parts': [{'text': 'model output turn 2'}]},
+  ]
+  models_module = models.AsyncModels(mock_api_client)
+  chats_module = chats.AsyncChats(modules=models_module)
+  chat = chats_module.create(model='gemini-1.5-flash', history=history)
+
+  expected_history = [
+      types.Content(
+          role='user', parts=[types.Part.from_text(text='user input turn 1')]
+      ),
+      types.Content(
+          role='model',
+          parts=[types.Part.from_text(text='model output turn 1')],
+      ),
+      types.Content(
+          role='user', parts=[types.Part.from_text(text='user input turn 2')]
+      ),
+      types.Content(
+          role='model',
+          parts=[types.Part.from_text(text='model output turn 2')],
+      ),
+  ]
+  assert chat.get_history() == expected_history
+  assert chat.get_history(curated=True) == expected_history
+
+
 def test_history_with_invalid_turns():
   valid_input = types.Content(
       role='user', parts=[types.Part.from_text(text='Hello')]
@@ -264,7 +344,9 @@ def test_history_with_invalid_turns():
 
   models_module = models.Models(mock_api_client)
   chats_module = chats.Chats(modules=models_module)
-  chat = chats_module.create(model='gemini-1.5-flash', history=comprehensive_history)
+  chat = chats_module.create(
+      model='gemini-1.5-flash', history=comprehensive_history
+  )
 
   assert chat.get_history() == comprehensive_history
   assert chat.get_history(curated=True) == curated_history
@@ -306,7 +388,9 @@ def test_chat_with_empty_content(mock_generate_content_empty_content):
   assert not chat.get_history(curated=True)
 
 
-def test_chat_stream_with_invalid_content(mock_generate_content_stream_invalid_content):
+def test_chat_stream_with_invalid_content(
+    mock_generate_content_stream_invalid_content,
+):
   models_module = models.Models(mock_api_client)
   chats_module = chats.Chats(modules=models_module)
   chat = chats_module.create(model='gemini-1.5-flash')
@@ -326,12 +410,14 @@ def test_chat_stream_with_invalid_content(mock_generate_content_stream_invalid_c
   assert not chat.get_history(curated=True)
 
 
-def test_chat_stream_with_empty_content(mock_generate_content_stream_empty_content):
+def test_chat_stream_with_empty_content(
+    mock_generate_content_stream_empty_content,
+):
   models_module = models.Models(mock_api_client)
   chats_module = chats.Chats(modules=models_module)
   chat = chats_module.create(model='gemini-1.5-flash')
 
-  chunks =chat.send_message_stream('Hello')
+  chunks = chat.send_message_stream('Hello')
   for chunk in chunks:
     pass