mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-06-25 17:54:21 +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:
|
||||
"""尝试将数据库字符串值解析为目标 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:
|
||||
# 处理 List[str]
|
||||
if target_type == List[str]:
|
||||
@@ -234,9 +234,9 @@ async def sync_initial_settings():
|
||||
2. 将数据库设置合并到内存 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.models import Settings as SettingsModel
|
||||
@@ -360,14 +360,14 @@ async def sync_initial_settings():
|
||||
continue
|
||||
|
||||
# 序列化值为字符串或 JSON 字符串
|
||||
if isinstance(value, (list, dict)): # 处理列表和字典
|
||||
if isinstance(value, (list, dict)):
|
||||
db_value = json.dumps(
|
||||
value, ensure_ascii=False
|
||||
) # 使用 ensure_ascii=False 以支持非 ASCII 字符
|
||||
)
|
||||
elif isinstance(value, bool):
|
||||
db_value = str(value).lower()
|
||||
elif value is None: # 处理 None 值
|
||||
db_value = "" # 或者根据需要设为 NULL 或其他标记
|
||||
elif value is None:
|
||||
db_value = ""
|
||||
else:
|
||||
db_value = str(value)
|
||||
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path # Add pathlib import
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
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.middleware.middleware import setup_middlewares
|
||||
from app.exception.exceptions import setup_exception_handlers
|
||||
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.service.key.key_manager import get_key_manager_instance
|
||||
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()
|
||||
|
||||
# Define project paths using pathlib
|
||||
# Assuming this file is at app/core/application.py
|
||||
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"
|
||||
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
|
||||
|
||||
# Removed _get_current_version function definition, moved to helpers.py
|
||||
|
||||
# 初始化模板引擎,并添加全局变量
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# 定义一个函数来更新模板全局变量
|
||||
def update_template_globals(app: FastAPI, update_info: dict):
|
||||
# Jinja2Templates 实例没有直接更新全局变量的方法
|
||||
@@ -40,114 +37,105 @@ def update_template_globals(app: FastAPI, update_info: dict):
|
||||
|
||||
|
||||
# --- Helper functions for lifespan ---
|
||||
|
||||
async def _setup_database_and_config(app_settings):
|
||||
"""Initializes database, syncs settings, and initializes KeyManager."""
|
||||
initialize_database()
|
||||
await initialize_database()
|
||||
logger.info("Database initialized successfully")
|
||||
await connect_to_db()
|
||||
await sync_initial_settings()
|
||||
# Initialize KeyManager using potentially updated settings
|
||||
await get_key_manager_instance(app_settings.API_KEYS)
|
||||
logger.info("Database, config sync, and KeyManager initialized successfully")
|
||||
|
||||
|
||||
async def _shutdown_database():
|
||||
"""Disconnects from the database."""
|
||||
await disconnect_from_db()
|
||||
|
||||
|
||||
def _start_scheduler():
|
||||
"""Starts the background scheduler."""
|
||||
try:
|
||||
start_scheduler()
|
||||
logger.info("Scheduler started successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
|
||||
|
||||
def _stop_scheduler():
|
||||
"""Stops the background scheduler."""
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
async def _perform_update_check(app: FastAPI):
|
||||
"""Checks for updates and stores the info in app.state."""
|
||||
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_available": update_available,
|
||||
"latest_version": latest_version,
|
||||
"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"):
|
||||
from starlette.datastructures import State
|
||||
|
||||
app.state = State()
|
||||
app.state.update_info = update_info
|
||||
logger.info(f"Update check completed. Info: {update_info}")
|
||||
|
||||
# --- Application Lifespan ---
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Manages the application startup and shutdown events.
|
||||
|
||||
|
||||
Args:
|
||||
app: FastAPI应用实例
|
||||
"""
|
||||
# Startup events
|
||||
logger.info("Application starting up...")
|
||||
try:
|
||||
# Setup database, config, and KeyManager
|
||||
await _setup_database_and_config(settings) # Pass settings object
|
||||
|
||||
# Perform update check after core components are ready
|
||||
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
|
||||
|
||||
# Start the scheduler
|
||||
await _setup_database_and_config(settings)
|
||||
await _perform_update_check(app)
|
||||
_start_scheduler()
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"Critical error during application startup: {str(e)}", exc_info=True)
|
||||
# Depending on the severity, you might want to prevent the app from fully starting
|
||||
# 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.
|
||||
logger.critical(
|
||||
f"Critical error during application startup: {str(e)}", exc_info=True
|
||||
)
|
||||
|
||||
yield # Application runs
|
||||
yield
|
||||
|
||||
# Shutdown events
|
||||
logger.info("Application shutting down...")
|
||||
_stop_scheduler()
|
||||
await _shutdown_database()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
创建并配置FastAPI应用程序实例
|
||||
|
||||
|
||||
Returns:
|
||||
FastAPI: 配置好的FastAPI应用程序实例
|
||||
"""
|
||||
# Removed: initialize_app() call
|
||||
|
||||
# 创建FastAPI应用
|
||||
# Read version from file for consistency
|
||||
current_version = get_current_version() # Use imported function
|
||||
current_version = get_current_version()
|
||||
app = FastAPI(
|
||||
title="Gemini Balance API",
|
||||
description="Gemini API代理服务,支持负载均衡和密钥管理",
|
||||
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"):
|
||||
from starlette.datastructures import State
|
||||
|
||||
app.state = State()
|
||||
# Set a default/initial state for update_info
|
||||
app.state.update_info = {
|
||||
"update_available": False,
|
||||
"latest_version": None,
|
||||
"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_exception_handlers(app)
|
||||
|
||||
|
||||
# 配置路由
|
||||
setup_routers(app)
|
||||
|
||||
|
||||
return app
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
from pathlib import Path
|
||||
from databases import Database
|
||||
from sqlalchemy import create_engine, MetaData
|
||||
# from sqlalchemy.orm import sessionmaker # 不再需要
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from app.config.config import settings
|
||||
|
||||
@@ -42,11 +42,12 @@ class ErrorLog(Base):
|
||||
def __repr__(self):
|
||||
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
|
||||
|
||||
# 新增 RequestLog 模型
|
||||
|
||||
class RequestLog(Base):
|
||||
"""
|
||||
API 请求日志表
|
||||
"""
|
||||
|
||||
__tablename__ = "t_request_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -71,7 +71,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
||||
.values(
|
||||
value=value,
|
||||
description=description if description else setting["description"],
|
||||
updated_at=datetime.now() # Use datetime.now()
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
)
|
||||
await database.execute(query)
|
||||
@@ -85,8 +85,8 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
||||
key=key,
|
||||
value=value,
|
||||
description=description,
|
||||
created_at=datetime.now(), # Use datetime.now()
|
||||
updated_at=datetime.now() # Use datetime.now()
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
)
|
||||
await database.execute(query)
|
||||
@@ -158,8 +158,8 @@ async def get_error_logs(
|
||||
error_code_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
sort_by: str = 'id', # 新增排序字段
|
||||
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
|
||||
sort_by: str = 'id',
|
||||
sort_order: str = 'desc'
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取错误日志,支持搜索、日期过滤和排序
|
||||
@@ -200,28 +200,20 @@ async def get_error_logs(
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
if end_date:
|
||||
# Use the datetime object directly for comparison
|
||||
query = query.where(ErrorLog.request_time < end_date)
|
||||
if error_code_search:
|
||||
try:
|
||||
# Attempt to convert search string to integer for exact match
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
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.")
|
||||
# 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) # 获取排序字段,默认为 id
|
||||
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id)
|
||||
if sort_order.lower() == 'asc':
|
||||
query = query.order_by(asc(sort_column))
|
||||
else:
|
||||
query = query.order_by(desc(sort_column))
|
||||
|
||||
# Apply limit and offset
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await database.fetch_all(query)
|
||||
@@ -254,7 +246,6 @@ async def get_error_logs_count(
|
||||
try:
|
||||
query = select(func.count()).select_from(ErrorLog)
|
||||
|
||||
# Apply the same filters as get_error_logs
|
||||
if key_search:
|
||||
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
|
||||
if error_search:
|
||||
@@ -265,23 +256,19 @@ async def get_error_logs_count(
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
if end_date:
|
||||
# Use the datetime object directly for comparison
|
||||
query = query.where(ErrorLog.request_time < end_date)
|
||||
if error_code_search:
|
||||
try:
|
||||
# Attempt to convert search string to integer for exact match
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
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.")
|
||||
# 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)
|
||||
return count_result[0] if count_result else 0
|
||||
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
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
raise
|
||||
|
||||
# --- 异步删除函数 (使用 databases 库) ---
|
||||
|
||||
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:
|
||||
# 数据库连接或执行错误
|
||||
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:
|
||||
"""
|
||||
@@ -364,7 +350,7 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
|
||||
if not exists:
|
||||
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)
|
||||
@@ -373,9 +359,7 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
return True
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
# --- RequestLog Services (保持异步) ---
|
||||
raise
|
||||
|
||||
# 新增函数:添加请求日志
|
||||
async def add_request_log(
|
||||
@@ -412,7 +396,6 @@ async def add_request_log(
|
||||
latency_ms=latency_ms
|
||||
)
|
||||
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
|
||||
except Exception as 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}")
|
||||
|
||||
try:
|
||||
# Decode Base64 to check size
|
||||
# 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
|
||||
decoded_data = base64.b64decode(data, validate=True)
|
||||
if len(decoded_data) > max_size:
|
||||
logger.error(
|
||||
f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)."
|
||||
@@ -141,7 +136,6 @@ class OpenAIMessageConverter(MessageConverter):
|
||||
raise ValueError(
|
||||
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
|
||||
except base64.binascii.Error as 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):
|
||||
for content_item in msg["content"]:
|
||||
if not isinstance(content_item, dict):
|
||||
# Skip non-dict items if any unexpected format appears
|
||||
logger.warning(
|
||||
f"Skipping unexpected content item format: {type(content_item)}"
|
||||
)
|
||||
@@ -184,13 +177,11 @@ class OpenAIMessageConverter(MessageConverter):
|
||||
logger.error(
|
||||
f"Failed to convert image URL {content_item['image_url']['url']}: {e}"
|
||||
)
|
||||
# Decide how to handle: skip part, add error text, etc.
|
||||
parts.append(
|
||||
{
|
||||
"text": f"[Error processing image: {content_item['image_url']['url']}]"
|
||||
}
|
||||
)
|
||||
# --- Add handling for input_audio ---
|
||||
elif content_type == "input_audio" and content_item.get(
|
||||
"input_audio"
|
||||
):
|
||||
@@ -205,7 +196,6 @@ class OpenAIMessageConverter(MessageConverter):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Validate size and format
|
||||
validated_data = self._validate_media_data(
|
||||
audio_format,
|
||||
audio_data,
|
||||
|
||||
@@ -223,3 +223,6 @@ def get_openai_compatible_logger():
|
||||
def get_error_log_logger():
|
||||
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))
|
||||
|
||||
|
||||
# Pydantic model for bulk delete request
|
||||
class DeleteKeysRequest(BaseModel):
|
||||
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}")
|
||||
result = await ConfigService.delete_key(key_to_delete)
|
||||
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(
|
||||
status_code=(
|
||||
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
|
||||
except HTTPException as e:
|
||||
# Re-raise HTTPExceptions directly
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
|
||||
@@ -104,14 +99,10 @@ async def delete_selected_keys_route(
|
||||
try:
|
||||
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} 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 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(
|
||||
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
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
|
||||
@@ -209,3 +209,25 @@ async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
|
||||
raise HTTPException(
|
||||
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=[
|
||||
GeminiContent(
|
||||
role="user",
|
||||
parts=[{"text": "hi"}], # 使用简单的文本进行验证
|
||||
parts=[{"text": "hi"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
# 调用 generate_content 进行验证
|
||||
await chat_service.generate_content(
|
||||
settings.TEST_MODEL, gemini_request, key # 使用配置中定义的测试模型
|
||||
settings.TEST_MODEL, gemini_request, key
|
||||
)
|
||||
# 如果没有抛出异常,说明 key 有效
|
||||
logger.info(
|
||||
f"Key {log_key} verification successful. Resetting failure count."
|
||||
)
|
||||
await key_manager.reset_key_failure_count(key)
|
||||
except Exception as e:
|
||||
# 验证失败,增加失败计数
|
||||
logger.warning(
|
||||
f"Key {log_key} verification failed: {str(e)}. Incrementing failure count."
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ class GeminiChatService:
|
||||
"""生成内容"""
|
||||
payload = _build_payload(model, request)
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now() # Record request time
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
@@ -152,20 +152,18 @@ class GeminiChatService:
|
||||
try:
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
status_code = 200
|
||||
return self.response_handler.handle_response(response, model, stream=False)
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||
# Try to parse status code from exception
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500 # Default to 500 if parsing fails
|
||||
status_code = 500
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
@@ -174,11 +172,10 @@ class GeminiChatService:
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
)
|
||||
raise e # Re-throw exception for upstream handling
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
# Log request to request log table
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
@@ -240,16 +237,14 @@ class GeminiChatService:
|
||||
logger.warning(
|
||||
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)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key, # Log key used for this failed attempt
|
||||
gemini_key=current_attempt_key,
|
||||
model_name=model,
|
||||
error_type="gemini-chat-stream",
|
||||
error_log=error_log_msg,
|
||||
@@ -257,28 +252,26 @@ class GeminiChatService:
|
||||
request_msg=payload
|
||||
)
|
||||
|
||||
# Attempt to switch API Key
|
||||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
else: # No more keys or retries exceeded by handle_api_failure logic
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break # Exit loop if no key available
|
||||
else:
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
)
|
||||
break # Exit loop after max retries
|
||||
break
|
||||
finally:
|
||||
# Log the final outcome of the streaming request
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=final_api_key, # Log the last key used
|
||||
is_success=is_success, # Log the final success status
|
||||
status_code=status_code, # Log the last known status code
|
||||
latency_ms=latency_ms, # Log total time including retries
|
||||
api_key=final_api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime
|
||||
)
|
||||
|
||||
@@ -173,7 +173,7 @@ class OpenAIChatService:
|
||||
self, original_chunk: Dict[str, Any], text: str
|
||||
) -> Dict[str, Any]:
|
||||
"""创建包含指定文本的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]:
|
||||
chunk_copy["choices"][0]["delta"]["content"] = text
|
||||
return chunk_copy
|
||||
@@ -184,10 +184,8 @@ class OpenAIChatService:
|
||||
api_key: str,
|
||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||
"""创建聊天完成"""
|
||||
# 转换消息格式
|
||||
messages, instruction = self.message_converter.convert(request.messages)
|
||||
|
||||
# 构建请求payload
|
||||
payload = _build_payload(request, messages, instruction)
|
||||
|
||||
if request.stream:
|
||||
@@ -219,7 +217,6 @@ class OpenAIChatService:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
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)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
@@ -587,7 +584,6 @@ class OpenAIChatService:
|
||||
error_code=status_code,
|
||||
request_msg={"image_data_truncated": image_data[:1000]},
|
||||
)
|
||||
# Re-raise the exception so the caller knows about the failure
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
|
||||
@@ -53,16 +53,14 @@ class GeminiApiClient(ApiClient):
|
||||
url = f"{self.base_url}/models?key={api_key}"
|
||||
try:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status() # 如果状态码不是 2xx,则引发 HTTPStatusError
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"获取模型列表失败: {e.response.status_code}")
|
||||
logger.error(e.response.text)
|
||||
# 返回 None 而不是抛出异常,以便上层处理
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"请求模型列表失败: {e}")
|
||||
# 返回 None 而不是抛出异常
|
||||
return None
|
||||
|
||||
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:
|
||||
# Preserve original description if not explicitly provided
|
||||
data["description"] = existing_settings_map[key].get(
|
||||
"description", description
|
||||
)
|
||||
@@ -111,7 +110,7 @@ class ConfigService:
|
||||
logger.info(f"Updated {len(settings_to_update)} settings.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to bulk update/insert settings: {str(e)}")
|
||||
raise # Re-raise the exception after logging
|
||||
raise
|
||||
|
||||
# 重置并重新初始化 KeyManager
|
||||
try:
|
||||
@@ -120,8 +119,6 @@ class ConfigService:
|
||||
logger.info("KeyManager instance re-initialized with updated settings.")
|
||||
except Exception as 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()
|
||||
|
||||
@@ -244,13 +241,11 @@ class ConfigService:
|
||||
models = await model_service.get_gemini_openai_models(api_key)
|
||||
return models
|
||||
except HTTPException as e:
|
||||
# Re-raise HTTPExceptions directly if they are already specific
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
|
||||
)
|
||||
# Raise a generic HTTPException for other errors
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import datetime
|
||||
import time
|
||||
import re # For potential status code parsing from generic errors
|
||||
import re
|
||||
from typing import List, Union
|
||||
|
||||
import openai
|
||||
from openai import APIStatusError # Import specific error type
|
||||
from openai import APIStatusError
|
||||
from openai.types import CreateEmbeddingResponse
|
||||
|
||||
from app.config.config import settings
|
||||
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()
|
||||
|
||||
@@ -26,7 +26,6 @@ class EmbeddingService:
|
||||
status_code = None
|
||||
response = None
|
||||
error_log_msg = ""
|
||||
# Prepare request message for logging (truncate if list or long string)
|
||||
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]]}
|
||||
if len(input_text) > 5:
|
||||
@@ -46,32 +45,29 @@ class EmbeddingService:
|
||||
status_code = e.status_code
|
||||
error_log_msg = f"OpenAI API error: {e}"
|
||||
logger.error(f"Error creating embedding (APIStatusError): {error_log_msg}")
|
||||
raise e # Re-raise the specific error
|
||||
raise e
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = f"Generic error: {e}"
|
||||
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))
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500 # Default if parsing fails
|
||||
raise e # Re-raise the generic error
|
||||
status_code = 500
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
if not is_success:
|
||||
# Log error to database if it failed
|
||||
await add_error_log(
|
||||
gemini_key=api_key, # Using gemini_key parameter name for consistency
|
||||
model_name=model,
|
||||
error_type="openai-embedding",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=request_msg_log
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="openai-embedding",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=request_msg_log
|
||||
)
|
||||
# Log request outcome to database regardless of success/failure
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
|
||||
@@ -153,3 +153,27 @@ async def process_delete_error_log_by_id(log_id: int) -> bool:
|
||||
exc_info=True,
|
||||
)
|
||||
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 = {
|
||||
"created": int(time.time()), # Current timestamp
|
||||
"created": int(time.time()),
|
||||
"data": images_data,
|
||||
}
|
||||
return response_data
|
||||
|
||||
@@ -60,7 +60,6 @@ class KeyManager:
|
||||
|
||||
current_key = await self.get_next_key()
|
||||
if current_key == initial_key:
|
||||
# await self.reset_failure_counts() 取消重置
|
||||
return current_key
|
||||
|
||||
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:
|
||||
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
||||
return key
|
||||
# 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空)
|
||||
# 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整
|
||||
if self.api_keys:
|
||||
return self.api_keys[0]
|
||||
# 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。
|
||||
# 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。
|
||||
# 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。
|
||||
# 根据现有代码,如果api_keys为空,self.api_keys[0]会报错。
|
||||
# 如果没有有效key且列表不空,返回第一个。若列表为空,这里会出IndexError。
|
||||
# 更安全的做法是:
|
||||
if not self.api_keys:
|
||||
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 self.api_keys[
|
||||
0
|
||||
] # Fallback to the first key if no key is "valid" but list is not empty
|
||||
return self.api_keys[0]
|
||||
|
||||
|
||||
_singleton_instance = None
|
||||
@@ -142,20 +128,13 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
||||
async with _singleton_lock:
|
||||
if _singleton_instance 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(
|
||||
"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(
|
||||
"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)
|
||||
logger.info(
|
||||
@@ -164,33 +143,28 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
||||
|
||||
# 1. 恢复失败计数
|
||||
if _preserved_failure_counts:
|
||||
# Initialize new instance's failure_counts for all new keys to 0
|
||||
current_failure_counts = {
|
||||
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():
|
||||
if key in current_failure_counts:
|
||||
current_failure_counts[key] = count
|
||||
_singleton_instance.key_failure_counts = current_failure_counts
|
||||
logger.info("Inherited failure counts for applicable keys.")
|
||||
_preserved_failure_counts = None # Clear after use
|
||||
_preserved_failure_counts = None
|
||||
|
||||
# 2. 调整 key_cycle 的起始点
|
||||
start_key_for_new_cycle = None
|
||||
if (
|
||||
_preserved_old_api_keys_for_reset
|
||||
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:
|
||||
# Find the index of the preserved next key in the *old* list
|
||||
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
|
||||
_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)):
|
||||
current_old_key_idx = (start_idx_in_old + i) % len(
|
||||
_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:
|
||||
try:
|
||||
# Find the index of the determined start_key in the new api_keys list
|
||||
target_idx = _singleton_instance.api_keys.index(
|
||||
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):
|
||||
next(_singleton_instance.key_cycle)
|
||||
logger.info(
|
||||
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
|
||||
)
|
||||
except ValueError:
|
||||
# This should not happen if start_key_for_new_cycle was correctly found in api_keys
|
||||
logger.warning(
|
||||
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."
|
||||
)
|
||||
except (
|
||||
StopIteration
|
||||
): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check
|
||||
):
|
||||
logger.error(
|
||||
"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_next_key_in_cycle = None
|
||||
# _preserved_failure_counts already cleared
|
||||
|
||||
return _singleton_instance
|
||||
|
||||
@@ -275,21 +244,16 @@ async def reset_key_manager_instance():
|
||||
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
|
||||
|
||||
# 3. 保存 key_cycle 的下一个 key 提示
|
||||
# This should be the key that get_next_key() would return next.
|
||||
try:
|
||||
if (
|
||||
_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.
|
||||
if _singleton_instance.api_keys:
|
||||
_preserved_next_key_in_cycle = (
|
||||
await _singleton_instance.get_next_key()
|
||||
)
|
||||
else:
|
||||
_preserved_next_key_in_cycle = None # No keys, so no next key
|
||||
_preserved_next_key_in_cycle = None
|
||||
except (
|
||||
StopIteration
|
||||
): # Should be caught by "if _singleton_instance.api_keys"
|
||||
):
|
||||
logger.warning(
|
||||
"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:
|
||||
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)
|
||||
|
||||
if gemini_models is None:
|
||||
|
||||
@@ -79,7 +79,6 @@ class OpenAICompatiableService:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
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)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
@@ -140,14 +139,12 @@ class OpenAICompatiableService:
|
||||
logger.warning(
|
||||
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)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key,
|
||||
model_name=model,
|
||||
@@ -157,8 +154,6 @@ class OpenAICompatiableService:
|
||||
request_msg=payload,
|
||||
)
|
||||
|
||||
# Attempt to switch API Key
|
||||
# Ensure key_manager is available (might need adjustment if not always passed)
|
||||
if self.key_manager:
|
||||
api_key = await self.key_manager.handle_api_failure(
|
||||
current_attempt_key, retries
|
||||
@@ -178,7 +173,6 @@ class OpenAICompatiableService:
|
||||
logger.error(f"Max retries ({max_retries}) reached for streaming.")
|
||||
break
|
||||
finally:
|
||||
# Log the final outcome of the streaming request
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
await add_request_log(
|
||||
@@ -189,7 +183,6 @@ class OpenAICompatiableService:
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime,
|
||||
)
|
||||
# If the loop finished due to failure, yield error and DONE
|
||||
if not is_success and retries >= max_retries:
|
||||
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
@@ -6,12 +6,12 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app import database
|
||||
from app.database.connection import database
|
||||
from app.config.config import settings
|
||||
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():
|
||||
|
||||
@@ -41,7 +41,7 @@ class StatsService:
|
||||
),
|
||||
1,
|
||||
),
|
||||
(RequestLog.status_code is None, 1), # type: ignore
|
||||
(RequestLog.status_code is None, 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("failure"),
|
||||
@@ -96,7 +96,7 @@ class StatsService:
|
||||
),
|
||||
1,
|
||||
),
|
||||
(RequestLog.status_code is None, 1), # type: ignore
|
||||
(RequestLog.status_code is None, 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("failure"),
|
||||
@@ -166,25 +166,24 @@ class StatsService:
|
||||
RequestLog.request_time.label("timestamp"),
|
||||
RequestLog.api_key.label("key"),
|
||||
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)
|
||||
.order_by(RequestLog.request_time.desc())
|
||||
) # Order by most recent first
|
||||
)
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
# Convert results to list of dicts and map status_code
|
||||
details = []
|
||||
for row in results:
|
||||
status = "failure" # 默认状态为 failure,如果 status_code 有效且在 200-299 范围内则更新为 success
|
||||
if row["status_code"] is not None: # 检查 status_code 是否为空
|
||||
status = "failure"
|
||||
if row["status_code"] is not None:
|
||||
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
||||
details.append(
|
||||
{
|
||||
"timestamp": row[
|
||||
"timestamp"
|
||||
].isoformat(), # Use ISO format for JS compatibility
|
||||
].isoformat(),
|
||||
"key": row["key"],
|
||||
"model": row["model"],
|
||||
"status": status,
|
||||
@@ -197,7 +196,6 @@ class StatsService:
|
||||
|
||||
except Exception as 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
|
||||
|
||||
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
|
||||
@@ -225,10 +223,10 @@ class StatsService:
|
||||
.where(
|
||||
RequestLog.api_key == key,
|
||||
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)
|
||||
.order_by(func.count(RequestLog.id).desc()) # Order by count descending
|
||||
.order_by(func.count(RequestLog.id).desc())
|
||||
)
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
@@ -237,7 +235,7 @@ class StatsService:
|
||||
logger.info(
|
||||
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}
|
||||
logger.info(
|
||||
@@ -250,6 +248,4 @@ class StatsService:
|
||||
f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Depending on requirements, you might return None or raise the exception
|
||||
# Raising allows the route handler to return a 500 error.
|
||||
raise # Re-raise the exception
|
||||
raise
|
||||
|
||||
@@ -7,11 +7,7 @@ from app.log.logger import get_update_logger
|
||||
|
||||
logger = get_update_logger()
|
||||
|
||||
# GitHub repository details are read from settings (defined in app/config/config.py or environment variables)
|
||||
|
||||
# GITHUB_API_URL will be constructed inside the function to ensure settings are loaded
|
||||
|
||||
VERSION_FILE_PATH = "VERSION" # Path relative to project root
|
||||
VERSION_FILE_PATH = "VERSION"
|
||||
|
||||
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。
|
||||
"""
|
||||
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:
|
||||
current_v = f.read().strip()
|
||||
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}")
|
||||
|
||||
# Check if repository details are configured in settings
|
||||
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":
|
||||
logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.")
|
||||
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"
|
||||
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:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 添加 User-Agent 头,GitHub API 可能需要
|
||||
headers = {
|
||||
"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.raise_for_status() # 对错误的 HTTP 状态码(4xx 或 5xx)抛出异常
|
||||
response = await client.get(github_api_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
latest_release = response.json()
|
||||
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'。")
|
||||
return False, None, "无法从 GitHub 解析最新版本。"
|
||||
|
||||
# 移除 tag 名称中可能存在的 'v' 前缀
|
||||
if latest_v_str.startswith('v'):
|
||||
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}")
|
||||
return False, None, "更新检查期间发生网络错误。"
|
||||
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'
|
||||
logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'")
|
||||
return False, None, "遇到无效的版本格式。"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -133,41 +133,69 @@ endblock %} {% block head_extra_styles %}
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Pagination custom styles */
|
||||
.pagination li a, .pagination li span { /* Assuming 'span' might be used for non-clickable items like '...' */
|
||||
display: flex; /* For centering content if icons are used */
|
||||
/* New Pagination Styles (inspired by keys_status.html) */
|
||||
ul.pagination a {
|
||||
/* Targets the <a> tags directly within ul.pagination */
|
||||
display: inline-flex; /* Consistent with flex from addPaginationLink */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.75rem; /* Adjust padding as needed */
|
||||
line-height: 1.25;
|
||||
color: #e2e8f0; /* Light gray/white text */
|
||||
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 */
|
||||
/* Tailwind classes from JS will handle padding, border-radius, font-size, transition */
|
||||
/* Defaults for non-active, non-disabled, non-hover buttons */
|
||||
background-color: rgba(80, 60, 160, 0.8);
|
||||
color: #ffffff;
|
||||
background-color: rgba(120, 100, 200, 0.6); /* Consistent with other button hovers */
|
||||
border-color: rgba(167, 139, 250, 0.8);
|
||||
border: 1px solid rgba(120, 100, 200, 0.4);
|
||||
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 */
|
||||
color: #ffffff !important;
|
||||
background-color: #7c3aed !important; /* Violet-600, ensure it overrides */
|
||||
border-color: #7c3aed !important;
|
||||
ul.pagination a:hover:not(.active):not(.disabled) {
|
||||
/* Hover for non-active, non-disabled */
|
||||
background-color: rgba(
|
||||
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 */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pagination li.disabled a, .pagination li.disabled span { /* Assuming 'disabled' class */
|
||||
color: rgba(226, 232, 240, 0.6) !important;
|
||||
background-color: rgba(80, 60, 160, 0.3) !important; /* Slightly more visible than pure disabled */
|
||||
border-color: rgba(120, 100, 200, 0.4) !important;
|
||||
ul.pagination a.disabled {
|
||||
/* Disabled state for '...' or prev/next unavailable */
|
||||
background-color: rgba(
|
||||
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;
|
||||
pointer-events: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block content %}
|
||||
@@ -303,6 +331,13 @@ endblock %} {% block head_extra_styles %}
|
||||
>
|
||||
<i class="fas fa-trash-alt mr-1.5"></i>删除
|
||||
</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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</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>
|
||||
</th>
|
||||
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
|
||||
<th class="px-5 py-3 font-semibold">错误类型</th>
|
||||
<th class="px-5 py-3 font-semibold">错误码</th>
|
||||
<th class="px-5 py-3 font-semibold">模型名称</th>
|
||||
<th class="px-5 py-3 font-semibold">请求时间</th>
|
||||
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">
|
||||
<th class="px-5 py-3 font-semibold text-white">Gemini密钥</th>
|
||||
<th class="px-5 py-3 font-semibold text-white">错误类型</th>
|
||||
<th class="px-5 py-3 font-semibold text-white">错误码</th>
|
||||
<th class="px-5 py-3 font-semibold text-white">模型名称</th>
|
||||
<th class="px-5 py-3 font-semibold text-white">请求时间</th>
|
||||
<th
|
||||
class="px-5 py-3 font-semibold text-white rounded-tr-lg text-center"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -7,15 +7,12 @@ import base64
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
import logging # Import logging
|
||||
import logging
|
||||
|
||||
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") # Or use a more specific logger if available
|
||||
helper_logger = logging.getLogger("app.utils")
|
||||
|
||||
# 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
|
||||
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:
|
||||
"""Reads the current version from the VERSION file."""
|
||||
version_file = VERSION_FILE_PATH # Use Path object defined above
|
||||
version_file = VERSION_FILE_PATH
|
||||
try:
|
||||
# Use Path object's open method
|
||||
with version_file.open('r', encoding='utf-8') as f:
|
||||
version = f.read().strip()
|
||||
if not version:
|
||||
|
||||
@@ -9,8 +9,7 @@ uvicorn
|
||||
google-genai
|
||||
jinja2
|
||||
python-multipart
|
||||
cryptography # 支持 MySQL 8+ caching_sha2_password 验证
|
||||
# 数据库相关依赖
|
||||
cryptography
|
||||
pymysql
|
||||
sqlalchemy
|
||||
aiomysql
|
||||
|
||||
Reference in New Issue
Block a user