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:
snaily
2025-05-14 14:25:04 +08:00
parent 67f87989db
commit 4becc8d4d4
27 changed files with 1116 additions and 1000 deletions
+8 -8
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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)}")
+1 -11
View File
@@ -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,
+3
View File
@@ -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")
-9
View File
@@ -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
+22
View File
@@ -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"
)
+2 -5
View File
@@ -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."
) )
+13 -20
View File
@@ -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
) )
+1 -5
View File
@@ -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()
+1 -3
View File
@@ -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]:
+1 -6
View File
@@ -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)}"
) )
+13 -17
View File
@@ -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
+1 -1
View File
@@ -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
+8 -44
View File
@@ -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."
) )
+1 -2
View File
@@ -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():
+11 -15
View File
@@ -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
+5 -18
View File
@@ -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, "遇到无效的版本格式。"
File diff suppressed because it is too large Load Diff
+72 -32
View File
@@ -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>
+3 -7
View File
@@ -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
View File
@@ -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