""" Gemini API 客户端 处理与 api.o1key.com 的通信,用于图像生成 """ import re import time from io import BytesIO from typing import Any, Callable, Dict, List, Optional import aiohttp from PIL import Image from ..utils.image_utils import encode_image_to_base64, decode_base64_to_pil from ..utils.config import get_api_key_or_raise from .base_client import BaseAPIClient # API 基础配置 API_BASE_URL = "https://api.o1key.com" class GeminiAPIClient(BaseAPIClient): """ Gemini API 客户端 用于调用 Gemini 3 Pro 模型进行图像生成 """ @staticmethod def get_timeout_by_resolution(resolution: str) -> int: """ 根据分辨率获取超时时间 Args: resolution: 分辨率(1K, 2K, 4K) Returns: 超时时间(秒) """ timeout_map = { "1K": 180, # 3 分钟 "2K": 300, # 5 分钟 "4K": 360 # 6 分钟 } return timeout_map.get(resolution, 300) # 默认 5 分钟 def __init__(self, api_key: Optional[str] = None): """ 初始化客户端 Args: api_key: API 密钥,如果为 None 则从配置文件或环境变量读取 """ if api_key is None: api_key = get_api_key_or_raise("O1KEY_API_KEY") super().__init__( base_url=API_BASE_URL, api_key=api_key, max_request_size=20 * 1024 * 1024 ) def get_endpoint(self, model: str = "", resolution: str = "2K", **kwargs) -> str: """ 根据模型和分辨率获取 API 端点 Args: model: 模型名称 resolution: 分辨率(1K, 2K, 4K) Returns: API 端点路径 """ from ..models_config import get_model_endpoint # 特殊处理:动态端点模型(根据分辨率选择) if model == "nano-banana-pro": if resolution == "1K": return "/v1beta/models/nano-banana-pro:generateContent" elif resolution == "2K": return "/v1beta/models/nano-banana-pro-2k:generateContent" elif resolution == "4K": return "/v1beta/models/nano-banana-pro-4k:generateContent" else: return "/v1beta/models/nano-banana-pro-2k:generateContent" elif model == "gemini-3-pro-image-preview-url": if resolution == "1K": return "/v1beta/models/gemini-3-pro-image-preview-url:generateContent" elif resolution == "2K": return "/v1beta/models/gemini-3-pro-image-preview-2k-url:generateContent" elif resolution == "4K": return "/v1beta/models/gemini-3-pro-image-preview-4k-url:generateContent" else: return "/v1beta/models/gemini-3-pro-image-preview-2k-url:generateContent" # 其他模型:从配置文件读取端点 endpoint = get_model_endpoint(model) if endpoint: return endpoint # 兜底:使用标准模式端点 return "/v1beta/models/gemini-3-pro-image-preview:generateContent" def build_request_body( self, prompt: str = "", images: Optional[List[Image.Image]] = None, aspect_ratio: str = "1:1", resolution: str = "2K", **kwargs ) -> Dict[str, Any]: """ 构建 API 请求体 Args: prompt: 提示词 images: 输入图像列表(可选) aspect_ratio: 宽高比 resolution: 分辨率 Returns: 请求体字典 """ parts = [] # 添加文本部分 parts.append({"text": prompt}) # 添加图像部分(如果有) if images: for img in images: img_base64 = encode_image_to_base64(img) parts.append({ "inline_data": { "mime_type": "image/png", "data": img_base64 } }) # 构建请求体 request_body = { "contents": [ { "role": "user", "parts": parts } ], "generationConfig": { "responseModalities": ["TEXT", "IMAGE"], "imageConfig": { "aspectRatio": aspect_ratio, "imageSize": resolution } } } return request_body def parse_response(self, response: Dict[str, Any]) -> List[Image.Image]: """ 同步解析 API 响应(保留以满足抽象基类要求) 注意:此方法仅用于兼容基类接口,实际使用请调用 parse_response_async() Args: response: API 响应字典 Returns: 图像列表 Raises: RuntimeError: 此方法不应被直接调用 """ raise RuntimeError( "parse_response() 不应被直接调用。" "请使用 generate_single_async() 或 generate_batch_async() 等高级方法。" ) async def parse_response_async( self, response: Dict[str, Any], session: Optional[aiohttp.ClientSession] = None ) -> List[Image.Image]: """ 异步解析 API 响应,提取生成的图像 Args: response: API 响应字典 session: aiohttp 会话(用于下载图片) Returns: 图像列表 Raises: RuntimeError: 解析失败或 API 拒绝时 """ # ========== 错误检测(按优先级顺序)========== # 1. 检查 candidatesTokenCount(最高优先级) usage_metadata = response.get("usageMetadata", {}) candidates_token_count = usage_metadata.get("candidatesTokenCount", -1) if candidates_token_count == 0: error_msg = ( "内容审核拒绝 - candidatesTokenCount = 0\n\n" "原因:提示词或参考图包含不适当内容(色情、暴力、敏感话题等)," "在内容审核阶段就被拒绝,连候选内容都未生成。\n\n" "建议:\n" " - 检查提示词,确保不包含敏感或违规内容\n" " - 如使用参考图,确保图片内容健康合规\n" " - 避免描述暴力、色情等不当内容\n" " - 调整提示词后重试" ) raise RuntimeError(error_msg) # 2. 检查 finishReason(次优先级) candidates = response.get("candidates", []) if candidates: for candidate in candidates: finish_reason = candidate.get("finishReason", "") if finish_reason and finish_reason != "STOP": # 根据不同的 finishReason 提供具体建议 reason_messages = { "PROHIBITED_CONTENT": ( "违禁内容拒绝", "生成内容触发了违禁内容策略", [ "避免引用未来未发布的产品或概念(知识库截止2025年1月)", "使用专业图片编辑软件处理特殊需求", "确保请求内容在模型知识范围内" ] ), "SAFETY": ( "安全过滤器拒绝", "内容触发了安全过滤器", [ "使用健康、正面的描述", "避免涉及隐私和伦理问题的内容", "调整提示词后重试" ] ), "RECITATION": ( "版权问题", "可能涉及版权或重复已有内容", [ "避免涉及版权敏感话题", "使用更原创的描述方式", "调整提示词后重试" ] ), "MAX_TOKENS": ( "Token 超限", "生成的内容超过了 Token 限制", [ "简化提示词", "减少输入图片数量", "降低请求复杂度" ] ) } if finish_reason in reason_messages: title, reason, suggestions = reason_messages[finish_reason] suggestions_text = "\n".join([f" - {s}" for s in suggestions]) error_msg = ( f"{title} - finishReason = {finish_reason}\n\n" f"原因:{reason}\n\n" f"建议:\n{suggestions_text}" ) else: # 未知的 finishReason error_msg = ( f"生成异常 - finishReason = {finish_reason}\n\n" "原因:生成过程中断,具体原因未知\n\n" "建议:\n" " - 使用健康、正面的描述\n" " - 避免敏感话题\n" " - 调整提示词后重试" ) raise RuntimeError(error_msg) # ========== 图像提取 ========== images = [] text_responses = [] # 收集文本响应 # 需要关闭 session 的标记 close_session = False if session is None: session = aiohttp.ClientSession() close_session = True try: for candidate in candidates: content = candidate.get("content", {}) parts = content.get("parts", []) for part in parts: # 方式1: inline_data 或 inlineData (base64) # 兼容两种命名方式:蛇形(inline_data)和驼峰(inlineData) inline_data_key = None if "inline_data" in part: inline_data_key = "inline_data" elif "inlineData" in part: inline_data_key = "inlineData" if inline_data_key: inline_data = part[inline_data_key] # 同样兼容 data/mimeType 的命名 img_data = inline_data.get("data") or inline_data.get("data", "") if img_data: img = decode_base64_to_pil(img_data) images.append(img) # 方式2: text 中的 URL - 改为异步下载 elif "text" in part: text = part["text"] # 收集文本响应(用于后续错误检测) text_responses.append(text) # 尝试 markdown 格式: ![alt](url) url_pattern_md = r'!\[.*?\]\((https?://[^\)]+)\)' urls = re.findall(url_pattern_md, text) # 如果没找到,尝试纯 URL 格式 if not urls: url_pattern_plain = r'https?://[^\s<>"{}|\\^`\[\]]+' urls = re.findall(url_pattern_plain, text) if urls: for url in urls: try: # 使用 aiohttp 异步下载,支持更大的超时 download_start = time.time() timeout = aiohttp.ClientTimeout(total=120) async with session.get(url, timeout=timeout) as img_response: if img_response.status == 200: img_data = await img_response.read() download_time = time.time() - download_start img_size_mb = len(img_data) / 1024 / 1024 speed_mbps = img_size_mb / download_time if download_time > 0 else 0 # print(f"🔽 图片下载: {img_size_mb:.2f}MB 耗时 {download_time:.2f}s 速度 {speed_mbps:.2f}MB/s") img = Image.open(BytesIO(img_data)) images.append(img) else: print(f"Nano Banana Pro: 下载图片失败 - HTTP {img_response.status}") except Exception as e: print(f"Nano Banana Pro: 下载图片失败 - {str(e)}") # 方式3: 直接的 URL 字段 - 也改为异步 elif "imageUrl" in part or "url" in part: url = part.get("imageUrl") or part.get("url") try: download_start = time.time() timeout = aiohttp.ClientTimeout(total=120) async with session.get(url, timeout=timeout) as img_response: if img_response.status == 200: img_data = await img_response.read() download_time = time.time() - download_start img_size_mb = len(img_data) / 1024 / 1024 speed_mbps = img_size_mb / download_time if download_time > 0 else 0 # print(f"🔽 图片下载: {img_size_mb:.2f}MB 耗时 {download_time:.2f}s 速度 {speed_mbps:.2f}MB/s") img = Image.open(BytesIO(img_data)) images.append(img) else: print(f"Nano Banana Pro: 下载图片失败 - HTTP {img_response.status}") except Exception as e: print(f"Nano Banana Pro: 下载图片失败 - {str(e)}") except Exception as e: raise RuntimeError(f"解析 API 响应失败: {str(e)}") finally: if close_session: await session.close() # 3. 检查 API 文本响应拒绝说明 if not images and text_responses: # API 返回了文本但没有图片,说明请求被拒绝 combined_text = "\n".join(text_responses) error_msg = ( f"API 拒绝响应\n\n" f"API 返回说明:\n{combined_text}\n\n" f"建议:\n" f" - 根据上述说明调整请求内容\n" f" - 确保提示词和参考图符合使用规范" ) raise RuntimeError(error_msg) if not images: raise RuntimeError("API 响应中未找到生成的图像") return images async def generate_single_async( self, prompt: str, model: str, resolution: str, aspect_ratio: str, images: Optional[List[Image.Image]] = None, session=None ) -> List[Image.Image]: """ 单次异步生成请求 Args: prompt: 提示词 model: 模型名称 resolution: 分辨率 aspect_ratio: 宽高比 images: 输入图像列表 session: aiohttp 会话 Returns: 生成的图像列表 """ endpoint = self.get_endpoint(model=model, resolution=resolution) request_body = self.build_request_body( prompt=prompt, images=images, aspect_ratio=aspect_ratio, resolution=resolution ) # 根据分辨率获取超时时间 timeout = self.get_timeout_by_resolution(resolution) response = await self.request_async(endpoint, request_body, session, timeout=timeout) # 使用异步解析方法,传入 session 以实现并发图片下载 return await self.parse_response_async(response, session) async def generate_batch_async( self, prompt: str, model: str, resolution: str, aspect_ratio: str, batch_size: int, images: Optional[List[Image.Image]] = None, progress_callback: Optional[Callable[[int, int, bool, Optional[str]], None]] = None ) -> List[Image.Image]: """ 批量全并发生成 Args: prompt: 提示词 model: 模型名称 resolution: 分辨率 aspect_ratio: 宽高比 batch_size: 批次大小 images: 输入图像列表 progress_callback: 进度回调,签名为 (completed, total, success, error_msg) Returns: 生成的图像列表 """ import aiohttp import asyncio all_images = [] completed = 0 success_count = 0 fail_count = 0 connector = aiohttp.TCPConnector(limit=0, limit_per_host=0) async with aiohttp.ClientSession(connector=connector) as session: tasks = [] for i in range(batch_size): task = asyncio.create_task( self.generate_single_async( prompt=prompt, model=model, resolution=resolution, aspect_ratio=aspect_ratio, images=images, session=session ), name=f"task_{i}" ) tasks.append(task) # 使用 as_completed 实时获取完成的任务 for coro in asyncio.as_completed(tasks): completed += 1 try: result = await coro if result: all_images.append(result[0]) success_count += 1 if progress_callback: progress_callback(completed, batch_size, True, None) except Exception as e: fail_count += 1 error_msg = str(e) # 截取错误信息的第一行 if '\n' in error_msg: error_msg = error_msg.split('\n')[0] if progress_callback: progress_callback(completed, batch_size, False, error_msg) if not all_images: raise RuntimeError(f"批量生成失败,{fail_count} 个请求全部失败") return all_images def generate_sync( self, prompt: str, model: str, resolution: str, aspect_ratio: str, batch_size: int, images: Optional[List[Image.Image]] = None, progress_callback: Optional[Callable[[int, int], None]] = None ) -> List[Image.Image]: """ 同步生成接口(用于 ComfyUI) Args: prompt: 提示词 model: 模型名称 resolution: 分辨率 aspect_ratio: 宽高比 batch_size: 批次大小 images: 输入图像列表 progress_callback: 进度回调 Returns: 生成的图像列表 """ coro = self.generate_batch_async( prompt=prompt, model=model, resolution=resolution, aspect_ratio=aspect_ratio, batch_size=batch_size, images=images, progress_callback=progress_callback ) return self.run_async_in_thread(coro) async def generate_multi_prompts_async( self, prompts: List[str], model: str, resolution: str, aspect_ratio: str, images_per_prompt: int, images: Optional[List[Image.Image]] = None, progress_callback: Optional[Callable[[int, int, bool, Optional[str]], None]] = None ) -> List[Image.Image]: """ 多提示词批量生成 为每个提示词生成指定数量的图像,所有请求并发执行。 Args: prompts: 提示词列表 model: 模型名称 resolution: 分辨率 aspect_ratio: 宽高比 images_per_prompt: 每个提示词生成的图像数量 images: 输入图像列表(所有提示词共享) progress_callback: 进度回调,签名为 (completed, total, success, error_msg) Returns: 生成的图像列表(长度 = len(prompts) * images_per_prompt) """ import aiohttp import asyncio all_images = [] completed = 0 success_count = 0 fail_count = 0 total_tasks = len(prompts) * images_per_prompt connector = aiohttp.TCPConnector(limit=0, limit_per_host=0) async with aiohttp.ClientSession(connector=connector) as session: tasks = [] # 为每个提示词创建 images_per_prompt 个任务 task_idx = 0 for prompt in prompts: for _ in range(images_per_prompt): task = asyncio.create_task( self.generate_single_async( prompt=prompt, model=model, resolution=resolution, aspect_ratio=aspect_ratio, images=images, session=session ), name=f"task_{task_idx}" ) tasks.append(task) task_idx += 1 # 使用 as_completed 实时获取完成的任务 for coro in asyncio.as_completed(tasks): completed += 1 try: result = await coro if result: all_images.append(result[0]) success_count += 1 if progress_callback: progress_callback(completed, total_tasks, True, None) except Exception as e: fail_count += 1 error_msg = str(e) # 截取错误信息的第一行 if '\n' in error_msg: error_msg = error_msg.split('\n')[0] if progress_callback: progress_callback(completed, total_tasks, False, error_msg) if not all_images: raise RuntimeError(f"批量生成失败,{fail_count} 个请求全部失败") return all_images def generate_multi_prompts_sync( self, prompts: List[str], model: str, resolution: str, aspect_ratio: str, images_per_prompt: int, images: Optional[List[Image.Image]] = None, progress_callback: Optional[Callable[[int, int], None]] = None ) -> List[Image.Image]: """ 多提示词批量生成(同步接口,用于 ComfyUI) Args: prompts: 提示词列表 model: 模型名称 resolution: 分辨率 aspect_ratio: 宽高比 images_per_prompt: 每个提示词生成的图像数量 images: 输入图像列表 progress_callback: 进度回调 Returns: 生成的图像列表 """ coro = self.generate_multi_prompts_async( prompts=prompts, model=model, resolution=resolution, aspect_ratio=aspect_ratio, images_per_prompt=images_per_prompt, images=images, progress_callback=progress_callback ) return self.run_async_in_thread(coro) async def query_balance_async(self) -> Dict[str, Any]: """ 异步查询余额信息 Returns: 余额信息字典,包含: - name: API 名称 - total_available: 可用余额(原始值) - 其他字段... Raises: RuntimeError: 查询失败时 """ endpoint = "/api/usage/token" response = await self.request_get_async(endpoint, use_bearer_token=True) if not response.get("code"): raise RuntimeError("余额查询响应格式错误") data = response.get("data", {}) return data def query_balance_sync(self) -> Dict[str, Any]: """ 同步查询余额信息(用于 ComfyUI 节点) Returns: 余额信息字典 Raises: RuntimeError: 查询失败时 """ coro = self.query_balance_async() return self.run_async_in_thread(coro) def format_balance_info(self, balance_data: Dict[str, Any]) -> str: """ 格式化余额信息为展示文本 Args: balance_data: 余额信息字典 Returns: 格式化的文本,格式为 "当前余额:$XX.XX | API:xxx" Example: >>> data = {"name": "test-api", "total_available": 50000000} >>> client.format_balance_info(data) '当前余额:$100.00 | API:test-api' """ api_name = balance_data.get("name", "未知") total_available = balance_data.get("total_available", 0) # 转换公式:实际显示 = total_available / 500000 balance_in_dollars = total_available / 500000 return f"当前余额:${balance_in_dollars:.2f} | API:{api_name}"