feat(vertex): 集成 Vertex AI Express API 支持

本次更新引入了对 Google Vertex AI Express API 的支持,允许用户配置和使用 Vertex AI 模型。

主要变更包括:

后端:
- 新增 `VERTEX_API_KEYS` 和 `VERTEX_EXPRESS_BASE_URL` 至系统配置 ([`.env.example`](.env.example:13), [`app/config/config.py:62`](app/config/config.py:62), [`app/database/models.py`](app/database/models.py), [`app/database/services.py`](app/database/services.py))。
- 实现 `VertexExpressChatService` ([`app/service/chat/vertex_express_chat_service.py`](app/service/chat/vertex_express_chat_service.py)) 用于处理与 Vertex AI Express API 的交互。
- 添加 `vertex_express_routes` ([`app/router/vertex_express_routes.py`](app/router/vertex_express_routes.py)) 来暴露 Vertex AI 相关的 API 端点,并集成到主应用 ([`app/core/application.py:36`](app/core/application.py:36), [`app/router/routes.py:15`](app/router/routes.py:15))。
- 更新密钥管理器 ([`app/service/key/key_manager.py`](app/service/key/key_manager.py)) 以支持 Vertex API 密钥的获取、检查和轮换。

前端 (配置编辑器):
- 在配置页面 ([`app/templates/config_editor.html:463`](app/templates/config_editor.html:463)) 添加了 Vertex API 密钥列表和 Vertex Express API 基础 URL 的表单字段。
- 实现了批量添加和删除 Vertex API 密钥的功能,包括相应的模态框和操作逻辑 ([`app/static/js/config_editor.js:550`](app/static/js/config_editor.js:550), [`app/static/js/config_editor.js:1097`](app/static/js/config_editor.js:1097), [`app/templates/config_editor.html:1657`](app/templates/config_editor.html:1657))。
- 确保新的配置项在初始化 ([`app/static/js/config_editor.js:598`](app/static/js/config_editor.js:598)) 和表单填充 ([`app/static/js/config_editor.js:671`](app/static/js/config_editor.js:671)) 时得到正确处理。
- 更新了数组项添加逻辑以识别 `VERTEX_API_KEYS` 为敏感字段 ([`app/static/js/config_editor.js:1235`](app/static/js/config_editor.js:1235))。

此功能扩展了应用支持的 AI 服务范围,为用户提供了更多模型选择。
This commit is contained in:
snaily
2025-05-17 00:13:49 +08:00
parent e260ad02bf
commit 6aab140ec2
19 changed files with 1040 additions and 77 deletions
+4
View File
@@ -10,6 +10,10 @@ MYSQL_DATABASE=default_db
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"] API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
ALLOWED_TOKENS=["sk-123456"] ALLOWED_TOKENS=["sk-123456"]
AUTH_TOKEN=sk-123456 AUTH_TOKEN=sk-123456
# For Vertex AI Platform API Keys
VERTEX_API_KEYS=["AQ.Abxxxxxxxxxxxxxxxxxxx"]
# For Vertex AI Platform Express API Base URL
VERTEX_EXPRESS_BASE_URL=https://aiplatform.googleapis.com/v1beta1/publishers/google
TEST_MODEL=gemini-1.5-flash TEST_MODEL=gemini-1.5-flash
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"] THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000} THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
+15 -16
View File
@@ -59,8 +59,10 @@ class Settings(BaseSettings):
TEST_MODEL: str = DEFAULT_MODEL TEST_MODEL: str = DEFAULT_MODEL
TIME_OUT: int = DEFAULT_TIMEOUT TIME_OUT: int = DEFAULT_TIMEOUT
MAX_RETRIES: int = MAX_RETRIES MAX_RETRIES: int = MAX_RETRIES
PROXIES: List[str] = [] # 新增:代理服务器列表 PROXIES: List[str] = []
VERTEX_API_KEYS: List[str] = []
VERTEX_EXPRESS_BASE_URL: str = "https://aiplatform.googleapis.com/v1beta1/publishers/google"
# 模型相关配置 # 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"] SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"] IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
@@ -68,8 +70,8 @@ class Settings(BaseSettings):
TOOLS_CODE_EXECUTION_ENABLED: bool = False TOOLS_CODE_EXECUTION_ENABLED: bool = False
SHOW_SEARCH_LINK: bool = True SHOW_SEARCH_LINK: bool = True
SHOW_THINKING_PROCESS: bool = True SHOW_THINKING_PROCESS: bool = True
THINKING_MODELS: List[str] = [] # 新增:用于思考过程的模型列表 THINKING_MODELS: List[str] = []
THINKING_BUDGET_MAP: Dict[str, float] = {} # 新增:模型对应的预算映射 THINKING_BUDGET_MAP: Dict[str, float] = {}
# 图像生成相关配置 # 图像生成相关配置
PAID_KEY: str = "" PAID_KEY: str = ""
@@ -101,12 +103,12 @@ class Settings(BaseSettings):
GITHUB_REPO_NAME: str = "gemini-balance" GITHUB_REPO_NAME: str = "gemini-balance"
# 日志配置 # 日志配置
LOG_LEVEL: str = "INFO" # 默认日志级别 LOG_LEVEL: str = "INFO"
AUTO_DELETE_ERROR_LOGS_ENABLED: bool = True # 是否开启自动删除错误日志 AUTO_DELETE_ERROR_LOGS_ENABLED: bool = True
AUTO_DELETE_ERROR_LOGS_DAYS: int = 7 # 自动删除多少天前的错误日志 (1, 7, 30) AUTO_DELETE_ERROR_LOGS_DAYS: int = 7
AUTO_DELETE_REQUEST_LOGS_ENABLED: bool = False # 是否开启自动删除请求日志 AUTO_DELETE_REQUEST_LOGS_ENABLED: bool = False
AUTO_DELETE_REQUEST_LOGS_DAYS: int = 30 # 自动删除多少天前的请求日志 (1, 7, 30) AUTO_DELETE_REQUEST_LOGS_DAYS: int = 30
SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS # 新增:安全设置 SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -141,7 +143,6 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
elif target_type == Dict[str, float]: elif target_type == Dict[str, float]:
parsed_dict = {} parsed_dict = {}
try: try:
# First attempt: standard JSON parsing
parsed = json.loads(db_value) parsed = json.loads(db_value)
if isinstance(parsed, dict): if isinstance(parsed, dict):
parsed_dict = {str(k): float(v) for k, v in parsed.items()} parsed_dict = {str(k): float(v) for k, v in parsed.items()}
@@ -150,7 +151,6 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}" f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}"
) )
except (json.JSONDecodeError, ValueError, TypeError) as e1: except (json.JSONDecodeError, ValueError, TypeError) as e1:
# Second attempt: try replacing single quotes if JSONDecodeError occurred
if isinstance(e1, json.JSONDecodeError) and "'" in db_value: if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
logger.warning( logger.warning(
f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}" f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}"
@@ -169,11 +169,10 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict." f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict."
) )
else: else:
# Log other errors (ValueError, TypeError) or JSON errors without single quotes
logger.error( logger.error(
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict." f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict."
) )
return parsed_dict # Return the parsed dict or an empty one if all attempts fail return parsed_dict
# 处理 List[Dict[str, str]] # 处理 List[Dict[str, str]]
elif target_type == List[Dict[str, str]]: elif target_type == List[Dict[str, str]]:
try: try:
@@ -192,7 +191,7 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
logger.warning( logger.warning(
f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}" f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}"
) )
return [] # 或者返回默认值?这里返回空列表 return []
else: else:
logger.warning( logger.warning(
f"Parsed DB value for key '{key}' is not a list type. Value: {db_value}" f"Parsed DB value for key '{key}' is not a list type. Value: {db_value}"
@@ -374,7 +373,7 @@ async def sync_initial_settings():
data = { data = {
"key": key, "key": key,
"value": db_value, "value": db_value,
"description": f"{key} configuration setting", # 默认描述 "description": f"{key} configuration setting",
"updated_at": now, "updated_at": now,
} }
+2 -2
View File
@@ -15,7 +15,7 @@ from app.router.routes import setup_routers
from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler
from app.service.key.key_manager import get_key_manager_instance from app.service.key.key_manager import get_key_manager_instance
from app.service.update.update_service import check_for_updates from app.service.update.update_service import check_for_updates
from app.utils.helpers import get_current_version # Import from helpers from app.utils.helpers import get_current_version
logger = get_application_logger() logger = get_application_logger()
@@ -43,7 +43,7 @@ async def _setup_database_and_config(app_settings):
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
await connect_to_db() await connect_to_db()
await sync_initial_settings() await sync_initial_settings()
await get_key_manager_instance(app_settings.API_KEYS) await get_key_manager_instance(app_settings.API_KEYS, app_settings.VERTEX_API_KEYS)
logger.info("Database, config sync, and KeyManager initialized successfully") logger.info("Database, config sync, and KeyManager initialized successfully")
+1 -4
View File
@@ -45,11 +45,8 @@ Base = declarative_base(metadata=metadata)
if settings.DATABASE_TYPE == "sqlite": if settings.DATABASE_TYPE == "sqlite":
database = Database(DATABASE_URL) database = Database(DATABASE_URL)
else: else:
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800)
# 移除了 SessionLocal 和 get_db 函数
# --- Async connection functions for lifespan/async routes ---
async def connect_to_db(): async def connect_to_db():
""" """
连接到数据库 连接到数据库
+2 -2
View File
@@ -2,7 +2,7 @@
数据库模型模块 数据库模型模块
""" """
import datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean # 添加 Boolean from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean
from app.database.connection import Base from app.database.connection import Base
@@ -53,7 +53,7 @@ class RequestLog(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间") request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间")
model_name = Column(String(100), nullable=True, comment="模型名称") model_name = Column(String(100), nullable=True, comment="模型名称")
api_key = Column(String(100), nullable=True, comment="使用的API密钥") # 考虑安全性,后续可优化 api_key = Column(String(100), nullable=True, comment="使用的API密钥")
is_success = Column(Boolean, nullable=False, comment="请求是否成功") is_success = Column(Boolean, nullable=False, comment="请求是否成功")
status_code = Column(Integer, nullable=True, comment="API响应状态码") status_code = Column(Integer, nullable=True, comment="API响应状态码")
latency_ms = Column(Integer, nullable=True, comment="请求耗时(毫秒)") latency_ms = Column(Integer, nullable=True, comment="请求耗时(毫秒)")
+3 -7
View File
@@ -189,7 +189,6 @@ async def get_error_logs(
ErrorLog.request_time ErrorLog.request_time
) )
# Apply filters
if key_search: if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%")) query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search: if error_search:
@@ -219,14 +218,14 @@ async def get_error_logs(
result = await database.fetch_all(query) result = await database.fetch_all(query)
return [dict(row) for row in result] return [dict(row) for row in result]
except Exception as e: except Exception as e:
logger.exception(f"Failed to get error logs with filters: {str(e)}") # Use exception for stack trace logger.exception(f"Failed to get error logs with filters: {str(e)}")
raise raise
async def get_error_logs_count( async def get_error_logs_count(
key_search: Optional[str] = None, key_search: Optional[str] = None,
error_search: Optional[str] = None, error_search: Optional[str] = None,
error_code_search: Optional[str] = None, # Added error code search error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None, start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None end_date: Optional[datetime] = None
) -> int: ) -> int:
@@ -294,7 +293,7 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
try: try:
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2) log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
except TypeError: except TypeError:
log_dict['request_msg'] = str(log_dict['request_msg']) # Fallback to string log_dict['request_msg'] = str(log_dict['request_msg'])
return log_dict return log_dict
else: else:
return None return None
@@ -372,7 +371,6 @@ async def delete_all_error_logs() -> int:
try: try:
# 1. 获取删除前的总数 # 1. 获取删除前的总数
count_query = select(func.count()).select_from(ErrorLog) count_query = select(func.count()).select_from(ErrorLog)
# fetch_val() is suitable here as we expect a single scalar value
total_to_delete = await database.fetch_val(count_query) total_to_delete = await database.fetch_val(count_query)
if total_to_delete == 0: if total_to_delete == 0:
@@ -380,7 +378,6 @@ async def delete_all_error_logs() -> int:
return 0 return 0
# 2. 执行删除操作 # 2. 执行删除操作
# This creates a query like "DELETE FROM error_log"
delete_query = delete(ErrorLog) delete_query = delete(ErrorLog)
await database.execute(delete_query) await database.execute(delete_query)
@@ -388,7 +385,6 @@ async def delete_all_error_logs() -> int:
return total_to_delete return total_to_delete
except Exception as e: except Exception as e:
logger.error(f"Failed to delete all error logs: {str(e)}", exc_info=True) logger.error(f"Failed to delete all error logs: {str(e)}", exc_info=True)
# Re-raise the exception so it can be caught by the service layer or route handler
raise raise
+5
View File
@@ -226,3 +226,8 @@ def get_error_log_logger():
def get_request_log_logger(): def get_request_log_logger():
return Logger.setup_logger("request_log") return Logger.setup_logger("request_log")
def get_vertex_express_logger():
return Logger.setup_logger("vertex_express")
+6 -5
View File
@@ -32,6 +32,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
and not request.url.path.startswith("/hf") and not request.url.path.startswith("/hf")
and not request.url.path.startswith("/openai") and not request.url.path.startswith("/openai")
and not request.url.path.startswith("/api/version/check") and not request.url.path.startswith("/api/version/check")
and not request.url.path.startswith("/vertex-express")
): ):
auth_token = request.cookies.get("auth_token") auth_token = request.cookies.get("auth_token")
@@ -60,7 +61,7 @@ def setup_middlewares(app: FastAPI) -> None:
# 配置CORS中间件 # 配置CORS中间件
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # 生产环境建议配置具体的域名 allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=[ allow_methods=[
"GET", "GET",
@@ -68,8 +69,8 @@ def setup_middlewares(app: FastAPI) -> None:
"PUT", "PUT",
"DELETE", "DELETE",
"OPTIONS", "OPTIONS",
], # 明确指定允许的HTTP方法 ],
allow_headers=["*"], # 生产环境建议配置具体的请求头 allow_headers=["*"],
expose_headers=["*"], # 允许前端访问的响应头 expose_headers=["*"],
max_age=600, # 预检请求缓存时间(秒) max_age=600,
) )
+2 -1
View File
@@ -8,7 +8,7 @@ from fastapi.templating import Jinja2Templates
from app.core.security import verify_auth_token from app.core.security import verify_auth_token
from app.log.logger import get_routes_logger from app.log.logger import get_routes_logger
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes
from app.service.key.key_manager import get_key_manager_instance from app.service.key.key_manager import get_key_manager_instance
from app.service.stats.stats_service import StatsService from app.service.stats.stats_service import StatsService
@@ -33,6 +33,7 @@ def setup_routers(app: FastAPI) -> None:
app.include_router(stats_routes.router) app.include_router(stats_routes.router)
app.include_router(version_routes.router) app.include_router(version_routes.router)
app.include_router(openai_compatiable_routes.router) app.include_router(openai_compatiable_routes.router)
app.include_router(vertex_express_routes.router)
setup_page_routes(app) setup_page_routes(app)
+146
View File
@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from copy import deepcopy
from app.config.config import settings
from app.log.logger import get_vertex_express_logger
from app.core.security import SecurityService
from app.domain.gemini_models import GeminiRequest
from app.service.chat.vertex_express_chat_service import GeminiChatService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.core.constants import API_VERSION
router = APIRouter(prefix=f"/vertex-express/{API_VERSION}")
logger = get_vertex_express_logger()
security_service = SecurityService()
model_service = ModelService()
async def get_key_manager():
"""获取密钥管理器实例"""
return await get_key_manager_instance()
async def get_next_working_key(key_manager: KeyManager = Depends(get_key_manager)):
"""获取下一个可用的API密钥"""
return await key_manager.get_next_working_vertex_key()
async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
"""获取Gemini聊天服务实例"""
return GeminiChatService(settings.VERTEX_EXPRESS_BASE_URL, key_manager)
@router.get("/models")
async def list_models(
_=Depends(security_service.verify_key_or_goog_api_key),
key_manager: KeyManager = Depends(get_key_manager)
):
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
operation_name = "list_gemini_models"
logger.info("-" * 50 + operation_name + "-" * 50)
logger.info("Handling Gemini models list request")
try:
api_key = await key_manager.get_first_valid_key()
if not api_key:
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
logger.info(f"Using API key: {api_key}")
models_data = await model_service.get_gemini_models(api_key)
if not models_data or "models" not in models_data:
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
models_json = deepcopy(models_data)
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
def add_derived_model(base_name, suffix, display_suffix):
model = model_mapping.get(base_name)
if not model:
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
return
item = deepcopy(model)
item["name"] = f"models/{base_name}{suffix}"
display_name = f'{item.get("displayName", base_name)}{display_suffix}'
item["displayName"] = display_name
item["description"] = display_name
models_json["models"].append(item)
if settings.SEARCH_MODELS:
for name in settings.SEARCH_MODELS:
add_derived_model(name, "-search", " For Search")
if settings.IMAGE_MODELS:
for name in settings.IMAGE_MODELS:
add_derived_model(name, "-image", " For Image")
if settings.THINKING_MODELS:
for name in settings.THINKING_MODELS:
add_derived_model(name, "-non-thinking", " Non Thinking")
logger.info("Gemini models list request successful")
return models_json
except HTTPException as http_exc:
raise http_exc
except Exception as e:
logger.error(f"Error getting Gemini models list: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error while fetching Gemini models list"
) from e
@router.post("/models/{model_name}:generateContent")
@RetryHandler(key_arg="api_key")
async def generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""处理 Gemini 非流式内容生成请求。"""
operation_name = "gemini_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
logger.info(f"Handling Gemini content generation request for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not await model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
response = await chat_service.generate_content(
model=model_name,
request=request,
api_key=api_key
)
return response
@router.post("/models/{model_name}:streamGenerateContent")
@RetryHandler(key_arg="api_key")
async def stream_generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""处理 Gemini 流式内容生成请求。"""
operation_name = "gemini_stream_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not await model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
response_stream = chat_service.stream_generate_content(
model=model_name,
request=request,
api_key=api_key
)
return StreamingResponse(response_stream, media_type="text/event-stream")
+2 -2
View File
@@ -131,7 +131,7 @@ class GeminiChatService:
self, original_response: Dict[str, Any], text: str self, original_response: Dict[str, Any], text: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""创建包含指定文本的响应""" """创建包含指定文本的响应"""
response_copy = json.loads(json.dumps(original_response)) # 深拷贝 response_copy = json.loads(json.dumps(original_response))
if response_copy.get("candidates") and response_copy["candidates"][0].get( if response_copy.get("candidates") and response_copy["candidates"][0].get(
"content", {} "content", {}
).get("parts"): ).get("parts"):
@@ -200,7 +200,7 @@ class GeminiChatService:
request_datetime = datetime.datetime.now() request_datetime = datetime.datetime.now()
start_time = time.perf_counter() start_time = time.perf_counter()
current_attempt_key = api_key current_attempt_key = api_key
final_api_key = current_attempt_key # Update final key used final_api_key = current_attempt_key
try: try:
async for line in self.api_client.stream_generate_content( async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key payload, model, current_attempt_key
+1 -1
View File
@@ -51,7 +51,7 @@ def _build_tools(
or model.endswith("-image") or model.endswith("-image")
or model.endswith("-image-generation") or model.endswith("-image-generation")
) )
and not _has_media_parts(messages) # Use the updated check and not _has_media_parts(messages)
): ):
tool["codeExecution"] = {} tool["codeExecution"] = {}
logger.debug("Code execution tool enabled.") logger.debug("Code execution tool enabled.")
@@ -0,0 +1,277 @@
# app/services/chat_service.py
import json
import re
import datetime
import time
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.domain.gemini_models import GeminiRequest
from app.handler.response_handler import GeminiResponseHandler
from app.handler.stream_optimizer import gemini_optimizer
from app.log.logger import get_gemini_logger
from app.service.client.api_client import GeminiApiClient
from app.service.key.key_manager import KeyManager
from app.database.services import add_error_log, add_request_log
logger = get_gemini_logger()
def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片部分"""
for content in contents:
if "parts" in content:
for part in content["parts"]:
if "image_url" in part or "inline_data" in part:
return True
return False
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
record = dict()
for item in tools:
if not item or not isinstance(item, dict):
continue
for k, v in item.items():
if k == "functionDeclarations" and v and isinstance(v, list):
functions = record.get("functionDeclarations", [])
functions.extend(v)
record["functionDeclarations"] = functions
else:
record[k] = v
return record
tool = dict()
if payload and isinstance(payload, dict) and "tools" in payload:
if payload.get("tools") and isinstance(payload.get("tools"), dict):
payload["tools"] = [payload.get("tools")]
items = payload.get("tools", [])
if items and isinstance(items, list):
tool.update(_merge_tools(items))
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (model.endswith("-search") or "-thinking" in model)
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
# 解决 "Tool use with function calling is unsupported" 问题
if tool.get("functionDeclarations"):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
return [tool] if tool else []
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
"""获取安全设置"""
if model == "gemini-2.0-flash-exp":
return GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return settings.SAFETY_SETTINGS
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
"""构建请求payload"""
request_dict = request.model_dump()
if request.generationConfig:
if request.generationConfig.maxOutputTokens is None:
# 如果未指定最大输出长度,则不传递该字段,解决截断的问题
request_dict["generationConfig"].pop("maxOutputTokens")
payload = {
"contents": request_dict.get("contents", []),
"tools": _build_tools(model, request_dict),
"safetySettings": _get_safety_settings(model),
"generationConfig": request_dict.get("generationConfig"),
"systemInstruction": request_dict.get("systemInstruction"),
}
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
if model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
return payload
class GeminiChatService:
"""聊天服务"""
def __init__(self, base_url: str, key_manager: KeyManager):
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
self.key_manager = key_manager
self.response_handler = GeminiResponseHandler()
def _extract_text_from_response(self, response: Dict[str, Any]) -> str:
"""从响应中提取文本内容"""
if not response.get("candidates"):
return ""
candidate = response["candidates"][0]
content = candidate.get("content", {})
parts = content.get("parts", [])
if parts and "text" in parts[0]:
return parts[0].get("text", "")
return ""
def _create_char_response(
self, original_response: Dict[str, Any], text: str
) -> Dict[str, Any]:
"""创建包含指定文本的响应"""
response_copy = json.loads(json.dumps(original_response)) # 深拷贝
if response_copy.get("candidates") and response_copy["candidates"][0].get(
"content", {}
).get("parts"):
response_copy["candidates"][0]["content"]["parts"][0]["text"] = text
return response_copy
async def generate_content(
self, model: str, request: GeminiRequest, api_key: str
) -> Dict[str, Any]:
"""生成内容"""
payload = _build_payload(model, request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
try:
response = await self.api_client.generate_content(payload, model, api_key)
is_success = True
status_code = 200
return self.response_handler.handle_response(response, model, stream=False)
except Exception as e:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="gemini-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)
async def stream_generate_content(
self, model: str, request: GeminiRequest, api_key: str
) -> AsyncGenerator[str, None]:
"""流式生成内容"""
retries = 0
max_retries = settings.MAX_RETRIES
payload = _build_payload(model, request)
is_success = False
status_code = None
final_api_key = api_key
while retries < max_retries:
request_datetime = datetime.datetime.now()
start_time = time.perf_counter()
current_attempt_key = api_key
final_api_key = current_attempt_key # Update final key used
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
line = line[6:]
response_data = self.response_handler.handle_response(
json.loads(line), model, stream=True
)
text = self._extract_text_from_response(response_data)
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in gemini_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_response(response_data, t),
lambda c: "data: " + json.dumps(c) + "\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
yield "data: " + json.dumps(response_data) + "\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200
break
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else:
logger.error(f"No valid API key available after {retries} retries.")
break
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)
+6 -6
View File
@@ -55,7 +55,7 @@ class ConfigService:
# 处理不同类型的值 # 处理不同类型的值
if isinstance(value, list): if isinstance(value, list):
db_value = json.dumps(value) db_value = json.dumps(value)
elif isinstance(value, dict): # 新增对 dict 类型的处理 elif isinstance(value, dict):
db_value = json.dumps(value) db_value = json.dumps(value)
elif isinstance(value, bool): elif isinstance(value, bool):
db_value = str(value).lower() db_value = str(value).lower()
@@ -115,7 +115,7 @@ class ConfigService:
# 重置并重新初始化 KeyManager # 重置并重新初始化 KeyManager
try: try:
await reset_key_manager_instance() await reset_key_manager_instance()
await get_key_manager_instance(settings.API_KEYS) await get_key_manager_instance(settings.API_KEYS, settings.VERTEX_API_KEYS)
logger.info("KeyManager instance re-initialized with updated settings.") logger.info("KeyManager instance re-initialized with updated settings.")
except Exception as e: except Exception as e:
logger.error(f"Failed to re-initialize KeyManager: {str(e)}") logger.error(f"Failed to re-initialize KeyManager: {str(e)}")
@@ -154,7 +154,7 @@ class ConfigService:
deleted_count = 0 deleted_count = 0
not_found_keys: List[str] = [] not_found_keys: List[str] = []
current_api_keys = list(settings.API_KEYS) # 创建副本以进行修改 current_api_keys = list(settings.API_KEYS)
keys_actually_removed: List[str] = [] keys_actually_removed: List[str] = []
for key_to_del in keys_to_delete: for key_to_del in keys_to_delete:
@@ -166,7 +166,7 @@ class ConfigService:
not_found_keys.append(key_to_del) not_found_keys.append(key_to_del)
if deleted_count > 0: if deleted_count > 0:
settings.API_KEYS = current_api_keys # 更新内存中的 settings settings.API_KEYS = current_api_keys
await ConfigService.update_config({"API_KEYS": settings.API_KEYS}) await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
logger.info( logger.info(
f"成功删除 {deleted_count} 个密钥。密钥: {keys_actually_removed}" f"成功删除 {deleted_count} 个密钥。密钥: {keys_actually_removed}"
@@ -182,9 +182,9 @@ class ConfigService:
} }
else: else:
message = "没有密钥被删除。" message = "没有密钥被删除。"
if not_found_keys: # 如果提供了密钥但都未找到 if not_found_keys:
message = f"所有 {len(not_found_keys)} 个指定的密钥均未找到: {not_found_keys}" message = f"所有 {len(not_found_keys)} 个指定的密钥均未找到: {not_found_keys}"
elif not keys_to_delete: # 如果 keys_to_delete 列表为空 elif not keys_to_delete:
message = "未指定要删除的密钥。" message = "未指定要删除的密钥。"
logger.warning(message) logger.warning(message)
return { return {
+3 -4
View File
@@ -161,10 +161,9 @@ async def process_delete_all_error_logs() -> int:
返回删除的日志数量。 返回删除的日志数量。
""" """
try: try:
# 确保数据库已连接 (如果适用,类似于 delete_old_error_logs) if not database.is_connected:
# if not database.is_connected: await database.connect()
# await database.connect() logger.info("Database connection established for deleting all error logs.")
# logger.info("Database connection established for deleting all error logs.")
deleted_count = await db_services.delete_all_error_logs() deleted_count = await db_services.delete_all_error_logs()
logger.info( logger.info(
+203 -15
View File
@@ -9,12 +9,19 @@ logger = get_key_manager_logger()
class KeyManager: class KeyManager:
def __init__(self, api_keys: list): def __init__(self, api_keys: list, vertex_api_keys: list):
self.api_keys = api_keys self.api_keys = api_keys
self.vertex_api_keys = vertex_api_keys
self.key_cycle = cycle(api_keys) self.key_cycle = cycle(api_keys)
self.vertex_key_cycle = cycle(vertex_api_keys)
self.key_cycle_lock = asyncio.Lock() self.key_cycle_lock = asyncio.Lock()
self.vertex_key_cycle_lock = asyncio.Lock()
self.failure_count_lock = asyncio.Lock() self.failure_count_lock = asyncio.Lock()
self.vertex_failure_count_lock = asyncio.Lock()
self.key_failure_counts: Dict[str, int] = {key: 0 for key in api_keys} self.key_failure_counts: Dict[str, int] = {key: 0 for key in api_keys}
self.vertex_key_failure_counts: Dict[str, int] = {
key: 0 for key in vertex_api_keys
}
self.MAX_FAILURES = settings.MAX_FAILURES self.MAX_FAILURES = settings.MAX_FAILURES
self.paid_key = settings.PAID_KEY self.paid_key = settings.PAID_KEY
@@ -26,17 +33,33 @@ class KeyManager:
async with self.key_cycle_lock: async with self.key_cycle_lock:
return next(self.key_cycle) return next(self.key_cycle)
async def get_next_vertex_key(self) -> str:
"""获取下一个 Vertex API key"""
async with self.vertex_key_cycle_lock:
return next(self.vertex_key_cycle)
async def is_key_valid(self, key: str) -> bool: async def is_key_valid(self, key: str) -> bool:
"""检查key是否有效""" """检查key是否有效"""
async with self.failure_count_lock: async with self.failure_count_lock:
return self.key_failure_counts[key] < self.MAX_FAILURES return self.key_failure_counts[key] < self.MAX_FAILURES
async def is_vertex_key_valid(self, key: str) -> bool:
"""检查 Vertex key 是否有效"""
async with self.vertex_failure_count_lock:
return self.vertex_key_failure_counts[key] < self.MAX_FAILURES
async def reset_failure_counts(self): async def reset_failure_counts(self):
"""重置所有key的失败计数""" """重置所有key的失败计数"""
async with self.failure_count_lock: async with self.failure_count_lock:
for key in self.key_failure_counts: for key in self.key_failure_counts:
self.key_failure_counts[key] = 0 self.key_failure_counts[key] = 0
async def reset_vertex_failure_counts(self):
"""重置所有 Vertex key 的失败计数"""
async with self.vertex_failure_count_lock:
for key in self.vertex_key_failure_counts:
self.vertex_key_failure_counts[key] = 0
async def reset_key_failure_count(self, key: str) -> bool: async def reset_key_failure_count(self, key: str) -> bool:
"""重置指定key的失败计数""" """重置指定key的失败计数"""
async with self.failure_count_lock: async with self.failure_count_lock:
@@ -49,6 +72,18 @@ class KeyManager:
) )
return False return False
async def reset_vertex_key_failure_count(self, key: str) -> bool:
"""重置指定 Vertex key 的失败计数"""
async with self.vertex_failure_count_lock:
if key in self.vertex_key_failure_counts:
self.vertex_key_failure_counts[key] = 0
logger.info(f"Reset failure count for Vertex key: {key}")
return True
logger.warning(
f"Attempt to reset failure count for non-existent Vertex key: {key}"
)
return False
async def get_next_working_key(self) -> str: async def get_next_working_key(self) -> str:
"""获取下一可用的API key""" """获取下一可用的API key"""
initial_key = await self.get_next_key() initial_key = await self.get_next_key()
@@ -62,6 +97,19 @@ class KeyManager:
if current_key == initial_key: if current_key == initial_key:
return current_key return current_key
async def get_next_working_vertex_key(self) -> str:
"""获取下一可用的 Vertex API key"""
initial_key = await self.get_next_vertex_key()
current_key = initial_key
while True:
if await self.is_vertex_key_valid(current_key):
return current_key
current_key = await self.get_next_vertex_key()
if current_key == initial_key:
return current_key
async def handle_api_failure(self, api_key: str, retries: int) -> str: async def handle_api_failure(self, api_key: str, retries: int) -> str:
"""处理API调用失败""" """处理API调用失败"""
async with self.failure_count_lock: async with self.failure_count_lock:
@@ -75,10 +123,23 @@ class KeyManager:
else: else:
return "" return ""
async def handle_vertex_api_failure(self, api_key: str, retries: int) -> str:
"""处理 Vertex API 调用失败"""
async with self.vertex_failure_count_lock:
self.vertex_key_failure_counts[api_key] += 1
if self.vertex_key_failure_counts[api_key] >= self.MAX_FAILURES:
logger.warning(
f"Vertex API key {api_key} has failed {self.MAX_FAILURES} times"
)
def get_fail_count(self, key: str) -> int: def get_fail_count(self, key: str) -> int:
"""获取指定密钥的失败次数""" """获取指定密钥的失败次数"""
return self.key_failure_counts.get(key, 0) return self.key_failure_counts.get(key, 0)
def get_vertex_fail_count(self, key: str) -> int:
"""获取指定 Vertex 密钥的失败次数"""
return self.vertex_key_failure_counts.get(key, 0)
async def get_keys_by_status(self) -> dict: async def get_keys_by_status(self) -> dict:
"""获取分类后的API key列表,包括失败次数""" """获取分类后的API key列表,包括失败次数"""
valid_keys = {} valid_keys = {}
@@ -94,6 +155,20 @@ class KeyManager:
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys} return {"valid_keys": valid_keys, "invalid_keys": invalid_keys}
async def get_vertex_keys_by_status(self) -> dict:
"""获取分类后的 Vertex API key 列表,包括失败次数"""
valid_keys = {}
invalid_keys = {}
async with self.vertex_failure_count_lock:
for key in self.vertex_api_keys:
fail_count = self.vertex_key_failure_counts[key]
if fail_count < self.MAX_FAILURES:
valid_keys[key] = fail_count
else:
invalid_keys[key] = fail_count
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys}
async def get_first_valid_key(self) -> str: async def get_first_valid_key(self) -> str:
"""获取第一个有效的API key""" """获取第一个有效的API key"""
async with self.failure_count_lock: async with self.failure_count_lock:
@@ -111,19 +186,24 @@ class KeyManager:
_singleton_instance = None _singleton_instance = None
_singleton_lock = asyncio.Lock() _singleton_lock = asyncio.Lock()
_preserved_failure_counts: Dict[str, int] | None = None _preserved_failure_counts: Dict[str, int] | None = None
_preserved_vertex_failure_counts: Dict[str, int] | None = None
_preserved_old_api_keys_for_reset: list | None = None _preserved_old_api_keys_for_reset: list | None = None
_preserved_vertex_old_api_keys_for_reset: list | None = None
_preserved_next_key_in_cycle: str | None = None _preserved_next_key_in_cycle: str | None = None
_preserved_vertex_next_key_in_cycle: str | None = None
async def get_key_manager_instance(api_keys: list = None) -> KeyManager: async def get_key_manager_instance(
api_keys: list = None, vertex_api_keys: list = None
) -> KeyManager:
""" """
获取 KeyManager 单例实例。 获取 KeyManager 单例实例。
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。 如果尚未创建实例,将使用提供的 api_keys,vertex_api_keys 初始化 KeyManager。
如果已创建实例,则忽略 api_keys 参数,返回现有单例。 如果已创建实例,则忽略 api_keys 参数,返回现有单例。
如果在重置后调用,会尝试恢复之前的状态(失败计数、循环位置)。 如果在重置后调用,会尝试恢复之前的状态(失败计数、循环位置)。
""" """
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle global _singleton_instance, _preserved_failure_counts, _preserved_vertex_failure_counts, _preserved_old_api_keys_for_reset, _preserved_vertex_old_api_keys_for_reset, _preserved_next_key_in_cycle, _preserved_vertex_next_key_in_cycle
async with _singleton_lock: async with _singleton_lock:
if _singleton_instance is None: if _singleton_instance is None:
@@ -131,14 +211,23 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
raise ValueError( raise ValueError(
"API keys are required to initialize or re-initialize the KeyManager instance." "API keys are required to initialize or re-initialize the KeyManager instance."
) )
if vertex_api_keys is None:
raise ValueError(
"Vertex API keys are required to initialize or re-initialize the KeyManager instance."
)
if not api_keys: if not api_keys:
logger.warning( logger.warning(
"Initializing KeyManager with an empty list of API keys." "Initializing KeyManager with an empty list of API keys."
) )
if not vertex_api_keys:
logger.warning(
"Initializing KeyManager with an empty list of Vertex API keys."
)
_singleton_instance = KeyManager(api_keys) _singleton_instance = KeyManager(api_keys, vertex_api_keys)
logger.info( logger.info(
f"KeyManager instance created/re-created with {len(api_keys)} API keys." f"KeyManager instance created/re-created with {len(api_keys)} API keys and {len(vertex_api_keys)} Vertex API keys."
) )
# 1. 恢复失败计数 # 1. 恢复失败计数
@@ -153,6 +242,19 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
logger.info("Inherited failure counts for applicable keys.") logger.info("Inherited failure counts for applicable keys.")
_preserved_failure_counts = None _preserved_failure_counts = None
if _preserved_vertex_failure_counts:
current_vertex_failure_counts = {
key: 0 for key in _singleton_instance.vertex_api_keys
}
for key, count in _preserved_vertex_failure_counts.items():
if key in current_vertex_failure_counts:
current_vertex_failure_counts[key] = count
_singleton_instance.vertex_key_failure_counts = (
current_vertex_failure_counts
)
logger.info("Inherited failure counts for applicable Vertex keys.")
_preserved_vertex_failure_counts = None
# 2. 调整 key_cycle 的起始点 # 2. 调整 key_cycle 的起始点
start_key_for_new_cycle = None start_key_for_new_cycle = None
if ( if (
@@ -201,9 +303,7 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. " f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. "
"New cycle will start from the beginning." "New cycle will start from the beginning."
) )
except ( except StopIteration:
StopIteration
):
logger.error( logger.error(
"StopIteration while advancing key cycle, implies empty new API key list previously missed." "StopIteration while advancing key cycle, implies empty new API key list previously missed."
) )
@@ -225,6 +325,76 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
_preserved_old_api_keys_for_reset = None _preserved_old_api_keys_for_reset = None
_preserved_next_key_in_cycle = None _preserved_next_key_in_cycle = None
# 3. 调整 vertex_key_cycle 的起始点
start_key_for_new_vertex_cycle = None
if (
_preserved_vertex_old_api_keys_for_reset
and _preserved_vertex_next_key_in_cycle
and _singleton_instance.vertex_api_keys
):
try:
start_idx_in_old = _preserved_vertex_old_api_keys_for_reset.index(
_preserved_vertex_next_key_in_cycle
)
for i in range(len(_preserved_vertex_old_api_keys_for_reset)):
current_old_key_idx = (start_idx_in_old + i) % len(
_preserved_vertex_old_api_keys_for_reset
)
key_candidate = _preserved_vertex_old_api_keys_for_reset[
current_old_key_idx
]
if key_candidate in _singleton_instance.vertex_api_keys:
start_key_for_new_vertex_cycle = key_candidate
break
except ValueError:
logger.warning(
f"Preserved next key '{_preserved_vertex_next_key_in_cycle}' not found in preserved old Vertex API keys. "
"New cycle will start from the beginning of the new list."
)
except Exception as e:
logger.error(
f"Error determining start key for new Vertex key cycle from preserved state: {e}. "
"New cycle will start from the beginning."
)
if start_key_for_new_vertex_cycle and _singleton_instance.vertex_api_keys:
try:
target_idx = _singleton_instance.vertex_api_keys.index(
start_key_for_new_vertex_cycle
)
for _ in range(target_idx):
next(_singleton_instance.vertex_key_cycle)
logger.info(
f"Vertex key cycle in new instance advanced. Next call to get_next_vertex_key() will yield: {start_key_for_new_vertex_cycle}"
)
except ValueError:
logger.warning(
f"Determined start key '{start_key_for_new_vertex_cycle}' not found in new Vertex API keys during cycle advancement. "
"New cycle will start from the beginning."
)
except StopIteration:
logger.error(
"StopIteration while advancing Vertex key cycle, implies empty new Vertex API key list previously missed."
)
except Exception as e:
logger.error(
f"Error advancing new Vertex key cycle: {e}. Cycle will start from beginning."
)
else:
if _singleton_instance.vertex_api_keys:
logger.info(
"New Vertex key cycle will start from the beginning of the new Vertex API key list (no specific start key determined or needed)."
)
else:
logger.info(
"New Vertex key cycle not applicable as the new Vertex API key list is empty."
)
# 清理所有保存的状态
_preserved_vertex_old_api_keys_for_reset = None
_preserved_vertex_next_key_in_cycle = None
return _singleton_instance return _singleton_instance
@@ -234,34 +404,52 @@ async def reset_key_manager_instance():
将保存当前实例的状态(失败计数、旧 API keys、下一个 key 提示) 将保存当前实例的状态(失败计数、旧 API keys、下一个 key 提示)
以供下一次 get_key_manager_instance 调用时恢复。 以供下一次 get_key_manager_instance 调用时恢复。
""" """
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle global _singleton_instance, _preserved_failure_counts, _preserved_vertex_failure_counts, _preserved_old_api_keys_for_reset, _preserved_vertex_old_api_keys_for_reset, _preserved_next_key_in_cycle, _preserved_vertex_next_key_in_cycle
async with _singleton_lock: async with _singleton_lock:
if _singleton_instance: if _singleton_instance:
# 1. 保存失败计数 # 1. 保存失败计数
_preserved_failure_counts = _singleton_instance.key_failure_counts.copy() _preserved_failure_counts = _singleton_instance.key_failure_counts.copy()
_preserved_vertex_failure_counts = _singleton_instance.vertex_key_failure_counts.copy()
# 2. 保存旧的 API keys 列表 # 2. 保存旧的 API keys 列表
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy() _preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
_preserved_vertex_old_api_keys_for_reset = _singleton_instance.vertex_api_keys.copy()
# 3. 保存 key_cycle 的下一个 key 提示 # 3. 保存 key_cycle 的下一个 key 提示
try: try:
if _singleton_instance.api_keys: if _singleton_instance.api_keys:
_preserved_next_key_in_cycle = ( _preserved_next_key_in_cycle = (
await _singleton_instance.get_next_key() await _singleton_instance.get_next_key()
) )
else: else:
_preserved_next_key_in_cycle = None _preserved_next_key_in_cycle = None
except ( except StopIteration:
StopIteration
):
logger.warning( logger.warning(
"Could not preserve next key hint: key cycle was empty or exhausted in old instance." "Could not preserve next key hint: key cycle was empty or exhausted in old instance."
) )
_preserved_next_key_in_cycle = None _preserved_next_key_in_cycle = None
except Exception as e: except Exception as e:
logger.error(f"Error preserving next key hint during reset: {e}") logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_next_key_in_cycle = None _preserved_next_key_in_cycle = None
# 4. 保存 vertex_key_cycle 的下一个 key 提示
try:
if _singleton_instance.vertex_api_keys:
_preserved_vertex_next_key_in_cycle = (
await _singleton_instance.get_next_vertex_key()
)
else:
_preserved_vertex_next_key_in_cycle = None
except StopIteration:
logger.warning(
"Could not preserve next key hint: Vertex key cycle was empty or exhausted in old instance."
)
_preserved_vertex_next_key_in_cycle = None
except Exception as e:
logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_vertex_next_key_in_cycle = None
_singleton_instance = None _singleton_instance = None
logger.info( logger.info(
"KeyManager instance has been reset. State (failure counts, old keys, next key hint) preserved for next instantiation." "KeyManager instance has been reset. State (failure counts, old keys, next key hint) preserved for next instantiation."
@@ -131,7 +131,7 @@ class OpenAICompatiableService:
logger.info("Streaming completed successfully") logger.info("Streaming completed successfully")
is_success = True is_success = True
status_code = 200 status_code = 200
break # 成功后退出循环 break
except Exception as e: except Exception as e:
retries += 1 retries += 1
is_success = False is_success = False
+217 -9
View File
@@ -10,8 +10,9 @@ const SHOW_CLASS = "show"; // For modals
const API_KEY_REGEX = /AIzaSy\S{33}/g; const API_KEY_REGEX = /AIzaSy\S{33}/g;
const PROXY_REGEX = const PROXY_REGEX =
/(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g; /(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g;
const VERTEX_API_KEY_REGEX = /AQ\.[a-zA-Z0-9_]{50}/g; // 新增 Vertex API Key 正则
const MASKED_VALUE = "••••••••"; const MASKED_VALUE = "••••••••";
// DOM Elements - Global Scope for frequently accessed elements // DOM Elements - Global Scope for frequently accessed elements
const safetySettingsContainer = document.getElementById( const safetySettingsContainer = document.getElementById(
"SAFETY_SETTINGS_container" "SAFETY_SETTINGS_container"
@@ -30,7 +31,17 @@ const bulkDeleteProxyModal = document.getElementById("bulkDeleteProxyModal");
const bulkDeleteProxyInput = document.getElementById("bulkDeleteProxyInput"); const bulkDeleteProxyInput = document.getElementById("bulkDeleteProxyInput");
const resetConfirmModal = document.getElementById("resetConfirmModal"); const resetConfirmModal = document.getElementById("resetConfirmModal");
const configForm = document.getElementById("configForm"); // Added for frequent use const configForm = document.getElementById("configForm"); // Added for frequent use
// Vertex API Key Modal Elements
const vertexApiKeyModal = document.getElementById("vertexApiKeyModal");
const vertexApiKeyBulkInput = document.getElementById("vertexApiKeyBulkInput");
const bulkDeleteVertexApiKeyModal = document.getElementById(
"bulkDeleteVertexApiKeyModal"
);
const bulkDeleteVertexApiKeyInput = document.getElementById(
"bulkDeleteVertexApiKeyInput"
);
// Model Helper Modal Elements // Model Helper Modal Elements
const modelHelperModal = document.getElementById("modelHelperModal"); const modelHelperModal = document.getElementById("modelHelperModal");
const modelHelperTitleElement = document.getElementById("modelHelperTitle"); const modelHelperTitleElement = document.getElementById("modelHelperTitle");
@@ -244,6 +255,8 @@ document.addEventListener("DOMContentLoaded", function () {
bulkDeleteApiKeyModal, bulkDeleteApiKeyModal,
proxyModal, proxyModal,
bulkDeleteProxyModal, bulkDeleteProxyModal,
vertexApiKeyModal, // 新增
bulkDeleteVertexApiKeyModal, // 新增
modelHelperModal, modelHelperModal,
]; ];
modals.forEach((modal) => { modals.forEach((modal) => {
@@ -371,7 +384,71 @@ document.addEventListener("DOMContentLoaded", function () {
} }
initializeSensitiveFields(); // Initialize sensitive field handling initializeSensitiveFields(); // Initialize sensitive field handling
// Vertex API Key Modal Elements and Events
const addVertexApiKeyBtn = document.getElementById("addVertexApiKeyBtn");
const closeVertexApiKeyModalBtn = document.getElementById(
"closeVertexApiKeyModalBtn"
);
const cancelAddVertexApiKeyBtn = document.getElementById(
"cancelAddVertexApiKeyBtn"
);
const confirmAddVertexApiKeyBtn = document.getElementById(
"confirmAddVertexApiKeyBtn"
);
const bulkDeleteVertexApiKeyBtn = document.getElementById(
"bulkDeleteVertexApiKeyBtn"
);
const closeBulkDeleteVertexModalBtn = document.getElementById(
"closeBulkDeleteVertexModalBtn"
);
const cancelBulkDeleteVertexApiKeyBtn = document.getElementById(
"cancelBulkDeleteVertexApiKeyBtn"
);
const confirmBulkDeleteVertexApiKeyBtn = document.getElementById(
"confirmBulkDeleteVertexApiKeyBtn"
);
if (addVertexApiKeyBtn) {
addVertexApiKeyBtn.addEventListener("click", () => {
openModal(vertexApiKeyModal);
if (vertexApiKeyBulkInput) vertexApiKeyBulkInput.value = "";
});
}
if (closeVertexApiKeyModalBtn)
closeVertexApiKeyModalBtn.addEventListener("click", () =>
closeModal(vertexApiKeyModal)
);
if (cancelAddVertexApiKeyBtn)
cancelAddVertexApiKeyBtn.addEventListener("click", () =>
closeModal(vertexApiKeyModal)
);
if (confirmAddVertexApiKeyBtn)
confirmAddVertexApiKeyBtn.addEventListener(
"click",
handleBulkAddVertexApiKeys
);
if (bulkDeleteVertexApiKeyBtn) {
bulkDeleteVertexApiKeyBtn.addEventListener("click", () => {
openModal(bulkDeleteVertexApiKeyModal);
if (bulkDeleteVertexApiKeyInput) bulkDeleteVertexApiKeyInput.value = "";
});
}
if (closeBulkDeleteVertexModalBtn)
closeBulkDeleteVertexModalBtn.addEventListener("click", () =>
closeModal(bulkDeleteVertexApiKeyModal)
);
if (cancelBulkDeleteVertexApiKeyBtn)
cancelBulkDeleteVertexApiKeyBtn.addEventListener("click", () =>
closeModal(bulkDeleteVertexApiKeyModal)
);
if (confirmBulkDeleteVertexApiKeyBtn)
confirmBulkDeleteVertexApiKeyBtn.addEventListener(
"click",
handleBulkDeleteVertexApiKeys
);
// Model Helper Modal Event Listeners // Model Helper Modal Event Listeners
if (closeModelHelperModalBtn) { if (closeModelHelperModalBtn) {
closeModelHelperModalBtn.addEventListener("click", () => closeModelHelperModalBtn.addEventListener("click", () =>
@@ -591,6 +668,14 @@ async function initConfig() {
) { ) {
config.FILTERED_MODELS = ["gemini-1.0-pro-latest"]; config.FILTERED_MODELS = ["gemini-1.0-pro-latest"];
} }
// --- 新增:处理 VERTEX_API_KEYS 默认值 ---
if (!config.VERTEX_API_KEYS || !Array.isArray(config.VERTEX_API_KEYS)) {
config.VERTEX_API_KEYS = [];
}
// --- 新增:处理 VERTEX_EXPRESS_BASE_URL 默认值 ---
if (typeof config.VERTEX_EXPRESS_BASE_URL === "undefined") {
config.VERTEX_EXPRESS_BASE_URL = "";
}
// --- 新增:处理 PROXIES 默认值 --- // --- 新增:处理 PROXIES 默认值 ---
if (!config.PROXIES || !Array.isArray(config.PROXIES)) { if (!config.PROXIES || !Array.isArray(config.PROXIES)) {
config.PROXIES = []; // 默认为空数组 config.PROXIES = []; // 默认为空数组
@@ -666,10 +751,12 @@ async function initConfig() {
SEARCH_MODELS: ["gemini-1.5-flash-latest"], SEARCH_MODELS: ["gemini-1.5-flash-latest"],
FILTERED_MODELS: ["gemini-1.0-pro-latest"], FILTERED_MODELS: ["gemini-1.0-pro-latest"],
UPLOAD_PROVIDER: "smms", UPLOAD_PROVIDER: "smms",
PROXIES: [], // 添加默认值 PROXIES: [],
VERTEX_API_KEYS: [], // 确保默认值存在
VERTEX_EXPRESS_BASE_URL: "", // 确保默认值存在
THINKING_MODELS: [], THINKING_MODELS: [],
THINKING_BUDGET_MAP: {}, THINKING_BUDGET_MAP: {},
AUTO_DELETE_ERROR_LOGS_ENABLED: false, // 新增默认值 AUTO_DELETE_ERROR_LOGS_ENABLED: false,
AUTO_DELETE_ERROR_LOGS_DAYS: 7, // 新增默认值 AUTO_DELETE_ERROR_LOGS_DAYS: 7, // 新增默认值
AUTO_DELETE_REQUEST_LOGS_ENABLED: false, // 新增默认值 AUTO_DELETE_REQUEST_LOGS_ENABLED: false, // 新增默认值
AUTO_DELETE_REQUEST_LOGS_DAYS: 30, // 新增默认值 AUTO_DELETE_REQUEST_LOGS_DAYS: 30, // 新增默认值
@@ -678,7 +765,7 @@ async function initConfig() {
FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS: 5, FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS: 5,
// --- 结束:处理假流式配置的默认值 --- // --- 结束:处理假流式配置的默认值 ---
}; };
populateForm(defaultConfig); populateForm(defaultConfig);
if (configForm) { if (configForm) {
// Ensure form exists // Ensure form exists
@@ -1090,7 +1177,126 @@ function handleBulkDeleteProxies() {
} }
bulkDeleteProxyInput.value = ""; bulkDeleteProxyInput.value = "";
} }
/**
* Handles the bulk addition of Vertex API keys from the modal input.
*/
function handleBulkAddVertexApiKeys() {
const vertexApiKeyContainer = document.getElementById(
"VERTEX_API_KEYS_container"
);
if (
!vertexApiKeyBulkInput ||
!vertexApiKeyContainer ||
!vertexApiKeyModal
) {
return;
}
const bulkText = vertexApiKeyBulkInput.value;
const extractedKeys = bulkText.match(VERTEX_API_KEY_REGEX) || [];
const currentKeyInputs = vertexApiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
let currentKeys = Array.from(currentKeyInputs)
.map((input) => {
return input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value")
: input.value;
})
.filter((key) => key && key.trim() !== "" && key !== MASKED_VALUE);
const combinedKeys = new Set([...currentKeys, ...extractedKeys]);
const uniqueKeys = Array.from(combinedKeys);
vertexApiKeyContainer.innerHTML = ""; // Clear existing items
uniqueKeys.forEach((key) => {
addArrayItemWithValue("VERTEX_API_KEYS", key); // VERTEX_API_KEYS are sensitive
});
// Ensure new sensitive inputs are masked
const newKeyInputs = vertexApiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
newKeyInputs.forEach((input) => {
if (configForm && typeof initializeSensitiveFields === "function") {
const focusoutEvent = new Event("focusout", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(focusoutEvent);
}
});
closeModal(vertexApiKeyModal);
showNotification(
`添加/更新了 ${uniqueKeys.length} 个唯一 Vertex 密钥`,
"success"
);
vertexApiKeyBulkInput.value = "";
}
/**
* Handles the bulk deletion of Vertex API keys based on input from the modal.
*/
function handleBulkDeleteVertexApiKeys() {
const vertexApiKeyContainer = document.getElementById(
"VERTEX_API_KEYS_container"
);
if (
!bulkDeleteVertexApiKeyInput ||
!vertexApiKeyContainer ||
!bulkDeleteVertexApiKeyModal
) {
return;
}
const bulkText = bulkDeleteVertexApiKeyInput.value;
if (!bulkText.trim()) {
showNotification("请粘贴需要删除的 Vertex API 密钥", "warning");
return;
}
const keysToDelete = new Set(bulkText.match(VERTEX_API_KEY_REGEX) || []);
if (keysToDelete.size === 0) {
showNotification(
"未在输入内容中提取到有效的 Vertex API 密钥格式",
"warning"
);
return;
}
const keyItems = vertexApiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
let deleteCount = 0;
keyItems.forEach((item) => {
const input = item.querySelector(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
const realValue =
input &&
(input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value")
: input.value);
if (realValue && keysToDelete.has(realValue)) {
item.remove();
deleteCount++;
}
});
closeModal(bulkDeleteVertexApiKeyModal);
if (deleteCount > 0) {
showNotification(`成功删除了 ${deleteCount} 个匹配的 Vertex 密钥`, "success");
} else {
showNotification("列表中未找到您输入的任何 Vertex 密钥进行删除", "info");
}
bulkDeleteVertexApiKeyInput.value = "";
}
/** /**
* Switches the active configuration tab. * Switches the active configuration tab.
* @param {string} tabId - The ID of the tab to switch to. * @param {string} tabId - The ID of the tab to switch to.
@@ -1231,9 +1437,11 @@ function addArrayItemWithValue(key, value) {
const isThinkingModel = key === "THINKING_MODELS"; const isThinkingModel = key === "THINKING_MODELS";
const isAllowedToken = key === "ALLOWED_TOKENS"; const isAllowedToken = key === "ALLOWED_TOKENS";
const isSensitive = key === "API_KEYS" || isAllowedToken; const isVertexApiKey = key === "VERTEX_API_KEYS"; // 新增判断
const isSensitive =
key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断
const modelId = isThinkingModel ? generateUUID() : null; const modelId = isThinkingModel ? generateUUID() : null;
const arrayItem = document.createElement("div"); const arrayItem = document.createElement("div");
arrayItem.className = `${ARRAY_ITEM_CLASS} flex items-center mb-2 gap-2`; arrayItem.className = `${ARRAY_ITEM_CLASS} flex items-center mb-2 gap-2`;
if (isThinkingModel) { if (isThinkingModel) {
+144 -2
View File
@@ -456,7 +456,51 @@ endblock %} {% block head_extra_styles %}
/> />
<small class="text-gray-500 mt-1 block">Gemini API的基础URL</small> <small class="text-gray-500 mt-1 block">Gemini API的基础URL</small>
</div> </div>
<!-- Vertex API密钥列表 -->
<div class="mb-6">
<label for="VERTEX_API_KEYS" class="block font-semibold mb-2 text-gray-700"
>Vertex API密钥列表</label
>
<div class="array-container" id="VERTEX_API_KEYS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
id="bulkDeleteVertexApiKeyBtn"
>
<i class="fas fa-trash-alt"></i> 删除Vertex密钥
</button>
<button
type="button"
class="bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
id="addVertexApiKeyBtn"
>
<i class="fas fa-plus"></i> 添加Vertex密钥
</button>
</div>
<small class="text-gray-500 mt-1 block"
>Vertex AI Platform API密钥列表。点击按钮可批量添加或删除。</small
>
</div>
<!-- Vertex Express API基础URL -->
<div class="mb-6">
<label for="VERTEX_EXPRESS_BASE_URL" class="block font-semibold mb-2 text-gray-700"
>Vertex Express API基础URL</label
>
<input
type="text"
id="VERTEX_EXPRESS_BASE_URL"
name="VERTEX_EXPRESS_BASE_URL"
placeholder="https://aiplatform.googleapis.com/v1beta1/publishers/google/models"
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 form-input-themed"
/>
<small class="text-gray-500 mt-1 block">Vertex Express API的基础URL</small>
</div>
<!-- 最大失败次数 --> <!-- 最大失败次数 -->
<div class="mb-6"> <div class="mb-6">
<label <label
@@ -1610,7 +1654,105 @@ endblock %} {% block head_extra_styles %}
</div> </div>
</div> </div>
</div> </div>
<!-- Vertex API Key Add Modal -->
<div id="vertexApiKeyModal" class="modal">
<div
class="w-full max-w-lg mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border: 1px solid rgba(120, 100, 200, 0.4);
"
>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-100">批量添加 Vertex API 密钥</h2>
<button
id="closeVertexApiKeyModalBtn"
class="text-gray-300 hover:text-gray-100 text-xl"
>
&times;
</button>
</div>
<p class="text-gray-300 mb-4">
每行粘贴一个或多个密钥,将自动提取有效密钥 (格式: AQ.开头,共53位) 并去重。
</p>
<textarea
id="vertexApiKeyBulkInput"
rows="10"
placeholder="在此处粘贴 Vertex API 密钥..."
class="w-full px-4 py-3 rounded-lg border font-mono text-sm form-input-themed"
></textarea>
<div class="flex justify-end gap-3 mt-6">
<button
type="button"
id="confirmAddVertexApiKeyBtn"
class="bg-violet-600 hover:bg-violet-700 text-white px-6 py-2 rounded-lg font-medium transition"
>
确认添加
</button>
<button
type="button"
id="cancelAddVertexApiKeyBtn"
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-6 py-2 rounded-lg font-medium transition"
>
取消
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Delete Vertex API Key Modal -->
<div id="bulkDeleteVertexApiKeyModal" class="modal">
<div
class="w-full max-w-lg mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border: 1px solid rgba(120, 100, 200, 0.4);
"
>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-100">批量删除 Vertex API 密钥</h2>
<button
id="closeBulkDeleteVertexModalBtn"
class="text-gray-300 hover:text-gray-100 text-xl"
>
&times;
</button>
</div>
<p class="text-gray-300 mb-4">
每行粘贴一个或多个密钥,将自动提取有效密钥并从列表中删除。
</p>
<textarea
id="bulkDeleteVertexApiKeyInput"
rows="10"
placeholder="在此处粘贴要删除的 Vertex API 密钥..."
class="w-full px-4 py-3 rounded-lg border font-mono text-sm form-input-themed focus:border-red-500 focus:ring-red-500"
></textarea>
<div class="flex justify-end gap-3 mt-6">
<button
type="button"
id="confirmBulkDeleteVertexApiKeyBtn"
class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg font-medium transition"
>
确认删除
</button>
<button
type="button"
id="cancelBulkDeleteVertexApiKeyBtn"
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-6 py-2 rounded-lg font-medium transition"
>
取消
</button>
</div>
</div>
</div>
</div>
<!-- Model Helper Modal --> <!-- Model Helper Modal -->
<div id="modelHelperModal" class="modal"> <div id="modelHelperModal" class="modal">
<div <div