Initial commit: Comfyui_o1key v1.10.0

This commit is contained in:
o1key
2026-02-06 15:56:30 +08:00
commit 9ee29e17d0
25 changed files with 5735 additions and 0 deletions
+728
View File
@@ -0,0 +1,728 @@
"""
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}"