GAP / app /core /services /gemini.py
misonL's picture
Upload 52 files
e82bac2 verified
Raw
History Blame Contribute Delete
19.9 kB
# -*- coding: utf-8 -*-
"""
Gemini API 客户端模块。
封装了与 Google Gemini API 交互的逻辑,使用 google-generativeai SDK。
"""
# 导入必要的库和类型
import os # 用于访问环境变量
import asyncio # 异步 IO 库
import logging # 日志库
from typing import Any, Dict, List, Optional, Tuple, Union, AsyncGenerator # 类型提示
import httpx # HTTP 客户端库,用于配置 SDK
import google.generativeai as genai # Google Gemini SDK
from google.generativeai import types # Gemini SDK 中的类型定义 (修正导入路径)
from google.api_core import exceptions as google_exceptions # Google API 核心异常
# 导入应用内部的模型和工具类
from app.api.models import ChatCompletionRequest # OpenAI 格式的聊天请求模型
from app.core.utils.response_wrapper import ResponseWrapper # 用于包装和处理 Gemini 响应的工具类 (新路径)
# 获取日志记录器实例
logger = logging.getLogger('my_logger')
# 定义与 Gemini API 交互的客户端类
class GeminiClient:
"""
Gemini API 客户端类。
封装了使用 google-generativeai SDK 与 Gemini API 进行通信的方法。
包括配置 SDK、转换数据格式、发送流式和非流式请求、处理响应等。
"""
# 类变量,用于存储可用的模型列表,将在首次调用 list_available_models 时填充
AVAILABLE_MODELS: List[str] = []
# 从环境变量读取额外的模型名称(逗号分隔),并添加到可用模型列表中
EXTRA_MODELS: List[str] = [model.strip() for model in os.environ.get("EXTRA_MODELS", "").split(",") if model.strip()]
def __init__(self, api_key: str, http_client: httpx.AsyncClient):
"""
初始化 GeminiClient 实例。
Args:
api_key (str): 用于访问 Gemini API 的 API 密钥。
http_client (httpx.AsyncClient): 共享的异步 HTTP 客户端实例,用于配置 SDK 的传输。
Raises:
ValueError: 如果 api_key 或 http_client 为空。
"""
# 验证输入参数
if not api_key:
raise ValueError("API Key 不能为空")
if not http_client:
raise ValueError("http_client 不能为空")
# 存储 API Key 和 HTTP 客户端
self.api_key = api_key
self.http_client = http_client
# --- 配置 Google Gemini SDK ---
try:
# 移除 client_options={"transport": self.http_client}
# 如果 google-generativeai==0.8.5 的 transport 参数可以直接接受 httpx.AsyncClient,
# 则应改为 transport=self.http_client。
# 但首先,我们解决 ClientOptions 的错误。
# 保守的改法是只用 transport="rest",让 SDK 自己处理 HTTP client。
# 或者,如果确定 0.8.5 版本支持,可以尝试 transport=self.http_client
# 查阅相关资料,0.x 版本似乎不直接支持在 configure 时注入 httpx_client 给 transport。
# 它更多的是依赖 google-auth 来处理 transport。
# 因此,最安全的初始修复是仅保留 api_key 和 transport="rest"。
genai.configure(
api_key=self.api_key, # 设置 API Key
transport="rest" # 指定使用 REST API 传输层
# client_options 已移除,因为它导致了 ValueError
)
logger.debug(f"Gemini SDK 已为 Key {self.api_key[:8]}... 配置完成 (移除了 client_options 中的 transport)。")
except Exception as config_err:
logger.error(f"配置 Gemini SDK 时出错 (Key: {self.api_key[:8]}...): {config_err}", exc_info=True)
# --- 内部辅助方法:数据格式转换 ---
def _convert_contents_to_sdk_format(self, contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
processed_contents = []
for item_content in contents:
processed_parts = []
if "parts" in item_content:
for part_data in item_content["parts"]:
processed_parts.append(part_data)
if item_content.get("role") and processed_parts:
processed_contents.append({"role": item_content["role"], "parts": processed_parts})
elif item_content.get("role") and not processed_parts:
logger.warning(f"角色 '{item_content.get('role')}' 的内容没有有效的 parts: {item_content.get('parts', [])}")
return processed_contents
def _convert_safety_settings_to_sdk_format(self, safety_settings: List[Dict[str, Any]]) -> List[Dict[str, str]]:
processed_safety_settings = []
for setting in safety_settings:
category = setting.get("category")
threshold = setting.get("threshold")
if category and threshold is not None:
processed_safety_settings.append({"category": category, "threshold": threshold})
else:
logger.warning(f"无效的安全设置项,缺少 category 或 threshold: {setting}")
return processed_safety_settings
def _convert_system_instruction_to_sdk_format(self, system_instruction: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if system_instruction and "parts" in system_instruction and isinstance(system_instruction["parts"], list):
return system_instruction
return None
# --- 内部辅助方法:处理 SDK 响应 ---
def _process_sdk_response(self, response: Dict[str, Any]) -> Tuple[str, Optional[Dict[str, Any]], Optional[str], Optional[str], Optional[str]]: # 类型注解已修改
text_content = ""
usage_metadata = None
safety_issue_detail = None
finish_reason = None
cached_content_id = None # 假设 0.8.5 版本不直接通过此方法返回缓存 ID
candidates = response.get("candidates")
if candidates and isinstance(candidates, list) and len(candidates) > 0:
candidate = candidates[0] # 取第一个候选者
if isinstance(candidate, dict):
content = candidate.get("content")
if content and isinstance(content, dict) and "parts" in content and isinstance(content["parts"], list):
for part in content["parts"]:
if isinstance(part, dict) and "text" in part:
text_content += part["text"]
# finish_reason 的处理:假设它直接是字符串或 None
raw_finish_reason = candidate.get("finishReason") # 注意大小写可能与 GenerateContentResponse 对象不同
if isinstance(raw_finish_reason, str):
finish_reason = raw_finish_reason
elif raw_finish_reason is not None: # 如果存在但不是字符串,记录警告
logger.warning(f"预期的 finish_reason 是字符串,但得到: {type(raw_finish_reason)} - {raw_finish_reason}")
safety_ratings = candidate.get("safetyRatings") # 注意大小写
if safety_ratings and isinstance(safety_ratings, list):
for rating in safety_ratings:
if isinstance(rating, dict):
category = rating.get("category")
probability = rating.get("probability") # 假设直接是字符串 'HIGH', 'MEDIUM', 'LOW', 'NEGLIGIBLE'
blocked = rating.get("blocked", False) # 默认为 False
is_problematic = blocked or probability in ['HIGH', 'MEDIUM']
if is_problematic:
log_level = logging.WARNING if blocked or probability == 'HIGH' else logging.INFO
logger.log(log_level, f"SDK 响应安全评分: Category={category}, Probability={probability}, Blocked={blocked}, Key: {self.api_key[:8]}...")
if blocked or probability == 'HIGH':
safety_issue_detail = f"安全问题: {category}"
else:
logger.warning(f"候选者格式不正确: {candidate}")
sdk_usage_metadata = response.get("usageMetadata") # 注意大小写
if sdk_usage_metadata and isinstance(sdk_usage_metadata, dict):
usage_metadata = {
"prompt_token_count": sdk_usage_metadata.get("promptTokenCount"), # 注意大小写
"candidates_token_count": sdk_usage_metadata.get("candidatesTokenCount"), # 注意大小写
"total_token_count": sdk_usage_metadata.get("totalTokenCount"), # 注意大小写
}
# 过滤掉值为 None 的 token 计数
usage_metadata = {k: v for k, v in usage_metadata.items() if v is not None}
if not usage_metadata: # 如果所有计数都为 None,则将 usage_metadata 设为 None
usage_metadata = None
# 尝试从响应顶层获取缓存元数据 (如果存在)
response_cache_metadata = response.get("cacheMetadata")
if response_cache_metadata and isinstance(response_cache_metadata, dict):
cached_content_id = response_cache_metadata.get("cachedContentId")
if cached_content_id:
logger.debug(f"从响应中提取到 cachedContentId: {cached_content_id}")
return text_content, usage_metadata, safety_issue_detail, finish_reason, cached_content_id
# --- API 调用方法 ---
async def stream_chat(self, request: ChatCompletionRequest, contents: List[Dict[str, Any]], safety_settings: List[Dict[str, Any]], system_instruction: Optional[Dict[str, Any]], cached_content_id: Optional[str] = None) -> AsyncGenerator[Union[str, Dict[str, Any]], None]:
logger.info(f"流式请求开始 (Key: {self.api_key[:8]}..., Model: {request.model}, CachedContentId: {cached_content_id}) →")
text_yielded = False
safety_issue_detail_sent = False
usage_metadata_received = None
final_finish_reason = "STOP"
try:
model = genai.GenerativeModel(model_name=request.model)
sdk_contents = self._convert_contents_to_sdk_format(contents)
sdk_safety_settings = self._convert_safety_settings_to_sdk_format(safety_settings)
sdk_system_instruction = self._convert_system_instruction_to_sdk_format(system_instruction)
sdk_generation_config = {
"temperature": request.temperature,
"max_output_tokens": request.max_tokens,
}
sdk_generation_config = {k: v for k, v in sdk_generation_config.items() if v is not None}
if cached_content_id:
logger.warning(f"google-generativeai==0.8.5 时 CachedContent 的用法未知,暂时不使用缓存 ID: {cached_content_id}")
async for chunk in await model.generate_content(
contents=sdk_contents,
stream=True,
safety_settings=sdk_safety_settings,
system_instruction=sdk_system_instruction,
generation_config=sdk_generation_config
):
text_in_chunk, usage_metadata, safety_issue_detail, finish_reason, cached_content_id_from_response = self._process_sdk_response(chunk)
if text_in_chunk:
yield text_in_chunk
text_yielded = True
if cached_content_id_from_response:
yield {"_cache_metadata": {"cached_content_id": cached_content_id_from_response}}
if usage_metadata:
usage_metadata_received = usage_metadata
if safety_issue_detail and not safety_issue_detail_sent:
yield {'_safety_issue': safety_issue_detail}
safety_issue_detail_sent = True
if finish_reason and finish_reason != "STOP":
final_finish_reason = finish_reason
except google_exceptions.GoogleAPIError as e:
logger.error(f"SDK 流处理 Google API 错误: {e}", exc_info=True)
raise RuntimeError(f"SDK 流处理 Google API 错误: {e}") from e
except Exception as e:
error_detail = f"SDK 流处理意外错误: {e}"
logger.error(error_detail, exc_info=True)
raise RuntimeError(error_detail) from e
finally:
logger.info(f"流式请求结束 (Key: {self.api_key[:8]}..., Model: {request.model}, CachedContentId: {cached_content_id}) ←")
yield {'_final_finish_reason': final_finish_reason}
if usage_metadata_received:
yield {'_usage_metadata': usage_metadata_received}
async def complete_chat(self, request: ChatCompletionRequest, contents: List[Dict[str, Any]], safety_settings: List[Dict[str, Any]], system_instruction: Optional[Dict[str, Any]], cached_content_id: Optional[str] = None) -> ResponseWrapper:
logger.info(f"非流式请求开始 (Key: {self.api_key[:8]}..., Model: {request.model}, CachedContentId: {cached_content_id})")
try:
model = genai.GenerativeModel(model_name=request.model)
sdk_contents = self._convert_contents_to_sdk_format(contents)
sdk_safety_settings = self._convert_safety_settings_to_sdk_format(safety_settings)
sdk_system_instruction = self._convert_system_instruction_to_sdk_format(system_instruction)
sdk_generation_config = {
"temperature": request.temperature,
"max_output_tokens": request.max_tokens,
}
sdk_generation_config = {k: v for k, v in sdk_generation_config.items() if v is not None}
if cached_content_id:
logger.warning(f"google-generativeai==0.8.5 时 CachedContent 的用法未知,暂时不使用缓存 ID: {cached_content_id}")
# 假设 model.generate_content 在非流式模式下直接返回一个字典
response_dict: Dict[str, Any] = await model.generate_content( # 类型注解已修改
contents=sdk_contents,
stream=False,
safety_settings=sdk_safety_settings,
system_instruction=sdk_system_instruction,
generation_config=sdk_generation_config
)
text_content, usage_metadata, safety_issue_detail, finish_reason, cached_content_id_from_response = self._process_sdk_response(response_dict) # 使用修改后的 response_dict
# 构建 ResponseWrapper 需要的数据结构
wrapped_response_data = {
"candidates": [],
"usageMetadata": usage_metadata, # 来自 _process_sdk_response
}
# 基于 _process_sdk_response 的输出来构建 candidate 数据
# 注意:_process_sdk_response 返回的是聚合的 text_content,而不是原始的 parts 结构
# 如果需要更精细的 parts 结构,需要在 _process_sdk_response 中调整或在这里重新处理 response_dict
if text_content or finish_reason: # 只要有文本或完成原因,就尝试构建 candidate
candidate_data = {
"content": {
"parts": [{"text": text_content if text_content else ""}] # 确保 text 字段存在
},
"finishReason": finish_reason, # 来自 _process_sdk_response
# safetyRatings 可以在这里从 response_dict 中提取并转换,如果需要的话
}
wrapped_response_data["candidates"].append(candidate_data)
if safety_issue_detail: # 如果存在安全问题,可以考虑如何体现在 ResponseWrapper 中
logger.warning(f"检测到安全问题,将包含在响应中: {safety_issue_detail}")
# 可以在 wrapped_response_data 中添加一个字段来表示安全问题,例如:
# wrapped_response_data["safetyFeedback"] = {"blockReason": safety_issue_detail}
# 或者根据 OpenAI 的格式,如果被阻止,finish_reason 可能是 "SAFETY"
if wrapped_response_data["candidates"] and isinstance(wrapped_response_data["candidates"], list) and len(wrapped_response_data["candidates"]) > 0:
# 如果是因为安全问题导致内容为空,可以更新 finish_reason
if not text_content and finish_reason != "SAFETY": # 假设 "SAFETY" 是一个可能的 finish_reason
logger.info(f"内容为空且存在安全问题,将 finish_reason 更新为 SAFETY (原: {finish_reason})")
# wrapped_response_data["candidates"][0]["finishReason"] = "SAFETY" # 取决于API具体行为
if cached_content_id_from_response:
wrapped_response_data["cacheMetadata"] = {"cached_content_id": cached_content_id_from_response}
logger.info(f"响应包含缓存元数据: {cached_content_id_from_response}")
elif cached_content_id: # 如果请求时使用了缓存ID,但响应中没有,记录一下
logger.debug(f"尝试使用了缓存 {cached_content_id},但 API 响应未明确返回缓存元数据。")
logger.info(f"非流式请求成功 (Key: {self.api_key[:8]}..., Model: {request.model}, CachedContentId: {cached_content_id})")
return ResponseWrapper(wrapped_response_data)
except google_exceptions.GoogleAPIError as e:
logger.error(f"SDK 非流处理 Google API 错误: {e}", exc_info=True)
raise RuntimeError(f"SDK 非流处理 Google API 错误: {e}") from e
except Exception as e:
error_detail = f"SDK 非流处理意外错误: {e}"
logger.error(error_detail, exc_info=True)
raise RuntimeError(error_detail) from e
@staticmethod
async def list_available_models(api_key: str, http_client: httpx.AsyncClient) -> List[str]:
if not api_key:
raise ValueError("API Key 不能为空")
logger.info(f"尝试使用 Key {api_key[:8]}... 获取模型列表 (通过 SDK)")
try:
# 同样移除 client_options={"transport": http_client}
genai.configure(
api_key=api_key,
transport="rest"
# client_options 已移除
)
except Exception as config_err:
logger.error(f"配置 Gemini SDK 以获取模型列表时出错: {config_err}", exc_info=True)
raise Exception(f"配置 SDK 失败: {config_err}") from config_err
try:
# genai.list_models() 在旧版本中可能返回同步生成器
models_iterable = genai.list_models() # 移除 await
model_names = []
for model in models_iterable: # 改为同步 for 循环
model_name = model.name
if model_name.startswith("models/"):
model_name = model_name[len("models/"):]
model_names.append(model_name)
logger.info(f"成功获取到 {len(model_names)} 个模型 (Key: {api_key[:8]}..., 通过 SDK)")
return model_names
except Exception as e:
logger.error(f"获取模型列表失败 (通过 SDK): {e}", exc_info=True)
# 确保在异步函数中正确处理同步代码可能引发的异常,或者将此部分变为同步(如果可以)
# 但由于整个 list_available_models 是 async def,同步迭代本身是允许的。
raise Exception(f"获取模型列表失败: {e}") from e