From 3ab80c6ff23ae138c269a1b5ac64c6369b88e8a8 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 8 Aug 2025 02:26:02 +0300 Subject: [PATCH] Bump google-genai to 1.29.0 (#150225) --- .../__init__.py | 6 ++- .../config_flow.py | 2 +- .../entity.py | 44 +++++++++++-------- .../manifest.json | 2 +- .../google_generative_ai_conversation/tts.py | 32 ++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../__init__.py | 33 ++------------ .../snapshots/test_init.ambr | 20 +++++++-- .../test_conversation.py | 3 ++ .../test_tts.py | 2 +- 11 files changed, 87 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 3c1c9cad0b0..a1fd5ea0f9b 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -124,7 +124,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}" ) - if not response.candidates[0].content.parts: + if ( + not response.candidates + or not response.candidates[0].content + or not response.candidates[0].content.parts + ): raise HomeAssistantError("Unknown error generating content") return {"text": response.text} diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index e760187bc66..9048304a006 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -377,7 +377,7 @@ async def google_generative_ai_config_option_schema( value=api_model.name, ) for api_model in sorted( - api_models, key=lambda x: x.name.lstrip("models/") or "" + api_models, key=lambda x: (x.name or "").lstrip("models/") ) if ( api_model.name diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 8e967d84517..90c144530e0 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import codecs -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Callable from dataclasses import replace import mimetypes from pathlib import Path @@ -15,6 +15,7 @@ from google.genai.errors import APIError, ClientError from google.genai.types import ( AutomaticFunctionCallingConfig, Content, + ContentDict, File, FileState, FunctionDeclaration, @@ -23,9 +24,11 @@ from google.genai.types import ( GoogleSearch, HarmCategory, Part, + PartUnionDict, SafetySetting, Schema, Tool, + ToolListUnion, ) import voluptuous as vol from voluptuous_openapi import convert @@ -237,7 +240,7 @@ def _convert_content( async def _transform_stream( - result: AsyncGenerator[GenerateContentResponse], + result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True try: @@ -342,7 +345,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): """Generate an answer for the chat log.""" options = self.subentry.data - tools: list[Tool | Callable[..., Any]] | None = None + tools: ToolListUnion | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -373,7 +376,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): else: raise HomeAssistantError("Invalid prompt content") - messages: list[Content] = [] + messages: list[Content | ContentDict] = [] # Google groups tool results, we do not. Group them before sending. tool_results: list[conversation.ToolResultContent] = [] @@ -400,7 +403,10 @@ class GoogleGenerativeAILLMBaseEntity(Entity): # The SDK requires the first message to be a user message # This is not the case if user used `start_conversation` # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 - if messages and messages[0].role != "user": + if messages and ( + (isinstance(messages[0], Content) and messages[0].role != "user") + or (isinstance(messages[0], dict) and messages[0]["role"] != "user") + ): messages.insert( 0, Content(role="user", parts=[Part.from_text(text=" ")]), @@ -440,14 +446,14 @@ class GoogleGenerativeAILLMBaseEntity(Entity): ) user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) - chat_request: str | list[Part] = user_message.content + chat_request: list[PartUnionDict] = [user_message.content] if user_message.attachments: files = await async_prepare_files_for_prompt( self.hass, self._genai_client, [a.path for a in user_message.attachments], ) - chat_request = [chat_request, *files] + chat_request = [*chat_request, *files] # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): @@ -464,15 +470,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity): error = ERROR_GETTING_RESPONSE raise HomeAssistantError(error) from err - chat_request = _create_google_tool_response_parts( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_response_generator), - ) - if isinstance(content, conversation.ToolResultContent) - ] + chat_request = list( + _create_google_tool_response_parts( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_response_generator), + ) + if isinstance(content, conversation.ToolResultContent) + ] + ) ) if not chat_log.unresponded_tool_results: @@ -559,13 +567,13 @@ async def async_prepare_files_for_prompt( await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) uploaded_file = await client.aio.files.get( - name=uploaded_file.name, + name=uploaded_file.name or "", config={"http_options": {"timeout": TIMEOUT_MILLIS}}, ) if uploaded_file.state == FileState.FAILED: raise HomeAssistantError( - f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}" + f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message if uploaded_file.error else 'unknown'}" ) prompt_parts = await hass.async_add_executor_job(upload_files) diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 25e44964a6d..ce089440b97 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-genai==1.7.0"] + "requirements": ["google-genai==1.29.0"] } diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 08e83242fcd..ed956bdb13c 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -146,15 +146,41 @@ class GoogleGenerativeAITextToSpeechEntity( ) ) ) + + def _extract_audio_parts( + response: types.GenerateContentResponse, + ) -> tuple[bytes, str]: + if ( + not response.candidates + or not response.candidates[0].content + or not response.candidates[0].content.parts + or not response.candidates[0].content.parts[0].inline_data + ): + raise ValueError("No content returned from TTS generation") + + data = response.candidates[0].content.parts[0].inline_data.data + mime_type = response.candidates[0].content.parts[0].inline_data.mime_type + + if not isinstance(data, bytes): + raise TypeError( + f"Expected bytes for audio data, got {type(data).__name__}" + ) + if not isinstance(mime_type, str): + raise TypeError( + f"Expected str for mime_type, got {type(mime_type).__name__}" + ) + + return data, mime_type + try: response = await self._genai_client.aio.models.generate_content( model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL), contents=message, config=config, ) - data = response.candidates[0].content.parts[0].inline_data.data - mime_type = response.candidates[0].content.parts[0].inline_data.mime_type - except (APIError, ClientError, ValueError) as exc: + + data, mime_type = _extract_audio_parts(response) + except (APIError, ClientError, ValueError, TypeError) as exc: LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc return "wav", convert_to_wav(data, mime_type) diff --git a/requirements_all.txt b/requirements_all.txt index fab1f283f66..1e27ad4ded9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1057,7 +1057,7 @@ google-cloud-speech==2.31.1 google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.7.0 +google-genai==1.29.0 # homeassistant.components.google_travel_time google-maps-routing==0.6.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e80789dbb1..caf90997fa5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -924,7 +924,7 @@ google-cloud-speech==2.31.1 google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.7.0 +google-genai==1.29.0 # homeassistant.components.google_travel_time google-maps-routing==0.6.15 diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 18b3c8e07f0..57119ce0ff1 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -1,43 +1,16 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import Mock - from google.genai.errors import APIError, ClientError -import httpx API_ERROR_500 = APIError( 500, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "Internal Server Error", - "status": "internal-error", - } - ), - ), + {"message": "Internal Server Error", "status": "internal-error"}, ) CLIENT_ERROR_BAD_REQUEST = ClientError( 400, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "Bad Request", - "status": "invalid-argument", - } - ), - ), + {"message": "Bad Request", "status": "invalid-argument"}, ) CLIENT_ERROR_API_KEY_INVALID = ClientError( 400, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "'reason': API_KEY_INVALID", - "status": "unauthorized", - } - ), - ), + {"message": "'reason': API_KEY_INVALID", "status": "unauthorized"}, ) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index d0b92a7e88d..c2568159c79 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -128,8 +128,14 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), - File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File( + name='doorbell_snapshot.jpg', + state= + ), + File( + name='context.txt', + state= + ), ]), 'model': 'models/gemini-2.5-flash', }), @@ -145,8 +151,14 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), - File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File( + name='doorbell_snapshot.jpg', + state= + ), + File( + name='context.txt', + state= + ), ]), 'model': 'models/gemini-2.5-flash', }), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 90f496b4b5b..ab8c10e933b 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -195,10 +195,13 @@ async def test_function_call( "response": { "result": "Test response", }, + "scheduling": None, + "will_continue": None, }, "inline_data": None, "text": None, "thought": None, + "thought_signature": None, "video_metadata": None, } diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 108ac82947c..87fc4fe8a76 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -37,7 +37,7 @@ from tests.common import MockConfigEntry, async_mock_service from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator -API_ERROR_500 = APIError("test", response=MagicMock()) +API_ERROR_500 = APIError("test", response_json={}) TEST_CHAT_MODEL = "models/some-tts-model"