mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-04 23:19:17 +08:00
feat: 改进错误日志功能并优化应用初始化流程
本次提交主要包含以下更新:
- **错误日志页面增强**:
- 重构了 [`app/static/js/error_logs.js`](app/static/js/error_logs.js) 中的分页逻辑,将样式控制移至 CSS,简化了 JavaScript 代码。
- 更新了 [`app/templates/error_logs.html`](app/templates/error_logs.html) 中的分页样式,使其与 `keys_status.html` 保持一致,提升了视觉统一性。
- 在错误日志页面新增了“清空全部”按钮,方便用户一键清除所有错误记录。
- 调整了错误日志表格头部的文本颜色为白色,以改善深色主题下的可读性。
- **应用初始化与配置优化**:
- 调整了 [`app/config/config.py`](app/config/config.py) 中日志记录器的获取方式,确保在配置加载早期即可用。
- 在 [`app/core/application.py`](app/core/application.py) 中引入了更明确的数据库连接管理(连接、断开、初始化)逻辑。
- 优化了 [`app/utils/helpers.py`](app/utils/helpers.py) 中项目路径和版本文件路径的定义方式,使其在模块级别初始化。
- **依赖清理**:
- 从 [`requirements.txt`](requirements.txt) 中移除了不必要的注释。
这些更改旨在提升错误日志模块的用户体验和功能性,并优化应用程序的启动和配置管理流程。
This commit is contained in:
@@ -121,9 +121,9 @@ settings = Settings()
|
|||||||
|
|
||||||
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
||||||
"""尝试将数据库字符串值解析为目标 Python 类型"""
|
"""尝试将数据库字符串值解析为目标 Python 类型"""
|
||||||
from app.log.logger import get_config_logger # 函数内导入
|
from app.log.logger import get_config_logger
|
||||||
|
|
||||||
logger = get_config_logger() # 函数内初始化
|
logger = get_config_logger()
|
||||||
try:
|
try:
|
||||||
# 处理 List[str]
|
# 处理 List[str]
|
||||||
if target_type == List[str]:
|
if target_type == List[str]:
|
||||||
@@ -234,9 +234,9 @@ async def sync_initial_settings():
|
|||||||
2. 将数据库设置合并到内存 settings (数据库优先)。
|
2. 将数据库设置合并到内存 settings (数据库优先)。
|
||||||
3. 将最终的内存 settings 同步回数据库。
|
3. 将最终的内存 settings 同步回数据库。
|
||||||
"""
|
"""
|
||||||
from app.log.logger import get_config_logger # 函数内导入
|
from app.log.logger import get_config_logger
|
||||||
|
|
||||||
logger = get_config_logger() # 函数内初始化
|
logger = get_config_logger()
|
||||||
# 延迟导入以避免循环依赖和确保数据库连接已初始化
|
# 延迟导入以避免循环依赖和确保数据库连接已初始化
|
||||||
from app.database.connection import database
|
from app.database.connection import database
|
||||||
from app.database.models import Settings as SettingsModel
|
from app.database.models import Settings as SettingsModel
|
||||||
@@ -360,14 +360,14 @@ async def sync_initial_settings():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 序列化值为字符串或 JSON 字符串
|
# 序列化值为字符串或 JSON 字符串
|
||||||
if isinstance(value, (list, dict)): # 处理列表和字典
|
if isinstance(value, (list, dict)):
|
||||||
db_value = json.dumps(
|
db_value = json.dumps(
|
||||||
value, ensure_ascii=False
|
value, ensure_ascii=False
|
||||||
) # 使用 ensure_ascii=False 以支持非 ASCII 字符
|
)
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
db_value = str(value).lower()
|
db_value = str(value).lower()
|
||||||
elif value is None: # 处理 None 值
|
elif value is None:
|
||||||
db_value = "" # 或者根据需要设为 NULL 或其他标记
|
db_value = ""
|
||||||
else:
|
else:
|
||||||
db_value = str(value)
|
db_value = str(value)
|
||||||
|
|
||||||
|
|||||||
+33
-45
@@ -1,35 +1,32 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path # Add pathlib import
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.config.config import settings, sync_initial_settings
|
from app.config.config import settings, sync_initial_settings
|
||||||
|
from app.database.connection import connect_to_db, disconnect_from_db
|
||||||
|
from app.database.initialization import initialize_database
|
||||||
|
from app.exception.exceptions import setup_exception_handlers
|
||||||
from app.log.logger import get_application_logger
|
from app.log.logger import get_application_logger
|
||||||
from app.middleware.middleware import setup_middlewares
|
from app.middleware.middleware import setup_middlewares
|
||||||
from app.exception.exceptions import setup_exception_handlers
|
|
||||||
from app.router.routes import setup_routers
|
from app.router.routes import setup_routers
|
||||||
from app.service.key.key_manager import get_key_manager_instance
|
|
||||||
from app.database.connection import connect_to_db, disconnect_from_db
|
|
||||||
from app.utils.helpers import get_current_version # Import from helpers
|
|
||||||
from app.database.initialization import initialize_database
|
|
||||||
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.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
|
||||||
|
|
||||||
logger = get_application_logger()
|
logger = get_application_logger()
|
||||||
|
|
||||||
# Define project paths using pathlib
|
|
||||||
# Assuming this file is at app/core/application.py
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py
|
|
||||||
STATIC_DIR = PROJECT_ROOT / "app" / "static"
|
STATIC_DIR = PROJECT_ROOT / "app" / "static"
|
||||||
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
|
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
|
||||||
|
|
||||||
# Removed _get_current_version function definition, moved to helpers.py
|
|
||||||
|
|
||||||
# 初始化模板引擎,并添加全局变量
|
# 初始化模板引擎,并添加全局变量
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
# 定义一个函数来更新模板全局变量
|
# 定义一个函数来更新模板全局变量
|
||||||
def update_template_globals(app: FastAPI, update_info: dict):
|
def update_template_globals(app: FastAPI, update_info: dict):
|
||||||
# Jinja2Templates 实例没有直接更新全局变量的方法
|
# Jinja2Templates 实例没有直接更新全局变量的方法
|
||||||
@@ -40,114 +37,105 @@ def update_template_globals(app: FastAPI, update_info: dict):
|
|||||||
|
|
||||||
|
|
||||||
# --- Helper functions for lifespan ---
|
# --- Helper functions for lifespan ---
|
||||||
|
|
||||||
async def _setup_database_and_config(app_settings):
|
async def _setup_database_and_config(app_settings):
|
||||||
"""Initializes database, syncs settings, and initializes KeyManager."""
|
"""Initializes database, syncs settings, and initializes KeyManager."""
|
||||||
initialize_database()
|
await initialize_database()
|
||||||
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()
|
||||||
# Initialize KeyManager using potentially updated settings
|
|
||||||
await get_key_manager_instance(app_settings.API_KEYS)
|
await get_key_manager_instance(app_settings.API_KEYS)
|
||||||
logger.info("Database, config sync, and KeyManager initialized successfully")
|
logger.info("Database, config sync, and KeyManager initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
async def _shutdown_database():
|
async def _shutdown_database():
|
||||||
"""Disconnects from the database."""
|
"""Disconnects from the database."""
|
||||||
await disconnect_from_db()
|
await disconnect_from_db()
|
||||||
|
|
||||||
|
|
||||||
def _start_scheduler():
|
def _start_scheduler():
|
||||||
"""Starts the background scheduler."""
|
"""Starts the background scheduler."""
|
||||||
try:
|
try:
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
logger.info("Scheduler started successfully.")
|
logger.info("Scheduler started successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start scheduler: {e}")
|
logger.error(f"Failed to start scheduler: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _stop_scheduler():
|
def _stop_scheduler():
|
||||||
"""Stops the background scheduler."""
|
"""Stops the background scheduler."""
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
|
|
||||||
|
|
||||||
async def _perform_update_check(app: FastAPI):
|
async def _perform_update_check(app: FastAPI):
|
||||||
"""Checks for updates and stores the info in app.state."""
|
"""Checks for updates and stores the info in app.state."""
|
||||||
update_available, latest_version, error_message = await check_for_updates()
|
update_available, latest_version, error_message = await check_for_updates()
|
||||||
current_version = get_current_version() # Use imported function
|
current_version = get_current_version()
|
||||||
update_info = {
|
update_info = {
|
||||||
"update_available": update_available,
|
"update_available": update_available,
|
||||||
"latest_version": latest_version,
|
"latest_version": latest_version,
|
||||||
"error_message": error_message,
|
"error_message": error_message,
|
||||||
"current_version": current_version
|
"current_version": current_version,
|
||||||
}
|
}
|
||||||
# Ensure app.state exists and store update info
|
|
||||||
if not hasattr(app, "state"):
|
if not hasattr(app, "state"):
|
||||||
from starlette.datastructures import State
|
from starlette.datastructures import State
|
||||||
|
|
||||||
app.state = State()
|
app.state = State()
|
||||||
app.state.update_info = update_info
|
app.state.update_info = update_info
|
||||||
logger.info(f"Update check completed. Info: {update_info}")
|
logger.info(f"Update check completed. Info: {update_info}")
|
||||||
|
|
||||||
# --- Application Lifespan ---
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""
|
"""
|
||||||
Manages the application startup and shutdown events.
|
Manages the application startup and shutdown events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: FastAPI应用实例
|
app: FastAPI应用实例
|
||||||
"""
|
"""
|
||||||
# Startup events
|
|
||||||
logger.info("Application starting up...")
|
logger.info("Application starting up...")
|
||||||
try:
|
try:
|
||||||
# Setup database, config, and KeyManager
|
await _setup_database_and_config(settings)
|
||||||
await _setup_database_and_config(settings) # Pass settings object
|
await _perform_update_check(app)
|
||||||
|
|
||||||
# Perform update check after core components are ready
|
|
||||||
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
|
|
||||||
|
|
||||||
# Start the scheduler
|
|
||||||
_start_scheduler()
|
_start_scheduler()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.critical(f"Critical error during application startup: {str(e)}", exc_info=True)
|
logger.critical(
|
||||||
# Depending on the severity, you might want to prevent the app from fully starting
|
f"Critical error during application startup: {str(e)}", exc_info=True
|
||||||
# For now, we log critically and let it yield, potentially in a broken state.
|
)
|
||||||
# Consider adding more robust error handling here if startup failures should halt the app.
|
|
||||||
|
|
||||||
yield # Application runs
|
yield
|
||||||
|
|
||||||
# Shutdown events
|
|
||||||
logger.info("Application shutting down...")
|
logger.info("Application shutting down...")
|
||||||
_stop_scheduler()
|
_stop_scheduler()
|
||||||
await _shutdown_database()
|
await _shutdown_database()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""
|
"""
|
||||||
创建并配置FastAPI应用程序实例
|
创建并配置FastAPI应用程序实例
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
FastAPI: 配置好的FastAPI应用程序实例
|
FastAPI: 配置好的FastAPI应用程序实例
|
||||||
"""
|
"""
|
||||||
# Removed: initialize_app() call
|
|
||||||
|
|
||||||
# 创建FastAPI应用
|
# 创建FastAPI应用
|
||||||
# Read version from file for consistency
|
current_version = get_current_version()
|
||||||
current_version = get_current_version() # Use imported function
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Gemini Balance API",
|
title="Gemini Balance API",
|
||||||
description="Gemini API代理服务,支持负载均衡和密钥管理",
|
description="Gemini API代理服务,支持负载均衡和密钥管理",
|
||||||
version=current_version,
|
version=current_version,
|
||||||
lifespan=lifespan
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize app.state early to ensure it exists before lifespan potentially uses it
|
|
||||||
if not hasattr(app, "state"):
|
if not hasattr(app, "state"):
|
||||||
from starlette.datastructures import State
|
from starlette.datastructures import State
|
||||||
|
|
||||||
app.state = State()
|
app.state = State()
|
||||||
# Set a default/initial state for update_info
|
|
||||||
app.state.update_info = {
|
app.state.update_info = {
|
||||||
"update_available": False,
|
"update_available": False,
|
||||||
"latest_version": None,
|
"latest_version": None,
|
||||||
"error_message": "Initializing...",
|
"error_message": "Initializing...",
|
||||||
"current_version": current_version # Use version read earlier
|
"current_version": current_version,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 配置静态文件
|
# 配置静态文件
|
||||||
@@ -155,11 +143,11 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# 配置中间件
|
# 配置中间件
|
||||||
setup_middlewares(app)
|
setup_middlewares(app)
|
||||||
|
|
||||||
# 配置异常处理器
|
# 配置异常处理器
|
||||||
setup_exception_handlers(app)
|
setup_exception_handlers(app)
|
||||||
|
|
||||||
# 配置路由
|
# 配置路由
|
||||||
setup_routers(app)
|
setup_routers(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from databases import Database
|
from databases import Database
|
||||||
from sqlalchemy import create_engine, MetaData
|
from sqlalchemy import create_engine, MetaData
|
||||||
# from sqlalchemy.orm import sessionmaker # 不再需要
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
|
|||||||
@@ -42,11 +42,12 @@ class ErrorLog(Base):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
|
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
|
||||||
|
|
||||||
# 新增 RequestLog 模型
|
|
||||||
class RequestLog(Base):
|
class RequestLog(Base):
|
||||||
"""
|
"""
|
||||||
API 请求日志表
|
API 请求日志表
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "t_request_log"
|
__tablename__ = "t_request_log"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|||||||
+11
-28
@@ -71,7 +71,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
|||||||
.values(
|
.values(
|
||||||
value=value,
|
value=value,
|
||||||
description=description if description else setting["description"],
|
description=description if description else setting["description"],
|
||||||
updated_at=datetime.now() # Use datetime.now()
|
updated_at=datetime.now()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await database.execute(query)
|
await database.execute(query)
|
||||||
@@ -85,8 +85,8 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
|||||||
key=key,
|
key=key,
|
||||||
value=value,
|
value=value,
|
||||||
description=description,
|
description=description,
|
||||||
created_at=datetime.now(), # Use datetime.now()
|
created_at=datetime.now(),
|
||||||
updated_at=datetime.now() # Use datetime.now()
|
updated_at=datetime.now()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await database.execute(query)
|
await database.execute(query)
|
||||||
@@ -158,8 +158,8 @@ async def get_error_logs(
|
|||||||
error_code_search: Optional[str] = None,
|
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,
|
||||||
sort_by: str = 'id', # 新增排序字段
|
sort_by: str = 'id',
|
||||||
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
|
sort_order: str = 'desc'
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取错误日志,支持搜索、日期过滤和排序
|
获取错误日志,支持搜索、日期过滤和排序
|
||||||
@@ -200,28 +200,20 @@ async def get_error_logs(
|
|||||||
if start_date:
|
if start_date:
|
||||||
query = query.where(ErrorLog.request_time >= start_date)
|
query = query.where(ErrorLog.request_time >= start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
# Use the datetime object directly for comparison
|
|
||||||
query = query.where(ErrorLog.request_time < end_date)
|
query = query.where(ErrorLog.request_time < end_date)
|
||||||
if error_code_search:
|
if error_code_search:
|
||||||
try:
|
try:
|
||||||
# Attempt to convert search string to integer for exact match
|
|
||||||
error_code_int = int(error_code_search)
|
error_code_int = int(error_code_search)
|
||||||
query = query.where(ErrorLog.error_code == error_code_int)
|
query = query.where(ErrorLog.error_code == error_code_int)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# If conversion fails, log a warning and potentially skip this filter
|
|
||||||
# or handle as needed (e.g., return no results for invalid code format)
|
|
||||||
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||||
# Optionally, force no results if the format is invalid:
|
|
||||||
# query = query.where(False) # This ensures no rows are returned
|
|
||||||
|
|
||||||
# 添加排序逻辑
|
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id)
|
||||||
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id
|
|
||||||
if sort_order.lower() == 'asc':
|
if sort_order.lower() == 'asc':
|
||||||
query = query.order_by(asc(sort_column))
|
query = query.order_by(asc(sort_column))
|
||||||
else:
|
else:
|
||||||
query = query.order_by(desc(sort_column))
|
query = query.order_by(desc(sort_column))
|
||||||
|
|
||||||
# Apply limit and offset
|
|
||||||
query = query.limit(limit).offset(offset)
|
query = query.limit(limit).offset(offset)
|
||||||
|
|
||||||
result = await database.fetch_all(query)
|
result = await database.fetch_all(query)
|
||||||
@@ -254,7 +246,6 @@ async def get_error_logs_count(
|
|||||||
try:
|
try:
|
||||||
query = select(func.count()).select_from(ErrorLog)
|
query = select(func.count()).select_from(ErrorLog)
|
||||||
|
|
||||||
# Apply the same filters as get_error_logs
|
|
||||||
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:
|
||||||
@@ -265,23 +256,19 @@ async def get_error_logs_count(
|
|||||||
if start_date:
|
if start_date:
|
||||||
query = query.where(ErrorLog.request_time >= start_date)
|
query = query.where(ErrorLog.request_time >= start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
# Use the datetime object directly for comparison
|
|
||||||
query = query.where(ErrorLog.request_time < end_date)
|
query = query.where(ErrorLog.request_time < end_date)
|
||||||
if error_code_search:
|
if error_code_search:
|
||||||
try:
|
try:
|
||||||
# Attempt to convert search string to integer for exact match
|
|
||||||
error_code_int = int(error_code_search)
|
error_code_int = int(error_code_search)
|
||||||
query = query.where(ErrorLog.error_code == error_code_int)
|
query = query.where(ErrorLog.error_code == error_code_int)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# If conversion fails, log a warning and potentially skip this filter
|
|
||||||
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||||
# Optionally, force count to 0 if the format is invalid:
|
|
||||||
# return 0 # Or query = query.where(False) before fetching
|
|
||||||
|
|
||||||
count_result = await database.fetch_one(query)
|
count_result = await database.fetch_one(query)
|
||||||
return count_result[0] if count_result else 0
|
return count_result[0] if count_result else 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
|
logger.exception(f"Failed to count error logs with filters: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -315,7 +302,6 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
|||||||
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# --- 异步删除函数 (使用 databases 库) ---
|
|
||||||
|
|
||||||
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -345,7 +331,7 @@ async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 数据库连接或执行错误
|
# 数据库连接或执行错误
|
||||||
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
|
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
|
||||||
raise # Re-raise the exception for the router to handle
|
raise
|
||||||
|
|
||||||
async def delete_error_log_by_id(log_id: int) -> bool:
|
async def delete_error_log_by_id(log_id: int) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -364,7 +350,7 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
|||||||
|
|
||||||
if not exists:
|
if not exists:
|
||||||
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
|
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
|
||||||
return False # 或者可以抛出 404 异常,由路由处理
|
return False
|
||||||
|
|
||||||
# 执行删除
|
# 执行删除
|
||||||
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
|
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
|
||||||
@@ -373,9 +359,7 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
|
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
|
||||||
raise # Re-raise the exception for the router to handle
|
raise
|
||||||
|
|
||||||
# --- RequestLog Services (保持异步) ---
|
|
||||||
|
|
||||||
# 新增函数:添加请求日志
|
# 新增函数:添加请求日志
|
||||||
async def add_request_log(
|
async def add_request_log(
|
||||||
@@ -412,7 +396,6 @@ async def add_request_log(
|
|||||||
latency_ms=latency_ms
|
latency_ms=latency_ms
|
||||||
)
|
)
|
||||||
await database.execute(query)
|
await database.execute(query)
|
||||||
# logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add request log: {str(e)}")
|
logger.error(f"Failed to add request log: {str(e)}")
|
||||||
|
|||||||
@@ -128,12 +128,7 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
raise ValueError(f"Unsupported media format: {format}")
|
raise ValueError(f"Unsupported media format: {format}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Decode Base64 to check size
|
decoded_data = base64.b64decode(data, validate=True)
|
||||||
# Be careful with memory usage for very large files
|
|
||||||
# Consider streaming decoding or checking length heuristic first if memory is a concern
|
|
||||||
decoded_data = base64.b64decode(
|
|
||||||
data, validate=True
|
|
||||||
) # Use validate=True for stricter check
|
|
||||||
if len(decoded_data) > max_size:
|
if len(decoded_data) > max_size:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)."
|
f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)."
|
||||||
@@ -141,7 +136,6 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Media data size exceeds limit of {max_size // 1024 // 1024}MB"
|
f"Media data size exceeds limit of {max_size // 1024 // 1024}MB"
|
||||||
)
|
)
|
||||||
# No need to return decoded_data, just the original base64 if valid
|
|
||||||
return data
|
return data
|
||||||
except base64.binascii.Error as e:
|
except base64.binascii.Error as e:
|
||||||
logger.error(f"Invalid Base64 data provided: {e}")
|
logger.error(f"Invalid Base64 data provided: {e}")
|
||||||
@@ -163,7 +157,6 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
if "content" in msg and isinstance(msg["content"], list):
|
if "content" in msg and isinstance(msg["content"], list):
|
||||||
for content_item in msg["content"]:
|
for content_item in msg["content"]:
|
||||||
if not isinstance(content_item, dict):
|
if not isinstance(content_item, dict):
|
||||||
# Skip non-dict items if any unexpected format appears
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Skipping unexpected content item format: {type(content_item)}"
|
f"Skipping unexpected content item format: {type(content_item)}"
|
||||||
)
|
)
|
||||||
@@ -184,13 +177,11 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to convert image URL {content_item['image_url']['url']}: {e}"
|
f"Failed to convert image URL {content_item['image_url']['url']}: {e}"
|
||||||
)
|
)
|
||||||
# Decide how to handle: skip part, add error text, etc.
|
|
||||||
parts.append(
|
parts.append(
|
||||||
{
|
{
|
||||||
"text": f"[Error processing image: {content_item['image_url']['url']}]"
|
"text": f"[Error processing image: {content_item['image_url']['url']}]"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# --- Add handling for input_audio ---
|
|
||||||
elif content_type == "input_audio" and content_item.get(
|
elif content_type == "input_audio" and content_item.get(
|
||||||
"input_audio"
|
"input_audio"
|
||||||
):
|
):
|
||||||
@@ -205,7 +196,6 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Validate size and format
|
|
||||||
validated_data = self._validate_media_data(
|
validated_data = self._validate_media_data(
|
||||||
audio_format,
|
audio_format,
|
||||||
audio_data,
|
audio_data,
|
||||||
|
|||||||
@@ -223,3 +223,6 @@ def get_openai_compatible_logger():
|
|||||||
def get_error_log_logger():
|
def get_error_log_logger():
|
||||||
return Logger.setup_logger("error_log")
|
return Logger.setup_logger("error_log")
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_log_logger():
|
||||||
|
return Logger.setup_logger("request_log")
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ async def reset_config(request: Request):
|
|||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# Pydantic model for bulk delete request
|
|
||||||
class DeleteKeysRequest(BaseModel):
|
class DeleteKeysRequest(BaseModel):
|
||||||
keys: List[str] = Field(..., description="List of API keys to delete")
|
keys: List[str] = Field(..., description="List of API keys to delete")
|
||||||
|
|
||||||
@@ -70,9 +69,6 @@ async def delete_single_key(key_to_delete: str, request: Request):
|
|||||||
logger.info(f"Attempting to delete key: {key_to_delete}")
|
logger.info(f"Attempting to delete key: {key_to_delete}")
|
||||||
result = await ConfigService.delete_key(key_to_delete)
|
result = await ConfigService.delete_key(key_to_delete)
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
# Optionally, translate specific errors to HTTP status codes
|
|
||||||
# For now, let's assume 400 for any failure from service if not found,
|
|
||||||
# or 500 if it was an unexpected error (though service should handle that)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=(
|
status_code=(
|
||||||
404 if "not found" in result.get("message", "").lower() else 400
|
404 if "not found" in result.get("message", "").lower() else 400
|
||||||
@@ -81,7 +77,6 @@ async def delete_single_key(key_to_delete: str, request: Request):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
# Re-raise HTTPExceptions directly
|
|
||||||
raise e
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
|
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
|
||||||
@@ -104,14 +99,10 @@ async def delete_selected_keys_route(
|
|||||||
try:
|
try:
|
||||||
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
|
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
|
||||||
result = await ConfigService.delete_selected_keys(delete_request.keys)
|
result = await ConfigService.delete_selected_keys(delete_request.keys)
|
||||||
# Similar to single delete, we can check result["success"]
|
|
||||||
if not result.get("success") and result.get("deleted_count", 0) == 0:
|
if not result.get("success") and result.get("deleted_count", 0) == 0:
|
||||||
# If no keys were actually deleted, it might be a client error (e.g., all keys not found)
|
|
||||||
# or an empty list was somehow passed despite the check above.
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=result.get("message", "Failed to delete keys.")
|
status_code=400, detail=result.get("message", "Failed to delete keys.")
|
||||||
)
|
)
|
||||||
# If some keys were deleted but others not found, it's still a partial success, return 200 with details.
|
|
||||||
return result
|
return result
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
raise e
|
raise e
|
||||||
|
|||||||
@@ -209,3 +209,25 @@ async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail="Internal server error during deletion"
|
status_code=500, detail="Internal server error during deletion"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/errors/all", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_all_error_logs_api(request: Request):
|
||||||
|
"""
|
||||||
|
删除所有错误日志 (异步)
|
||||||
|
"""
|
||||||
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
|
logger.warning("Unauthorized access attempt to delete all error logs")
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted_count = await error_log_service.process_delete_all_error_logs()
|
||||||
|
logger.info(f"Successfully deleted all {deleted_count} error logs.")
|
||||||
|
# No body needed for 204 response
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error deleting all error logs: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Internal server error during deletion of all logs"
|
||||||
|
)
|
||||||
|
|||||||
@@ -58,21 +58,18 @@ async def check_failed_keys():
|
|||||||
contents=[
|
contents=[
|
||||||
GeminiContent(
|
GeminiContent(
|
||||||
role="user",
|
role="user",
|
||||||
parts=[{"text": "hi"}], # 使用简单的文本进行验证
|
parts=[{"text": "hi"}],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
# 调用 generate_content 进行验证
|
|
||||||
await chat_service.generate_content(
|
await chat_service.generate_content(
|
||||||
settings.TEST_MODEL, gemini_request, key # 使用配置中定义的测试模型
|
settings.TEST_MODEL, gemini_request, key
|
||||||
)
|
)
|
||||||
# 如果没有抛出异常,说明 key 有效
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Key {log_key} verification successful. Resetting failure count."
|
f"Key {log_key} verification successful. Resetting failure count."
|
||||||
)
|
)
|
||||||
await key_manager.reset_key_failure_count(key)
|
await key_manager.reset_key_failure_count(key)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 验证失败,增加失败计数
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Key {log_key} verification failed: {str(e)}. Incrementing failure count."
|
f"Key {log_key} verification failed: {str(e)}. Incrementing failure count."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class GeminiChatService:
|
|||||||
"""生成内容"""
|
"""生成内容"""
|
||||||
payload = _build_payload(model, request)
|
payload = _build_payload(model, request)
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
request_datetime = datetime.datetime.now() # Record request time
|
request_datetime = datetime.datetime.now()
|
||||||
is_success = False
|
is_success = False
|
||||||
status_code = None
|
status_code = None
|
||||||
response = None
|
response = None
|
||||||
@@ -152,20 +152,18 @@ class GeminiChatService:
|
|||||||
try:
|
try:
|
||||||
response = await self.api_client.generate_content(payload, model, api_key)
|
response = await self.api_client.generate_content(payload, model, api_key)
|
||||||
is_success = True
|
is_success = True
|
||||||
status_code = 200 # Assume 200 on success
|
status_code = 200
|
||||||
return self.response_handler.handle_response(response, model, stream=False)
|
return self.response_handler.handle_response(response, model, stream=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = str(e)
|
error_log_msg = str(e)
|
||||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||||
# Try to parse status code from exception
|
|
||||||
match = re.search(r"status code (\d+)", error_log_msg)
|
match = re.search(r"status code (\d+)", error_log_msg)
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
else:
|
else:
|
||||||
status_code = 500 # Default to 500 if parsing fails
|
status_code = 500
|
||||||
|
|
||||||
# Log error to error log table
|
|
||||||
await add_error_log(
|
await add_error_log(
|
||||||
gemini_key=api_key,
|
gemini_key=api_key,
|
||||||
model_name=model,
|
model_name=model,
|
||||||
@@ -174,11 +172,10 @@ class GeminiChatService:
|
|||||||
error_code=status_code,
|
error_code=status_code,
|
||||||
request_msg=payload
|
request_msg=payload
|
||||||
)
|
)
|
||||||
raise e # Re-throw exception for upstream handling
|
raise e
|
||||||
finally:
|
finally:
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
# Log request to request log table
|
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -240,16 +237,14 @@ class GeminiChatService:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||||
)
|
)
|
||||||
# Parse error code for logging
|
|
||||||
match = re.search(r"status code (\d+)", error_log_msg)
|
match = re.search(r"status code (\d+)", error_log_msg)
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
else:
|
else:
|
||||||
status_code = 500
|
status_code = 500
|
||||||
|
|
||||||
# Log error to error log table
|
|
||||||
await add_error_log(
|
await add_error_log(
|
||||||
gemini_key=current_attempt_key, # Log key used for this failed attempt
|
gemini_key=current_attempt_key,
|
||||||
model_name=model,
|
model_name=model,
|
||||||
error_type="gemini-chat-stream",
|
error_type="gemini-chat-stream",
|
||||||
error_log=error_log_msg,
|
error_log=error_log_msg,
|
||||||
@@ -257,28 +252,26 @@ class GeminiChatService:
|
|||||||
request_msg=payload
|
request_msg=payload
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attempt to switch API Key
|
|
||||||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
||||||
if api_key:
|
if api_key:
|
||||||
logger.info(f"Switched to new API key: {api_key}")
|
logger.info(f"Switched to new API key: {api_key}")
|
||||||
else: # No more keys or retries exceeded by handle_api_failure logic
|
else:
|
||||||
logger.error(f"No valid API key available after {retries} retries.")
|
logger.error(f"No valid API key available after {retries} retries.")
|
||||||
break # Exit loop if no key available
|
break
|
||||||
|
|
||||||
if retries >= max_retries:
|
if retries >= max_retries:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Max retries ({max_retries}) reached for streaming."
|
f"Max retries ({max_retries}) reached for streaming."
|
||||||
)
|
)
|
||||||
break # Exit loop after max retries
|
break
|
||||||
finally:
|
finally:
|
||||||
# Log the final outcome of the streaming request
|
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
api_key=final_api_key, # Log the last key used
|
api_key=final_api_key,
|
||||||
is_success=is_success, # Log the final success status
|
is_success=is_success,
|
||||||
status_code=status_code, # Log the last known status code
|
status_code=status_code,
|
||||||
latency_ms=latency_ms, # Log total time including retries
|
latency_ms=latency_ms,
|
||||||
request_time=request_datetime
|
request_time=request_datetime
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ class OpenAIChatService:
|
|||||||
self, original_chunk: Dict[str, Any], text: str
|
self, original_chunk: Dict[str, Any], text: str
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""创建包含指定文本的OpenAI响应块"""
|
"""创建包含指定文本的OpenAI响应块"""
|
||||||
chunk_copy = json.loads(json.dumps(original_chunk)) # 深拷贝
|
chunk_copy = json.loads(json.dumps(original_chunk))
|
||||||
if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]:
|
if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]:
|
||||||
chunk_copy["choices"][0]["delta"]["content"] = text
|
chunk_copy["choices"][0]["delta"]["content"] = text
|
||||||
return chunk_copy
|
return chunk_copy
|
||||||
@@ -184,10 +184,8 @@ class OpenAIChatService:
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||||
"""创建聊天完成"""
|
"""创建聊天完成"""
|
||||||
# 转换消息格式
|
|
||||||
messages, instruction = self.message_converter.convert(request.messages)
|
messages, instruction = self.message_converter.convert(request.messages)
|
||||||
|
|
||||||
# 构建请求payload
|
|
||||||
payload = _build_payload(request, messages, instruction)
|
payload = _build_payload(request, messages, instruction)
|
||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
@@ -219,7 +217,6 @@ class OpenAIChatService:
|
|||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = str(e)
|
error_log_msg = str(e)
|
||||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||||
# Try to parse status code from exception
|
|
||||||
match = re.search(r"status code (\d+)", error_log_msg)
|
match = re.search(r"status code (\d+)", error_log_msg)
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
@@ -587,7 +584,6 @@ class OpenAIChatService:
|
|||||||
error_code=status_code,
|
error_code=status_code,
|
||||||
request_msg={"image_data_truncated": image_data[:1000]},
|
request_msg={"image_data_truncated": image_data[:1000]},
|
||||||
)
|
)
|
||||||
# Re-raise the exception so the caller knows about the failure
|
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
|
|||||||
@@ -53,16 +53,14 @@ class GeminiApiClient(ApiClient):
|
|||||||
url = f"{self.base_url}/models?key={api_key}"
|
url = f"{self.base_url}/models?key={api_key}"
|
||||||
try:
|
try:
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
response.raise_for_status() # 如果状态码不是 2xx,则引发 HTTPStatusError
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.error(f"获取模型列表失败: {e.response.status_code}")
|
logger.error(f"获取模型列表失败: {e.response.status_code}")
|
||||||
logger.error(e.response.text)
|
logger.error(e.response.text)
|
||||||
# 返回 None 而不是抛出异常,以便上层处理
|
|
||||||
return None
|
return None
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"请求模型列表失败: {e}")
|
logger.error(f"请求模型列表失败: {e}")
|
||||||
# 返回 None 而不是抛出异常
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class ConfigService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if key in existing_keys:
|
if key in existing_keys:
|
||||||
# Preserve original description if not explicitly provided
|
|
||||||
data["description"] = existing_settings_map[key].get(
|
data["description"] = existing_settings_map[key].get(
|
||||||
"description", description
|
"description", description
|
||||||
)
|
)
|
||||||
@@ -111,7 +110,7 @@ class ConfigService:
|
|||||||
logger.info(f"Updated {len(settings_to_update)} settings.")
|
logger.info(f"Updated {len(settings_to_update)} settings.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to bulk update/insert settings: {str(e)}")
|
logger.error(f"Failed to bulk update/insert settings: {str(e)}")
|
||||||
raise # Re-raise the exception after logging
|
raise
|
||||||
|
|
||||||
# 重置并重新初始化 KeyManager
|
# 重置并重新初始化 KeyManager
|
||||||
try:
|
try:
|
||||||
@@ -120,8 +119,6 @@ class ConfigService:
|
|||||||
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)}")
|
||||||
# Decide if this error should prevent returning the updated config
|
|
||||||
# For now, we log the error and continue
|
|
||||||
|
|
||||||
return await ConfigService.get_config()
|
return await ConfigService.get_config()
|
||||||
|
|
||||||
@@ -244,13 +241,11 @@ class ConfigService:
|
|||||||
models = await model_service.get_gemini_openai_models(api_key)
|
models = await model_service.get_gemini_openai_models(api_key)
|
||||||
return models
|
return models
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
# Re-raise HTTPExceptions directly if they are already specific
|
|
||||||
raise e
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
|
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
# Raise a generic HTTPException for other errors
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
|
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
import re # For potential status code parsing from generic errors
|
import re
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
|
||||||
import openai
|
import openai
|
||||||
from openai import APIStatusError # Import specific error type
|
from openai import APIStatusError
|
||||||
from openai.types import CreateEmbeddingResponse
|
from openai.types import CreateEmbeddingResponse
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.log.logger import get_embeddings_logger
|
from app.log.logger import get_embeddings_logger
|
||||||
from app.database.services import add_error_log, add_request_log # Import DB logging functions
|
from app.database.services import add_error_log, add_request_log
|
||||||
|
|
||||||
logger = get_embeddings_logger()
|
logger = get_embeddings_logger()
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@ class EmbeddingService:
|
|||||||
status_code = None
|
status_code = None
|
||||||
response = None
|
response = None
|
||||||
error_log_msg = ""
|
error_log_msg = ""
|
||||||
# Prepare request message for logging (truncate if list or long string)
|
|
||||||
if isinstance(input_text, list):
|
if isinstance(input_text, list):
|
||||||
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
|
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
|
||||||
if len(input_text) > 5:
|
if len(input_text) > 5:
|
||||||
@@ -46,32 +45,29 @@ class EmbeddingService:
|
|||||||
status_code = e.status_code
|
status_code = e.status_code
|
||||||
error_log_msg = f"OpenAI API error: {e}"
|
error_log_msg = f"OpenAI API error: {e}"
|
||||||
logger.error(f"Error creating embedding (APIStatusError): {error_log_msg}")
|
logger.error(f"Error creating embedding (APIStatusError): {error_log_msg}")
|
||||||
raise e # Re-raise the specific error
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = f"Generic error: {e}"
|
error_log_msg = f"Generic error: {e}"
|
||||||
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
|
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
|
||||||
# Try to parse status code from generic error (less reliable)
|
|
||||||
match = re.search(r"status code (\d+)", str(e))
|
match = re.search(r"status code (\d+)", str(e))
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
else:
|
else:
|
||||||
status_code = 500 # Default if parsing fails
|
status_code = 500
|
||||||
raise e # Re-raise the generic error
|
raise e
|
||||||
finally:
|
finally:
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
if not is_success:
|
if not is_success:
|
||||||
# Log error to database if it failed
|
await add_error_log(
|
||||||
await add_error_log(
|
gemini_key=api_key,
|
||||||
gemini_key=api_key, # Using gemini_key parameter name for consistency
|
model_name=model,
|
||||||
model_name=model,
|
error_type="openai-embedding",
|
||||||
error_type="openai-embedding",
|
error_log=error_log_msg,
|
||||||
error_log=error_log_msg,
|
error_code=status_code,
|
||||||
error_code=status_code,
|
request_msg=request_msg_log
|
||||||
request_msg=request_msg_log
|
|
||||||
)
|
)
|
||||||
# Log request outcome to database regardless of success/failure
|
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
|
|||||||
@@ -153,3 +153,27 @@ async def process_delete_error_log_by_id(log_id: int) -> bool:
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def process_delete_all_error_logs() -> int:
|
||||||
|
"""
|
||||||
|
处理删除所有错误日志的请求。
|
||||||
|
返回删除的日志数量。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 确保数据库已连接 (如果适用,类似于 delete_old_error_logs)
|
||||||
|
# if not database.is_connected:
|
||||||
|
# await database.connect()
|
||||||
|
# logger.info("Database connection established for deleting all error logs.")
|
||||||
|
|
||||||
|
deleted_count = await db_services.delete_all_error_logs()
|
||||||
|
logger.info(
|
||||||
|
f"Successfully processed request to delete all error logs. Count: {deleted_count}"
|
||||||
|
)
|
||||||
|
return deleted_count
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Service error in process_delete_all_error_logs: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class ImageCreateService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"created": int(time.time()), # Current timestamp
|
"created": int(time.time()),
|
||||||
"data": images_data,
|
"data": images_data,
|
||||||
}
|
}
|
||||||
return response_data
|
return response_data
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ class KeyManager:
|
|||||||
|
|
||||||
current_key = await self.get_next_key()
|
current_key = await self.get_next_key()
|
||||||
if current_key == initial_key:
|
if current_key == initial_key:
|
||||||
# await self.reset_failure_counts() 取消重置
|
|
||||||
return current_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:
|
||||||
@@ -101,25 +100,12 @@ class KeyManager:
|
|||||||
for key in self.key_failure_counts:
|
for key in self.key_failure_counts:
|
||||||
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
||||||
return key
|
return key
|
||||||
# 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空)
|
|
||||||
# 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整
|
|
||||||
if self.api_keys:
|
if self.api_keys:
|
||||||
return self.api_keys[0]
|
return self.api_keys[0]
|
||||||
# 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。
|
|
||||||
# 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。
|
|
||||||
# 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。
|
|
||||||
# 根据现有代码,如果api_keys为空,self.api_keys[0]会报错。
|
|
||||||
# 如果没有有效key且列表不空,返回第一个。若列表为空,这里会出IndexError。
|
|
||||||
# 更安全的做法是:
|
|
||||||
if not self.api_keys:
|
if not self.api_keys:
|
||||||
logger.warning("API key list is empty, cannot get first valid key.")
|
logger.warning("API key list is empty, cannot get first valid key.")
|
||||||
# Depending on desired behavior, either raise error or return an indicator like "" or None
|
|
||||||
# For now, let's allow it to potentially fail if a key is expected by caller
|
|
||||||
# but it's better to be explicit. Let's return empty string for consistency with handle_api_failure
|
|
||||||
return ""
|
return ""
|
||||||
return self.api_keys[
|
return self.api_keys[0]
|
||||||
0
|
|
||||||
] # Fallback to the first key if no key is "valid" but list is not empty
|
|
||||||
|
|
||||||
|
|
||||||
_singleton_instance = None
|
_singleton_instance = None
|
||||||
@@ -142,20 +128,13 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
|||||||
async with _singleton_lock:
|
async with _singleton_lock:
|
||||||
if _singleton_instance is None:
|
if _singleton_instance is None:
|
||||||
if api_keys is None:
|
if api_keys is None:
|
||||||
# This case needs careful handling. If it's the very first call, api_keys are required.
|
|
||||||
# If it's after a reset and no api_keys are provided, what should happen?
|
|
||||||
# The original ValueError was "API keys are required to initialize the KeyManager".
|
|
||||||
# Let's assume if api_keys is None here, it's an error unless we are restoring from non-None _preserved_old_api_keys_for_reset.
|
|
||||||
# However, the user's request implies new api_keys will be part of the reset flow.
|
|
||||||
# For now, stick to a strict requirement for api_keys if _singleton_instance is None.
|
|
||||||
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 not api_keys: # Handle case where api_keys is an empty list
|
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."
|
||||||
)
|
)
|
||||||
# Consider if this should be an error or allowed. Current KeyManager supports it.
|
|
||||||
|
|
||||||
_singleton_instance = KeyManager(api_keys)
|
_singleton_instance = KeyManager(api_keys)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -164,33 +143,28 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
|||||||
|
|
||||||
# 1. 恢复失败计数
|
# 1. 恢复失败计数
|
||||||
if _preserved_failure_counts:
|
if _preserved_failure_counts:
|
||||||
# Initialize new instance's failure_counts for all new keys to 0
|
|
||||||
current_failure_counts = {
|
current_failure_counts = {
|
||||||
key: 0 for key in _singleton_instance.api_keys
|
key: 0 for key in _singleton_instance.api_keys
|
||||||
}
|
}
|
||||||
# Inherit counts for keys that exist in both old and new lists
|
|
||||||
for key, count in _preserved_failure_counts.items():
|
for key, count in _preserved_failure_counts.items():
|
||||||
if key in current_failure_counts:
|
if key in current_failure_counts:
|
||||||
current_failure_counts[key] = count
|
current_failure_counts[key] = count
|
||||||
_singleton_instance.key_failure_counts = current_failure_counts
|
_singleton_instance.key_failure_counts = current_failure_counts
|
||||||
logger.info("Inherited failure counts for applicable keys.")
|
logger.info("Inherited failure counts for applicable keys.")
|
||||||
_preserved_failure_counts = None # Clear after use
|
_preserved_failure_counts = None
|
||||||
|
|
||||||
# 2. 调整 key_cycle 的起始点
|
# 2. 调整 key_cycle 的起始点
|
||||||
start_key_for_new_cycle = None
|
start_key_for_new_cycle = None
|
||||||
if (
|
if (
|
||||||
_preserved_old_api_keys_for_reset
|
_preserved_old_api_keys_for_reset
|
||||||
and _preserved_next_key_in_cycle
|
and _preserved_next_key_in_cycle
|
||||||
and _singleton_instance.api_keys # Ensure new api_keys list is not empty
|
and _singleton_instance.api_keys
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# Find the index of the preserved next key in the *old* list
|
|
||||||
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
|
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
|
||||||
_preserved_next_key_in_cycle
|
_preserved_next_key_in_cycle
|
||||||
)
|
)
|
||||||
|
|
||||||
# Iterate through the old key list (circularly) starting from _preserved_next_key_in_cycle
|
|
||||||
# Find the first key that also exists in the new api_keys list
|
|
||||||
for i in range(len(_preserved_old_api_keys_for_reset)):
|
for i in range(len(_preserved_old_api_keys_for_reset)):
|
||||||
current_old_key_idx = (start_idx_in_old + i) % len(
|
current_old_key_idx = (start_idx_in_old + i) % len(
|
||||||
_preserved_old_api_keys_for_reset
|
_preserved_old_api_keys_for_reset
|
||||||
@@ -214,26 +188,22 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
|||||||
|
|
||||||
if start_key_for_new_cycle and _singleton_instance.api_keys:
|
if start_key_for_new_cycle and _singleton_instance.api_keys:
|
||||||
try:
|
try:
|
||||||
# Find the index of the determined start_key in the new api_keys list
|
|
||||||
target_idx = _singleton_instance.api_keys.index(
|
target_idx = _singleton_instance.api_keys.index(
|
||||||
start_key_for_new_cycle
|
start_key_for_new_cycle
|
||||||
)
|
)
|
||||||
# Advance the new cycle by calling next() target_idx times
|
|
||||||
# This positions the cycle so that the *next* call to next() will yield start_key_for_new_cycle
|
|
||||||
for _ in range(target_idx):
|
for _ in range(target_idx):
|
||||||
next(_singleton_instance.key_cycle)
|
next(_singleton_instance.key_cycle)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
|
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# This should not happen if start_key_for_new_cycle was correctly found in api_keys
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
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
|
||||||
): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check
|
):
|
||||||
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."
|
||||||
)
|
)
|
||||||
@@ -254,7 +224,6 @@ 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
|
||||||
# _preserved_failure_counts already cleared
|
|
||||||
|
|
||||||
return _singleton_instance
|
return _singleton_instance
|
||||||
|
|
||||||
@@ -275,21 +244,16 @@ async def reset_key_manager_instance():
|
|||||||
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
|
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
|
||||||
|
|
||||||
# 3. 保存 key_cycle 的下一个 key 提示
|
# 3. 保存 key_cycle 的下一个 key 提示
|
||||||
# This should be the key that get_next_key() would return next.
|
|
||||||
try:
|
try:
|
||||||
if (
|
if _singleton_instance.api_keys:
|
||||||
_singleton_instance.api_keys
|
|
||||||
): # Only if there are keys to cycle through
|
|
||||||
# Calling get_next_key() consumes one key and returns it. This is the key
|
|
||||||
# we want the new cycle to effectively start with.
|
|
||||||
_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 # No keys, so no next key
|
_preserved_next_key_in_cycle = None
|
||||||
except (
|
except (
|
||||||
StopIteration
|
StopIteration
|
||||||
): # Should be caught by "if _singleton_instance.api_keys"
|
):
|
||||||
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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ logger = get_model_logger()
|
|||||||
|
|
||||||
class ModelService:
|
class ModelService:
|
||||||
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||||
"""使用 GeminiApiClient 获取并过滤模型列表"""
|
api_client = GeminiApiClient(base_url=settings.BASE_URL)
|
||||||
api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端
|
|
||||||
gemini_models = await api_client.get_models(api_key)
|
gemini_models = await api_client.get_models(api_key)
|
||||||
|
|
||||||
if gemini_models is None:
|
if gemini_models is None:
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ class OpenAICompatiableService:
|
|||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = str(e)
|
error_log_msg = str(e)
|
||||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||||
# Try to parse status code from exception
|
|
||||||
match = re.search(r"status code (\d+)", error_log_msg)
|
match = re.search(r"status code (\d+)", error_log_msg)
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
@@ -140,14 +139,12 @@ class OpenAICompatiableService:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||||
)
|
)
|
||||||
# Parse error code for logging
|
|
||||||
match = re.search(r"status code (\d+)", error_log_msg)
|
match = re.search(r"status code (\d+)", error_log_msg)
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
else:
|
else:
|
||||||
status_code = 500
|
status_code = 500
|
||||||
|
|
||||||
# Log error to error log table
|
|
||||||
await add_error_log(
|
await add_error_log(
|
||||||
gemini_key=current_attempt_key,
|
gemini_key=current_attempt_key,
|
||||||
model_name=model,
|
model_name=model,
|
||||||
@@ -157,8 +154,6 @@ class OpenAICompatiableService:
|
|||||||
request_msg=payload,
|
request_msg=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attempt to switch API Key
|
|
||||||
# Ensure key_manager is available (might need adjustment if not always passed)
|
|
||||||
if self.key_manager:
|
if self.key_manager:
|
||||||
api_key = await self.key_manager.handle_api_failure(
|
api_key = await self.key_manager.handle_api_failure(
|
||||||
current_attempt_key, retries
|
current_attempt_key, retries
|
||||||
@@ -178,7 +173,6 @@ class OpenAICompatiableService:
|
|||||||
logger.error(f"Max retries ({max_retries}) reached for streaming.")
|
logger.error(f"Max retries ({max_retries}) reached for streaming.")
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
# Log the final outcome of the streaming request
|
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
@@ -189,7 +183,6 @@ class OpenAICompatiableService:
|
|||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
request_time=request_datetime,
|
request_time=request_datetime,
|
||||||
)
|
)
|
||||||
# If the loop finished due to failure, yield error and DONE
|
|
||||||
if not is_success and retries >= max_retries:
|
if not is_success and retries >= max_retries:
|
||||||
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
|
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|
||||||
from app import database
|
from app.database.connection import database
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.database.models import RequestLog
|
from app.database.models import RequestLog
|
||||||
from app.log.logger import Logger
|
from app.log.logger import get_request_log_logger
|
||||||
|
|
||||||
logger = Logger.setup_logger("request_log_service")
|
logger = get_request_log_logger()
|
||||||
|
|
||||||
|
|
||||||
async def delete_old_request_logs_task():
|
async def delete_old_request_logs_task():
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class StatsService:
|
|||||||
),
|
),
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
(RequestLog.status_code is None, 1), # type: ignore
|
(RequestLog.status_code is None, 1),
|
||||||
else_=0,
|
else_=0,
|
||||||
)
|
)
|
||||||
).label("failure"),
|
).label("failure"),
|
||||||
@@ -96,7 +96,7 @@ class StatsService:
|
|||||||
),
|
),
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
(RequestLog.status_code is None, 1), # type: ignore
|
(RequestLog.status_code is None, 1),
|
||||||
else_=0,
|
else_=0,
|
||||||
)
|
)
|
||||||
).label("failure"),
|
).label("failure"),
|
||||||
@@ -166,25 +166,24 @@ class StatsService:
|
|||||||
RequestLog.request_time.label("timestamp"),
|
RequestLog.request_time.label("timestamp"),
|
||||||
RequestLog.api_key.label("key"),
|
RequestLog.api_key.label("key"),
|
||||||
RequestLog.model_name.label("model"),
|
RequestLog.model_name.label("model"),
|
||||||
RequestLog.status_code, # We might need to map this to 'success'/'failure' later
|
RequestLog.status_code,
|
||||||
)
|
)
|
||||||
.where(RequestLog.request_time >= start_time)
|
.where(RequestLog.request_time >= start_time)
|
||||||
.order_by(RequestLog.request_time.desc())
|
.order_by(RequestLog.request_time.desc())
|
||||||
) # Order by most recent first
|
)
|
||||||
|
|
||||||
results = await database.fetch_all(query)
|
results = await database.fetch_all(query)
|
||||||
|
|
||||||
# Convert results to list of dicts and map status_code
|
|
||||||
details = []
|
details = []
|
||||||
for row in results:
|
for row in results:
|
||||||
status = "failure" # 默认状态为 failure,如果 status_code 有效且在 200-299 范围内则更新为 success
|
status = "failure"
|
||||||
if row["status_code"] is not None: # 检查 status_code 是否为空
|
if row["status_code"] is not None:
|
||||||
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
||||||
details.append(
|
details.append(
|
||||||
{
|
{
|
||||||
"timestamp": row[
|
"timestamp": row[
|
||||||
"timestamp"
|
"timestamp"
|
||||||
].isoformat(), # Use ISO format for JS compatibility
|
].isoformat(),
|
||||||
"key": row["key"],
|
"key": row["key"],
|
||||||
"model": row["model"],
|
"model": row["model"],
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -197,7 +196,6 @@ class StatsService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
||||||
# Re-raise the exception to be handled by the route
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
|
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
|
||||||
@@ -225,10 +223,10 @@ class StatsService:
|
|||||||
.where(
|
.where(
|
||||||
RequestLog.api_key == key,
|
RequestLog.api_key == key,
|
||||||
RequestLog.request_time >= cutoff_time,
|
RequestLog.request_time >= cutoff_time,
|
||||||
RequestLog.model_name.isnot(None), # Ensure model_name is not null
|
RequestLog.model_name.isnot(None),
|
||||||
)
|
)
|
||||||
.group_by(RequestLog.model_name)
|
.group_by(RequestLog.model_name)
|
||||||
.order_by(func.count(RequestLog.id).desc()) # Order by count descending
|
.order_by(func.count(RequestLog.id).desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
results = await database.fetch_all(query)
|
results = await database.fetch_all(query)
|
||||||
@@ -237,7 +235,7 @@ class StatsService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"No usage details found for key ending in ...{key[-4:]} in the last 24h."
|
f"No usage details found for key ending in ...{key[-4:]} in the last 24h."
|
||||||
)
|
)
|
||||||
return {} # Return empty dict if no records found
|
return {}
|
||||||
|
|
||||||
usage_details = {row["model_name"]: row["call_count"] for row in results}
|
usage_details = {row["model_name"]: row["call_count"] for row in results}
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -250,6 +248,4 @@ class StatsService:
|
|||||||
f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}",
|
f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
# Depending on requirements, you might return None or raise the exception
|
raise
|
||||||
# Raising allows the route handler to return a 500 error.
|
|
||||||
raise # Re-raise the exception
|
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ from app.log.logger import get_update_logger
|
|||||||
|
|
||||||
logger = get_update_logger()
|
logger = get_update_logger()
|
||||||
|
|
||||||
# GitHub repository details are read from settings (defined in app/config/config.py or environment variables)
|
VERSION_FILE_PATH = "VERSION"
|
||||||
|
|
||||||
# GITHUB_API_URL will be constructed inside the function to ensure settings are loaded
|
|
||||||
|
|
||||||
VERSION_FILE_PATH = "VERSION" # Path relative to project root
|
|
||||||
|
|
||||||
async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
|
async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -24,9 +20,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
|
|||||||
- Optional[str]: 如果检查失败,则为错误消息,否则为 None。
|
- Optional[str]: 如果检查失败,则为错误消息,否则为 None。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Read current version from VERSION file
|
|
||||||
# Ensure the path is correct relative to the execution context or use absolute path if needed
|
|
||||||
# Assuming execution from project root d:/develop/pythonProjects/gemini-balance
|
|
||||||
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
|
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
|
||||||
current_v = f.read().strip()
|
current_v = f.read().strip()
|
||||||
if not current_v:
|
if not current_v:
|
||||||
@@ -41,25 +34,22 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
|
|||||||
|
|
||||||
logger.info(f"当前应用程序版本 (from {VERSION_FILE_PATH}): {current_v}")
|
logger.info(f"当前应用程序版本 (from {VERSION_FILE_PATH}): {current_v}")
|
||||||
|
|
||||||
# Check if repository details are configured in settings
|
|
||||||
if not settings.GITHUB_REPO_OWNER or not settings.GITHUB_REPO_NAME or \
|
if not settings.GITHUB_REPO_OWNER or not settings.GITHUB_REPO_NAME or \
|
||||||
settings.GITHUB_REPO_OWNER == "your_owner" or settings.GITHUB_REPO_NAME == "your_repo":
|
settings.GITHUB_REPO_OWNER == "your_owner" or settings.GITHUB_REPO_NAME == "your_repo":
|
||||||
logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.")
|
logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.")
|
||||||
return False, None, "Update check skipped: Repository not configured in settings."
|
return False, None, "Update check skipped: Repository not configured in settings."
|
||||||
|
|
||||||
# Construct the API URL inside the function to ensure settings are loaded
|
|
||||||
github_api_url = f"https://api.github.com/repos/{settings.GITHUB_REPO_OWNER}/{settings.GITHUB_REPO_NAME}/releases/latest"
|
github_api_url = f"https://api.github.com/repos/{settings.GITHUB_REPO_OWNER}/{settings.GITHUB_REPO_NAME}/releases/latest"
|
||||||
logger.debug(f"Checking for updates at URL: {github_api_url}") # Log the URL for debugging
|
logger.debug(f"Checking for updates at URL: {github_api_url}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
# 添加 User-Agent 头,GitHub API 可能需要
|
|
||||||
headers = {
|
headers = {
|
||||||
"Accept": "application/vnd.github.v3+json",
|
"Accept": "application/vnd.github.v3+json",
|
||||||
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0" # Use repo name from settings for User-Agent
|
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0"
|
||||||
}
|
}
|
||||||
response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL
|
response = await client.get(github_api_url, headers=headers)
|
||||||
response.raise_for_status() # 对错误的 HTTP 状态码(4xx 或 5xx)抛出异常
|
response.raise_for_status()
|
||||||
|
|
||||||
latest_release = response.json()
|
latest_release = response.json()
|
||||||
latest_v_str = latest_release.get("tag_name")
|
latest_v_str = latest_release.get("tag_name")
|
||||||
@@ -68,7 +58,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
|
|||||||
logger.warning("在最新的 GitHub release 响应中找不到 'tag_name'。")
|
logger.warning("在最新的 GitHub release 响应中找不到 'tag_name'。")
|
||||||
return False, None, "无法从 GitHub 解析最新版本。"
|
return False, None, "无法从 GitHub 解析最新版本。"
|
||||||
|
|
||||||
# 移除 tag 名称中可能存在的 'v' 前缀
|
|
||||||
if latest_v_str.startswith('v'):
|
if latest_v_str.startswith('v'):
|
||||||
latest_v_str = latest_v_str[1:]
|
latest_v_str = latest_v_str[1:]
|
||||||
|
|
||||||
@@ -98,8 +87,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
|
|||||||
logger.error(f"检查更新时发生网络错误: {e}")
|
logger.error(f"检查更新时发生网络错误: {e}")
|
||||||
return False, None, "更新检查期间发生网络错误。"
|
return False, None, "更新检查期间发生网络错误。"
|
||||||
except version.InvalidVersion:
|
except version.InvalidVersion:
|
||||||
# Note: latest_v_str might not be defined if the error occurs before fetching it.
|
|
||||||
# Consider adding a check or default value for logging.
|
|
||||||
latest_v_str_for_log = latest_v_str if 'latest_v_str' in locals() else 'N/A'
|
latest_v_str_for_log = latest_v_str if 'latest_v_str' in locals() else 'N/A'
|
||||||
logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'")
|
logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'")
|
||||||
return False, None, "遇到无效的版本格式。"
|
return False, None, "遇到无效的版本格式。"
|
||||||
|
|||||||
+876
-710
File diff suppressed because it is too large
Load Diff
@@ -133,41 +133,69 @@ endblock %} {% block head_extra_styles %}
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pagination custom styles */
|
/* New Pagination Styles (inspired by keys_status.html) */
|
||||||
.pagination li a, .pagination li span { /* Assuming 'span' might be used for non-clickable items like '...' */
|
ul.pagination a {
|
||||||
display: flex; /* For centering content if icons are used */
|
/* Targets the <a> tags directly within ul.pagination */
|
||||||
|
display: inline-flex; /* Consistent with flex from addPaginationLink */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem 0.75rem; /* Adjust padding as needed */
|
/* Tailwind classes from JS will handle padding, border-radius, font-size, transition */
|
||||||
line-height: 1.25;
|
/* Defaults for non-active, non-disabled, non-hover buttons */
|
||||||
color: #e2e8f0; /* Light gray/white text */
|
background-color: rgba(80, 60, 160, 0.8);
|
||||||
background-color: rgba(107, 70, 193, 0.4); /* Consistent with other buttons */
|
|
||||||
border: 1px solid rgba(120, 100, 200, 0.6); /* Consistent with other buttons */
|
|
||||||
border-radius: 0.375rem; /* Tailwind's rounded-md */
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
min-width: 36px; /* Ensure minimum width for small numbers */
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination li a:hover, .pagination li span:hover:not(.disabled) { /* Avoid hover on disabled spans */
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: rgba(120, 100, 200, 0.6); /* Consistent with other button hovers */
|
border: 1px solid rgba(120, 100, 200, 0.4);
|
||||||
border-color: rgba(167, 139, 250, 0.8);
|
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
min-width: 36px; /* Retain from original error_logs for consistency */
|
||||||
|
text-align: center; /* Retain from original error_logs for consistency */
|
||||||
|
/* Ensure base transition if not fully handled by JS's Tailwind classes */
|
||||||
|
transition: background-color 0.15s ease-in-out,
|
||||||
|
border-color 0.15s ease-in-out, color 0.15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination li.active a, .pagination li.active span { /* Assuming 'active' class for current page */
|
ul.pagination a:hover:not(.active):not(.disabled) {
|
||||||
color: #ffffff !important;
|
/* Hover for non-active, non-disabled */
|
||||||
background-color: #7c3aed !important; /* Violet-600, ensure it overrides */
|
background-color: rgba(
|
||||||
border-color: #7c3aed !important;
|
100,
|
||||||
|
80,
|
||||||
|
180,
|
||||||
|
0.9
|
||||||
|
); /* Slightly lighter/more interactive purple */
|
||||||
|
border-color: rgba(140, 120, 220, 0.7);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.pagination a.active {
|
||||||
|
/* Active state */
|
||||||
|
background-color: rgba(120, 100, 200, 0.9);
|
||||||
|
border-color: rgba(150, 130, 230, 0.7);
|
||||||
|
color: #ffffff; /* Ensure text is white */
|
||||||
font-weight: 600; /* Make active page number bolder */
|
font-weight: 600; /* Make active page number bolder */
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination li.disabled a, .pagination li.disabled span { /* Assuming 'disabled' class */
|
ul.pagination a.disabled {
|
||||||
color: rgba(226, 232, 240, 0.6) !important;
|
/* Disabled state for '...' or prev/next unavailable */
|
||||||
background-color: rgba(80, 60, 160, 0.3) !important; /* Slightly more visible than pure disabled */
|
background-color: rgba(
|
||||||
border-color: rgba(120, 100, 200, 0.4) !important;
|
80,
|
||||||
|
60,
|
||||||
|
160,
|
||||||
|
0.3
|
||||||
|
) !important; /* Use existing disabled bg */
|
||||||
|
color: rgba(
|
||||||
|
226,
|
||||||
|
232,
|
||||||
|
240,
|
||||||
|
0.6
|
||||||
|
) !important; /* Use existing disabled text color */
|
||||||
|
border-color: rgba(
|
||||||
|
120,
|
||||||
|
100,
|
||||||
|
200,
|
||||||
|
0.4
|
||||||
|
) !important; /* Use existing disabled border color */
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %} {% block content %}
|
{% endblock %} {% block content %}
|
||||||
@@ -303,6 +331,13 @@ endblock %} {% block head_extra_styles %}
|
|||||||
>
|
>
|
||||||
<i class="fas fa-trash-alt mr-1.5"></i>删除
|
<i class="fas fa-trash-alt mr-1.5"></i>删除
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="deleteAllLogsBtn"
|
||||||
|
class="flex items-center justify-center px-4 py-1.5 bg-red-700 hover:bg-red-800 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap"
|
||||||
|
title="清空所有错误日志"
|
||||||
|
>
|
||||||
|
<i class="fas fa-dumpster-fire mr-1.5"></i>清空全部
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -323,15 +358,20 @@ endblock %} {% block head_extra_styles %}
|
|||||||
class="form-checkbox h-4 w-4 text-violet-500 border-gray-500 rounded focus:ring-violet-500 bg-transparent"
|
class="form-checkbox h-4 w-4 text-violet-500 border-gray-500 rounded focus:ring-violet-500 bg-transparent"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
|
<th
|
||||||
|
class="px-5 py-3 font-semibold text-white cursor-pointer"
|
||||||
|
id="sortById"
|
||||||
|
>
|
||||||
ID <i class="fas fa-sort ml-1"></i>
|
ID <i class="fas fa-sort ml-1"></i>
|
||||||
</th>
|
</th>
|
||||||
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
|
<th class="px-5 py-3 font-semibold text-white">Gemini密钥</th>
|
||||||
<th class="px-5 py-3 font-semibold">错误类型</th>
|
<th class="px-5 py-3 font-semibold text-white">错误类型</th>
|
||||||
<th class="px-5 py-3 font-semibold">错误码</th>
|
<th class="px-5 py-3 font-semibold text-white">错误码</th>
|
||||||
<th class="px-5 py-3 font-semibold">模型名称</th>
|
<th class="px-5 py-3 font-semibold text-white">模型名称</th>
|
||||||
<th class="px-5 py-3 font-semibold">请求时间</th>
|
<th class="px-5 py-3 font-semibold text-white">请求时间</th>
|
||||||
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">
|
<th
|
||||||
|
class="px-5 py-3 font-semibold text-white rounded-tr-lg text-center"
|
||||||
|
>
|
||||||
操作
|
操作
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ import base64
|
|||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import logging # Import logging
|
import logging
|
||||||
|
|
||||||
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
||||||
|
|
||||||
# Define logger for helper functions if needed, or use specific loggers
|
helper_logger = logging.getLogger("app.utils")
|
||||||
helper_logger = logging.getLogger("app.utils") # Or use a more specific logger if available
|
|
||||||
|
|
||||||
# Define project root and version file path here for get_current_version
|
|
||||||
# Assuming this file is at app/utils/helpers.py
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
|
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
|
||||||
|
|
||||||
@@ -159,9 +156,8 @@ def is_valid_api_key(key: str) -> bool:
|
|||||||
|
|
||||||
def get_current_version(default_version: str = "0.0.0") -> str:
|
def get_current_version(default_version: str = "0.0.0") -> str:
|
||||||
"""Reads the current version from the VERSION file."""
|
"""Reads the current version from the VERSION file."""
|
||||||
version_file = VERSION_FILE_PATH # Use Path object defined above
|
version_file = VERSION_FILE_PATH
|
||||||
try:
|
try:
|
||||||
# Use Path object's open method
|
|
||||||
with version_file.open('r', encoding='utf-8') as f:
|
with version_file.open('r', encoding='utf-8') as f:
|
||||||
version = f.read().strip()
|
version = f.read().strip()
|
||||||
if not version:
|
if not version:
|
||||||
|
|||||||
+1
-2
@@ -9,8 +9,7 @@ uvicorn
|
|||||||
google-genai
|
google-genai
|
||||||
jinja2
|
jinja2
|
||||||
python-multipart
|
python-multipart
|
||||||
cryptography # 支持 MySQL 8+ caching_sha2_password 验证
|
cryptography
|
||||||
# 数据库相关依赖
|
|
||||||
pymysql
|
pymysql
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
aiomysql
|
aiomysql
|
||||||
|
|||||||
Reference in New Issue
Block a user