refactor(google-gemini): rewrite integration using google-genai 1.16.0

Completely rewrote the Google Gemini integration logic based on google-genai 1.16.0 to fix issue #6.
Key changes:
- Updated to the latest google-genai library
- Made API endpoint abstract while retaining option for custom endpoint configuration
- Refactored logic and classes exclusively within Google Gemini implementation
- All changes are limited to Google Gemini integration refactoring with no impact on other functionality.
This commit is contained in:
SMKRV
2025-05-21 01:26:42 +03:00
parent 8cd876195a
commit 7958bd010b
6 changed files with 311 additions and 103 deletions
+11 -3
View File
@@ -239,10 +239,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_check_api(session, endpoint: str, headers: dict, provider: str) -> bool:
"""Check API availability for different providers."""
try:
if provider == API_PROVIDER_ANTHROPIC:
if provider == API_PROVIDER_GEMINI:
# Gemini API does not support GET /models for validation, just check key presence
if headers.get("Authorization", "").replace("Bearer ", ""):
return True
else:
_LOGGER.error("Gemini API key is missing or empty")
return False
elif provider == API_PROVIDER_ANTHROPIC:
check_url = f"{endpoint}/v1/models"
elif provider == API_PROVIDER_DEEPSEEK:
check_url = f"{endpoint}/models" # DeepSeek
check_url = f"{endpoint}/models"
else: # OpenAI
check_url = f"{endpoint}/models"
@@ -251,7 +258,8 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str)
if response.status in [200, 404]:
return True
elif response.status == 401:
raise ConfigEntryNotReady("Invalid API key")
_LOGGER.error("Invalid API key")
return False
elif response.status == 429:
_LOGGER.warning("Rate limit exceeded during API check")
return False
+116 -69
View File
@@ -11,6 +11,7 @@ import asyncio
from typing import Any, Dict, List, Optional
from aiohttp import ClientSession, ClientTimeout
from async_timeout import timeout
from datetime import datetime, timedelta
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -250,89 +251,135 @@ class APIClient:
temperature: float,
max_tokens: int,
) -> Dict[str, Any]:
"""Create completion using Gemini API."""
# Extract API key from headers (Bearer token)
api_key = self.headers.get("Authorization", "").replace("Bearer ", "")
url = f"{self.endpoint}/models/{model}:generateContent?key={api_key}"
"""Create completion using Gemini API with google-genai library.
# Convert messages to Gemini format
contents = []
system_instruction = ""
# Process messages
for msg in messages:
if msg['role'] == 'system':
system_instruction += msg['content'] + "\n"
else:
# Convert role: 'user' stays 'user', anything else becomes 'model'
role = "user" if msg['role'] == 'user' else "model"
contents.append({
"role": role,
"parts": [{"text": msg['content']}]
})
# Ensure contents starts with a user message if not empty
if contents and contents[0]["role"] != "user":
# Add a placeholder user message
contents.insert(0, {
"role": "user",
"parts": [{"text": "I need your assistance."}]
})
# Ensure contents is not empty
if not contents:
contents.append({
"role": "user",
"parts": [{"text": "I need your assistance."}]
})
# Create payload with snake_case keys as required by Gemini API
payload = {
"contents": contents,
"generation_config": { # Changed from camelCase to snake_case
"temperature": temperature,
"max_output_tokens": max_tokens # Changed from camelCase to snake_case
}
}
if system_instruction:
payload["system_instruction"] = { # Changed from camelCase to snake_case
"parts": [{"text": system_instruction.strip()}]
}
Args:
model: The model name to use
messages: List of message dictionaries with role and content
temperature: Sampling temperature between 0.0 and 2.0
max_tokens: Maximum number of tokens to generate
Returns:
Dictionary with response content and token usage
"""
try:
data = await self._make_request(url, payload)
# Импортируем библиотеку в отдельном потоке, чтобы избежать блокировки event loop
def import_genai():
from google import genai
return genai
# Safely extract response data
candidates = data.get("candidates", [])
if not candidates:
raise HomeAssistantError("Gemini API returned no candidates")
genai = await asyncio.to_thread(import_genai)
content = candidates[0].get("content", {})
parts = content.get("parts", [])
if not parts:
raise HomeAssistantError("Gemini API response contains no content parts")
# Extract API key from headers (Bearer token)
api_key = self.headers.get("Authorization", "").replace("Bearer ", "")
answer_text = parts[0].get("text", "")
# Создаем клиент в отдельном потоке
def create_client():
if self.endpoint and self.endpoint != "https://generativelanguage.googleapis.com/v1beta":
return genai.Client(api_key=api_key, transport="rest",
client_options={"api_endpoint": self.endpoint})
else:
return genai.Client(api_key=api_key)
# Safely extract usage data
usage = data.get("usageMetadata", {})
prompt_tokens = usage.get("promptTokenCount", 0)
completion_tokens = usage.get("candidatesTokenCount", 0)
total_tokens = usage.get("totalTokenCount", prompt_tokens + completion_tokens)
client = await asyncio.to_thread(create_client)
# Process messages to extract system instruction and chat history
system_instruction = ""
contents = []
for msg in messages:
if msg['role'] == 'system':
system_instruction += msg['content'] + "\n"
else:
# For chat history, we need to convert to the format Gemini expects
role = "user" if msg['role'] == 'user' else "model"
contents.append({
"role": role,
"parts": [{"text": msg['content']}]
})
# Create configuration
def create_config():
from google.genai import types
config = types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
# Add system instruction if present
if system_instruction:
config.system_instruction = system_instruction.strip()
return config
config = await asyncio.to_thread(create_config)
# Выполняем запрос в отдельном потоке
def generate_content():
# For single message without history, use generate_content
if len(contents) <= 1:
# If we have no content yet, create a simple prompt
if not contents:
prompt = "I need your assistance."
else:
prompt = contents[0]["parts"][0]["text"]
return client.models.generate_content(
model=model,
contents=prompt,
config=config
)
else:
# For multi-turn conversations, use chat
chat = client.chats.create(model=model, config=config)
# Send all messages in sequence
for content in contents:
if content["role"] == "user":
response = chat.send_message(content["parts"][0]["text"])
# We don't send assistant messages as they're already part of the history
return response
response = await asyncio.to_thread(generate_content)
# Extract response text
def extract_response():
response_text = response.text if hasattr(response, 'text') else ""
# Try to get token usage if available
usage = {}
if hasattr(response, 'usage_metadata'):
usage = {
"prompt_tokens": getattr(response.usage_metadata, 'prompt_token_count', 0),
"completion_tokens": getattr(response.usage_metadata, 'candidates_token_count', 0),
"total_tokens": getattr(response.usage_metadata, 'total_token_count', 0)
}
else:
# Estimate token count as fallback
usage = {
"prompt_tokens": len(" ".join([m["content"] for m in messages]).split()) // 3,
"completion_tokens": len(response_text.split()) // 3,
"total_tokens": 0 # Will be calculated below
}
usage["total_tokens"] = usage["prompt_tokens"] + usage["completion_tokens"]
return response_text, usage
response_text, usage = await asyncio.to_thread(extract_response)
return {
"choices": [{
"message": {
"content": answer_text
"content": response_text
}
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens
}
"usage": usage
}
except ImportError as e:
_LOGGER.error(f"Google Gemini library not installed: {str(e)}")
raise HomeAssistantError(f"Missing dependency: {str(e)}. Please install google-genai.")
except Exception as e:
_LOGGER.error(f"Gemini API error: {str(e)}")
raise HomeAssistantError(f"Gemini API error: {str(e)}")
+173 -21
View File
@@ -8,6 +8,7 @@ Config flow for HA text AI integration.
"""
import logging
from typing import Any, Dict, Optional
from datetime import datetime, timedelta
import voluptuous as vol
from homeassistant import config_entries
@@ -147,41 +148,173 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
})
)
# Debug log to identify what's in the input
_LOGGER.debug(f"Provider step input data: {user_input}")
input_copy = user_input.copy()
# Check if CONF_NAME exists in input_copy and ensure it's not empty
if CONF_NAME not in input_copy or not input_copy[CONF_NAME]:
_LOGGER.warning(f"Missing name in configuration input: {input_copy}")
input_copy[CONF_NAME] = f"gemini_assistant_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
_LOGGER.info(f"Auto-generated name: {input_copy[CONF_NAME]}")
# Ensure API key is present
if CONF_API_KEY not in input_copy or not input_copy[CONF_API_KEY]:
self._errors["base"] = "invalid_auth"
_LOGGER.error("API validation error: 'api_key'")
return self.async_show_form(
step_id="provider",
data_schema=vol.Schema({
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL if self._provider == API_PROVIDER_GEMINI else DEFAULT_MODEL)): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT if self._provider == API_PROVIDER_GEMINI else DEFAULT_OPENAI_ENDPOINT)): str,
vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(CONF_MAX_TOKENS, default=input_copy.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(CONF_REQUEST_INTERVAL, default=input_copy.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
vol.Optional(
CONF_CONTEXT_MESSAGES,
default=input_copy.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=20)
),
vol.Optional(
CONF_MAX_HISTORY_SIZE,
default=input_copy.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=100)
),
}),
errors=self._errors
)
try:
# Validate and normalize the name
normalized_name = self._validate_and_normalize_name(input_copy[CONF_NAME])
input_copy[CONF_NAME] = normalized_name
except ValueError as e:
return self.async_show_form(
step_id="provider",
data_schema=vol.Schema({
vol.Required(CONF_NAME, default=input_copy[CONF_NAME]): str,
vol.Required(CONF_API_KEY, default=input_copy[CONF_API_KEY]): str,
vol.Required(CONF_MODEL, default=input_copy[CONF_MODEL]): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy[CONF_API_ENDPOINT]): str,
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL if self._provider == API_PROVIDER_GEMINI else DEFAULT_MODEL)): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT if self._provider == API_PROVIDER_GEMINI else DEFAULT_OPENAI_ENDPOINT)): str,
vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(CONF_MAX_TOKENS, default=input_copy.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(CONF_REQUEST_INTERVAL, default=input_copy.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
vol.Optional(
CONF_CONTEXT_MESSAGES,
default=input_copy.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=20)
),
vol.Optional(
CONF_MAX_HISTORY_SIZE,
default=input_copy.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=100)
),
}),
errors={"name": str(e)}
)
try:
if not await self._async_validate_api(input_copy):
return self.async_show_form(
step_id="provider",
data_schema=vol.Schema({}),
errors=self._errors
)
# Special handling for Gemini API validation
if self._provider == API_PROVIDER_GEMINI:
# For Gemini, we just check if API key is present as there's no simple endpoint to validate
if not input_copy.get(CONF_API_KEY):
self._errors["base"] = "invalid_auth"
_LOGGER.error("API validation error: 'api_key'")
return self.async_show_form(
step_id="provider",
data_schema=vol.Schema({
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL)): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT)): str,
# Other fields remain the same
}),
errors=self._errors
)
else:
# For other providers, validate API connection
if not await self._async_validate_api(input_copy):
return self.async_show_form(
step_id="provider",
data_schema=vol.Schema({
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_MODEL)): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_OPENAI_ENDPOINT)): str,
vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(CONF_MAX_TOKENS, default=input_copy.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(CONF_REQUEST_INTERVAL, default=input_copy.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
vol.Optional(
CONF_CONTEXT_MESSAGES,
default=input_copy.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=20)
),
vol.Optional(
CONF_MAX_HISTORY_SIZE,
default=input_copy.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=100)
),
}),
errors=self._errors
)
except Exception as e:
# Handle any unexpected exceptions during validation
_LOGGER.exception("Unexpected error during API validation")
return self.async_show_form(
step_id="provider",
data_schema=vol.Schema({}),
data_schema=vol.Schema({
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL if self._provider == API_PROVIDER_GEMINI else DEFAULT_MODEL)): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT if self._provider == API_PROVIDER_GEMINI else DEFAULT_OPENAI_ENDPOINT)): str,
# Other fields remain the same
}),
errors={"base": str(e)}
)
# All validation passed, create the entry
return await self._create_entry(input_copy)
def _validate_and_normalize_name(self, name: str) -> str:
@@ -219,23 +352,34 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_validate_api(self, user_input: Dict[str, Any]) -> bool:
"""Validate API connection."""
try:
if CONF_API_KEY not in user_input:
_LOGGER.error("API validation error: 'api_key'")
self._errors["base"] = "invalid_auth"
return False
session = async_get_clientsession(self.hass)
headers = self._get_api_headers(user_input)
endpoint = user_input[CONF_API_ENDPOINT].rstrip('/')
check_url = (
f"{endpoint}/v1/models" if self._provider == API_PROVIDER_ANTHROPIC
else f"{endpoint}/models"
)
async with session.get(check_url, headers=headers) as response:
if response.status == 401:
if self._provider == API_PROVIDER_GEMINI:
if not user_input[CONF_API_KEY]:
self._errors["base"] = "invalid_auth"
return False
elif response.status not in [200, 404]:
self._errors["base"] = "cannot_connect"
return False
return True
else:
check_url = (
f"{endpoint}/v1/models" if self._provider == API_PROVIDER_ANTHROPIC
else f"{endpoint}/models"
)
async with session.get(check_url, headers=headers) as response:
if response.status == 401:
self._errors["base"] = "invalid_auth"
return False
elif response.status not in [200, 404]:
self._errors["base"] = "cannot_connect"
return False
return True
except Exception as err:
_LOGGER.error("API validation error: %s", str(err))
@@ -244,6 +388,9 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def _get_api_headers(self, user_input: Dict[str, Any]) -> Dict[str, str]:
"""Get API headers based on provider."""
if CONF_API_KEY not in user_input:
return {"Content-Type": "application/json"}
api_key = user_input[CONF_API_KEY]
if self._provider == API_PROVIDER_ANTHROPIC:
@@ -252,6 +399,11 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"anthropic-version": "2023-06-01",
"Content-Type": "application/json"
}
elif self._provider == API_PROVIDER_GEMINI:
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
+1 -1
View File
@@ -74,7 +74,7 @@ ICONS_SUBDOMAIN = "icons"
# Default values
DEFAULT_MODEL: Final = "gpt-4o-mini"
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat"
DEFAULT_GEMINI_MODEL: Final = "gemini-pro"
DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash"
DEFAULT_TEMPERATURE: Final = 0.1
DEFAULT_MAX_TOKENS: Final = 1000
DEFAULT_REQUEST_INTERVAL: Final = 1.0
+9 -9
View File
@@ -13,17 +13,17 @@
"loggers": ["custom_components.ha_text_ai"],
"mqtt": [],
"quality_scale": "silver",
"requirements": [
"openai>=1.12.0",
"anthropic>=0.8.0",
"google-generativeai>=0.3.0",
"aiohttp>=3.8.0",
"async-timeout>=4.0.0",
"certifi>=2024.2.2"
],
"requirements": [
"openai>=1.12.0",
"anthropic>=0.8.0",
"google-genai>=1.16.0",
"aiohttp>=3.8.0",
"async-timeout>=4.0.0",
"certifi>=2024.2.2"
],
"single_config_entry": false,
"ssdp": [],
"usb": [],
"version": "2.1.6",
"version": "2.1.7",
"zeroconf": []
}
+1
View File
@@ -9,6 +9,7 @@ Sensor platform for HA Text AI.
import logging
import math
from typing import Any, Dict
from datetime import datetime, timedelta
from homeassistant.components.sensor import (
SensorEntity,