mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-03 22:04:18 +08:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929592bbc4 | ||
|
|
2225a40bbe | ||
|
|
3480fa3b0f | ||
|
|
d7113f5fc4 | ||
|
|
2072f54ca1 | ||
|
|
7c9b721164 | ||
|
|
83ce50975a | ||
|
|
7da9110704 | ||
|
|
e9d19de7c6 | ||
|
|
e822831178 | ||
|
|
775930edce | ||
|
|
cb40848c04 | ||
|
|
7098c8755f | ||
|
|
705d602dee | ||
|
|
cd257a9406 | ||
|
|
cd54650431 | ||
|
|
a5602c602e | ||
|
|
dd70fd4c44 | ||
|
|
dbe50628b3 | ||
|
|
83ed0527d3 | ||
|
|
ab31f4bb98 | ||
|
|
734a8c4bc4 | ||
|
|
fea3af4692 | ||
|
|
9302cf295e | ||
|
|
b4f040e77a | ||
|
|
defabf4355 | ||
|
|
f3ed3168e4 | ||
|
|
01765b1731 | ||
|
|
f83f0fa768 | ||
|
|
a7085964e8 | ||
|
|
d3cd2856b7 | ||
|
|
353d22cc70 | ||
|
|
eb96474c19 | ||
|
|
0c48a2d74d | ||
|
|
1b23d574a5 | ||
|
|
ebc5dc571b | ||
|
|
9a7a1d7c2f | ||
|
|
c99e090ea9 | ||
|
|
eb311de0c2 | ||
|
|
c254077a66 | ||
|
|
ef4a528611 | ||
|
|
f593d97381 | ||
|
|
053ef631c4 | ||
|
|
075d20c62d | ||
|
|
0768aed179 | ||
|
|
c2eac24175 | ||
|
|
1c6dabcea7 | ||
|
|
76937aa24f | ||
|
|
b96ce8f15a | ||
|
|
87d60117c5 | ||
|
|
a53a30fd38 | ||
|
|
98e7fb62d5 | ||
|
|
6a59b4f847 | ||
|
|
d1ba2c4ae9 | ||
|
|
0693a5c245 | ||
|
|
742db744d1 | ||
|
|
12a84921c1 | ||
|
|
73e98a185d | ||
|
|
73a7c81f85 | ||
|
|
86dba93974 |
21
.env.example
21
.env.example
@@ -1,13 +1,15 @@
|
||||
# MySQL数据库配置
|
||||
MYSQL_HOST=
|
||||
MYSQL_PORT=
|
||||
MYSQL_USER=
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_HOST=gemini-balance-mysql
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=gemini
|
||||
MYSQL_PASSWORD=change_me
|
||||
MYSQL_DATABASE=default_db
|
||||
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
|
||||
ALLOWED_TOKENS=["sk-123456"]
|
||||
# AUTH_TOKEN=sk-123456
|
||||
TEST_MODEL=gemini-1.5-flash
|
||||
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
|
||||
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
|
||||
IMAGE_MODELS=["gemini-2.0-flash-exp"]
|
||||
SEARCH_MODELS=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
|
||||
FILTERED_MODELS=["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"]
|
||||
@@ -21,6 +23,9 @@ CHECK_INTERVAL_HOURS=1
|
||||
TIMEZONE=Asia/Shanghai
|
||||
# 请求超时时间(秒)
|
||||
TIME_OUT=300
|
||||
# 代理服务器配置 (支持 http 和 socks5)
|
||||
# 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"]
|
||||
PROXIES=[]
|
||||
#########################image_generate 相关配置###########################
|
||||
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
|
||||
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
|
||||
@@ -38,3 +43,11 @@ STREAM_SHORT_TEXT_THRESHOLD=10
|
||||
STREAM_LONG_TEXT_THRESHOLD=50
|
||||
STREAM_CHUNK_SIZE=5
|
||||
##########################################################################
|
||||
######################### 日志配置 #######################################
|
||||
# 日志级别 (debug, info, warning, error, critical),默认为 info
|
||||
LOG_LEVEL=info
|
||||
##########################################################################
|
||||
|
||||
# 安全设置 (JSON 字符串格式)
|
||||
# 注意:这里的示例值可能需要根据实际模型支持情况调整
|
||||
SAFETY_SETTINGS='[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}]'
|
||||
|
||||
@@ -4,6 +4,7 @@ WORKDIR /app
|
||||
|
||||
# 复制所需文件到容器中
|
||||
COPY ./requirements.txt /app
|
||||
COPY ./VERSION /app
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY ./app /app/app
|
||||
|
||||
17
LICENSE
Normal file
17
LICENSE
Normal file
@@ -0,0 +1,17 @@
|
||||
知识共享署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 协议
|
||||
|
||||
您可以自由地:
|
||||
- 共享 — 在任何媒介以任何形式复制、发行本作品
|
||||
- 演绎 — 修改、转换或以本作品为基础进行创作
|
||||
|
||||
惟须遵守下列条件:
|
||||
- 署名 — 您必须给出适当的署名,提供指向本协议的链接,并指明是否(对原作)作了修改。您可以以任何合理方式进行,但不得以任何方式暗示许可方认可您或您的使用。
|
||||
- 非商业性使用 — 您不得将本作品用于商业目的,包括但不限于任何形式的商业倒卖、SaaS、API 付费接口、二次销售、打包出售、收费分发或其他直接或间接盈利行为。
|
||||
|
||||
如需商业授权,请联系原作者获得书面许可。违者将承担相应法律责任。
|
||||
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
||||
58
README.md
58
README.md
@@ -1,5 +1,9 @@
|
||||
# Gemini Balance - Gemini API 代理和负载均衡器
|
||||
|
||||
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
|
||||
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://www.uvicorn.org/)
|
||||
@@ -63,6 +67,7 @@ app/
|
||||
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||
* **模型列表自动维护**: 支持openai和gemini模型列表获取,与newapi自动获取模型列表完美兼容,无需手动填写。
|
||||
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
|
||||
* **代理支持**: 支持配置 HTTP/SOCKS5 代理服务器 (`PROXIES`),用于访问 Gemini API,方便在特殊网络环境下使用。支持批量添加代理。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -154,19 +159,23 @@ app/
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | 可选,是否启用代码执行工具 | `false` |
|
||||
| `SHOW_SEARCH_LINK` | 可选,是否在响应中显示搜索结果链接 | `true` |
|
||||
| `SHOW_THINKING_PROCESS` | 可选,是否显示模型思考过程 | `true` |
|
||||
| `THINKING_MODELS` | 可选,支持思考功能的模型列表 | `[]` |
|
||||
| `THINKING_BUDGET_MAP` | 可选,思考功能预算映射 (模型名:预算值) | `{}` |
|
||||
| `BASE_URL` | 可选,Gemini API 基础 URL,默认无需修改 | `https://generativelanguage.googleapis.com/v1beta` |
|
||||
| `MAX_FAILURES` | 可选,允许单个key失败的次数 | `3` |
|
||||
| `MAX_RETRIES` | 可选,API 请求失败时的最大重试次数 | `3` |
|
||||
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
|
||||
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
|
||||
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
|
||||
| `PROXIES` | 可选,代理服务器列表 (例如 `http://user:pass@host:port`, `socks5://host:port`) | `[]` |
|
||||
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
|
||||
| **图像生成相关** | | |
|
||||
| `PAID_KEY` | 可选,付费版API Key,用于图片生成等高级功能 | `your-paid-api-key` |
|
||||
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
|
||||
| `UPLOAD_PROVIDER` | 可选,图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `SMMS_SECRET_TOKEN` | 可选,SM.MS图床的API Token | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | 可选,PicoGo图床的API Key | `your-picogo-apikey` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | 可选,CloudFlare 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `PICGO_API_KEY` | 可选,[PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | 可选,[CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE`| 可选,CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
|
||||
| **流式优化器相关** | | |
|
||||
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
|
||||
@@ -186,28 +195,47 @@ app/
|
||||
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
|
||||
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
|
||||
|
||||
### OpenAI API 相关 (`(/hf)/v1`)
|
||||
### OpenAI API 相关
|
||||
|
||||
* `GET /v1/models`: 列出可用的 OpenAI 模型。
|
||||
* `POST /v1/chat/completions`: 通过 OpenAI API 进行聊天补全。
|
||||
* `POST /v1/images/generations`: 通过 OpenAI API 生成图像。
|
||||
* `POST /v1/embeddings`: 通过 OpenAI API 创建文本嵌入。
|
||||
* `GET (/hf)/v1/models`: 列出可用的模型 (底层用的gemini格式)。
|
||||
* `POST (/hf)/v1/chat/completions`: 进行聊天补全 (底层用的gemini格式, 支持流式传输)。
|
||||
* `POST (/hf)/v1/embeddings`: 创建文本嵌入 (底层用的gemini格式)。
|
||||
* `POST (/hf)/v1/images/generations`: 生成图像 (底层用的gemini格式)。
|
||||
* `GET /openai/v1/models`: 列出可用的模型 (底层用的openai格式)。
|
||||
* `POST /openai/v1/chat/completions`: 进行聊天补全 (底层用的openai格式, 支持流式传输, 可防止截断,速度也快)。
|
||||
* `POST /openai/v1/embeddings`: 创建文本嵌入 (底层用的openai格式)。
|
||||
* `POST /openai/v1/images/generations`: 生成图像 (底层用的openai格式)。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Pull Request 或 Issue。
|
||||
|
||||
## 🎉 特别鸣谢
|
||||
|
||||
特别鸣谢以下项目和平台为本项目提供图床服务:
|
||||
|
||||
* [PicGo](https://www.picgo.net/)
|
||||
* [SM.MS](https://smms.app/)
|
||||
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 开源项目
|
||||
|
||||
## 🙏 感谢贡献者
|
||||
|
||||
感谢所有为本项目做出贡献的开发者!
|
||||
|
||||
<a href="https://github.com/toddyoe" title="toddyoe"><img src="https://avatars.githubusercontent.com/u/167494546?s=64&v=4" width="64" height="64"></a>
|
||||
<a href="https://github.com/yangtb2024" title="yangtb2024"><img src="https://avatars.githubusercontent.com/u/164613316?s=64&v=4" width="64" height="64"></a>
|
||||
<a href="https://github.com/cr-zhichen" title="cr-zhichen"><img src="https://avatars.githubusercontent.com/u/57337795?s=64&v=4" width="64" height="64"></a>
|
||||
<a href="https://github.com/BetterAndBetterII" title="BetterAndBetterII"><img src="https://avatars.githubusercontent.com/u/141388234?s=96&v=4" width="64" height="64"></a>
|
||||
<a href="https://github.com/yanhao98" title="yanhao98"><img src="https://avatars.githubusercontent.com/u/37316281?s=64&v=4" width="64" height="64"></a>
|
||||
<a href="https://github.com/Haoyu99" title="Haoyu99"><img src="https://avatars.githubusercontent.com/u/93185981?s=60&v=4" width="64" height="64"></a>
|
||||
[](https://github.com/snailyp/gemini-balance/graphs/contributors)
|
||||
|
||||
## 📄 许可证
|
||||
## ⭐ Star History
|
||||
|
||||
本项目采用 MIT 许可证。
|
||||
[](https://star-history.com/#snailyp/gemini-balance&Date)
|
||||
|
||||
## 💖 友情项目
|
||||
|
||||
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线:AI驱动的热点事件时间轴生成工具
|
||||
|
||||
## 🎁 项目支持
|
||||
|
||||
如果你觉得这个项目对你有帮助,可以考虑通过 [爱发电](https://afdian.com/a/snaily) 支持我。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
|
||||
@@ -9,18 +9,11 @@ from pydantic import ValidationError
|
||||
from pydantic_settings import BaseSettings
|
||||
from sqlalchemy import insert, update, select
|
||||
|
||||
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES
|
||||
from app.log.logger import get_config_logger
|
||||
# 延迟导入以避免循环依赖,仅在 sync_initial_settings 中使用
|
||||
# from app.database.connection import database
|
||||
# from app.database.models import Settings as SettingsModel
|
||||
# from app.database.services import get_all_settings # get_all_settings 可能不适合启动时调用,直接查询
|
||||
|
||||
logger = get_config_logger()
|
||||
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_SAFETY_SETTINGS, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES
|
||||
from app.log.logger import Logger
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用程序配置"""
|
||||
# 数据库配置
|
||||
MYSQL_HOST: str
|
||||
MYSQL_PORT: int
|
||||
@@ -37,7 +30,8 @@ class Settings(BaseSettings):
|
||||
TEST_MODEL: str = DEFAULT_MODEL
|
||||
TIME_OUT: int = DEFAULT_TIMEOUT
|
||||
MAX_RETRIES: int = MAX_RETRIES
|
||||
|
||||
PROXIES: List[str] = [] # 新增:代理服务器列表
|
||||
|
||||
# 模型相关配置
|
||||
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
||||
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
||||
@@ -45,6 +39,8 @@ class Settings(BaseSettings):
|
||||
TOOLS_CODE_EXECUTION_ENABLED: bool = False
|
||||
SHOW_SEARCH_LINK: bool = True
|
||||
SHOW_THINKING_PROCESS: bool = True
|
||||
THINKING_MODELS: List[str] = [] # 新增:用于思考过程的模型列表
|
||||
THINKING_BUDGET_MAP: Dict[str, float] = {} # 新增:模型对应的预算映射
|
||||
|
||||
# 图像生成相关配置
|
||||
PAID_KEY: str = ""
|
||||
@@ -66,6 +62,14 @@ class Settings(BaseSettings):
|
||||
# 调度器配置
|
||||
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
|
||||
TIMEZONE: str = "Asia/Shanghai" # 默认时区
|
||||
|
||||
# github
|
||||
GITHUB_REPO_OWNER: str = "snailyp"
|
||||
GITHUB_REPO_NAME: str = "gemini-balance"
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL: str = "INFO" # 默认日志级别
|
||||
SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS # 新增:安全设置
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -78,26 +82,83 @@ settings = Settings()
|
||||
|
||||
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
||||
"""尝试将数据库字符串值解析为目标 Python 类型"""
|
||||
from app.log.logger import get_config_logger # 函数内导入
|
||||
logger = get_config_logger() # 函数内初始化
|
||||
try:
|
||||
# 处理 List[str]
|
||||
if target_type == List[str]:
|
||||
# 尝试解析 JSON 列表,如果失败则按逗号分割
|
||||
try:
|
||||
parsed = json.loads(db_value)
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed]
|
||||
except json.JSONDecodeError:
|
||||
# 回退到逗号分割,去除空格
|
||||
return [item.strip() for item in db_value.split(',') if item.strip()]
|
||||
# 如果解析后不是列表或解析失败,返回空列表或进行其他处理
|
||||
logger.warning(f"Could not parse '{db_value}' as List[str] for key '{key}', falling back to comma split or empty list.")
|
||||
return [item.strip() for item in db_value.split(',') if item.strip()] # Fallback
|
||||
return [item.strip() for item in db_value.split(',') if item.strip()]
|
||||
# 处理 Dict[str, float]
|
||||
elif target_type == Dict[str, float]:
|
||||
parsed_dict = {}
|
||||
try:
|
||||
# First attempt: standard JSON parsing
|
||||
parsed = json.loads(db_value)
|
||||
if isinstance(parsed, dict):
|
||||
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
|
||||
else:
|
||||
logger.warning(f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}")
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e1:
|
||||
# Second attempt: try replacing single quotes if JSONDecodeError occurred
|
||||
if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
|
||||
logger.warning(f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}")
|
||||
try:
|
||||
corrected_db_value = db_value.replace("'", '"')
|
||||
parsed = json.loads(corrected_db_value)
|
||||
if isinstance(parsed, dict):
|
||||
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
|
||||
else:
|
||||
logger.warning(f"Parsed DB value (after quote replacement) for key '{key}' is not a dictionary type. Value: {corrected_db_value}")
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e2:
|
||||
logger.error(f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict.")
|
||||
else:
|
||||
# Log other errors (ValueError, TypeError) or JSON errors without single quotes
|
||||
logger.error(f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict.")
|
||||
return parsed_dict # Return the parsed dict or an empty one if all attempts fail
|
||||
# 处理 List[Dict[str, str]]
|
||||
elif target_type == List[Dict[str, str]]:
|
||||
try:
|
||||
parsed = json.loads(db_value)
|
||||
if isinstance(parsed, list):
|
||||
# 验证列表中的每个元素是否为字典,并且键和值都是字符串
|
||||
valid = all(
|
||||
isinstance(item, dict) and
|
||||
all(isinstance(k, str) for k in item.keys()) and
|
||||
all(isinstance(v, str) for v in item.values())
|
||||
for item in parsed
|
||||
)
|
||||
if valid:
|
||||
return parsed
|
||||
else:
|
||||
logger.warning(f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}")
|
||||
return [] # 或者返回默认值?这里返回空列表
|
||||
else:
|
||||
logger.warning(f"Parsed DB value for key '{key}' is not a list type. Value: {db_value}")
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Could not parse '{db_value}' as JSON for List[Dict[str, str]] for key '{key}'. Returning empty list.")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing List[Dict[str, str]] for key '{key}': {e}. Value: {db_value}. Returning empty list.")
|
||||
return []
|
||||
# 处理 bool
|
||||
elif target_type == bool:
|
||||
return db_value.lower() in ('true', '1', 'yes', 'on')
|
||||
# 处理 int
|
||||
elif target_type == int:
|
||||
return int(db_value)
|
||||
# 处理 float
|
||||
elif target_type == float:
|
||||
return float(db_value)
|
||||
else: # 默认为 str 或其他 pydantic 能处理的类型
|
||||
# 默认为 str 或其他 pydantic 能直接处理的类型
|
||||
else:
|
||||
return db_value
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Failed to parse db_value '{db_value}' for key '{key}' as type {target_type}: {e}. Using original string value.")
|
||||
@@ -110,6 +171,8 @@ async def sync_initial_settings():
|
||||
2. 将数据库设置合并到内存 settings (数据库优先)。
|
||||
3. 将最终的内存 settings 同步回数据库。
|
||||
"""
|
||||
from app.log.logger import get_config_logger # 函数内导入
|
||||
logger = get_config_logger() # 函数内初始化
|
||||
# 延迟导入以避免循环依赖和确保数据库连接已初始化
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings as SettingsModel
|
||||
@@ -153,20 +216,18 @@ async def sync_initial_settings():
|
||||
# 比较解析后的值和内存中的值
|
||||
# 注意:对于列表等复杂类型,直接比较可能不够健壮,但这里简化处理
|
||||
if parsed_db_value != memory_value:
|
||||
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
|
||||
# 优先处理 List[str] 类型,避免直接对泛型使用 isinstance
|
||||
if target_type == List[str]:
|
||||
if isinstance(parsed_db_value, list):
|
||||
# 可以选择性地添加对列表元素的检查,但这里保持简化
|
||||
setattr(settings, key, parsed_db_value)
|
||||
logger.info(f"Updated setting '{key}' in memory from database value (List[str]).")
|
||||
updated_in_memory = True
|
||||
else:
|
||||
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected List[str], got {type(parsed_db_value)}. Skipping update.")
|
||||
# 对于其他非泛型类型,使用常规的 isinstance 检查
|
||||
elif isinstance(parsed_db_value, target_type):
|
||||
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
|
||||
type_match = False
|
||||
if target_type == List[str] and isinstance(parsed_db_value, list):
|
||||
type_match = True
|
||||
elif target_type == Dict[str, float] and isinstance(parsed_db_value, dict):
|
||||
type_match = True
|
||||
elif target_type not in (List[str], Dict[str, float]) and isinstance(parsed_db_value, target_type):
|
||||
type_match = True
|
||||
|
||||
if type_match:
|
||||
setattr(settings, key, parsed_db_value)
|
||||
logger.info(f"Updated setting '{key}' in memory from database value.")
|
||||
logger.debug(f"Updated setting '{key}' in memory from database value ({target_type}).")
|
||||
updated_in_memory = True
|
||||
else:
|
||||
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update.")
|
||||
@@ -197,10 +258,12 @@ async def sync_initial_settings():
|
||||
|
||||
for key, value in final_memory_settings.items():
|
||||
# 序列化值为字符串或 JSON 字符串
|
||||
if isinstance(value, list):
|
||||
db_value = json.dumps(value)
|
||||
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 或其他标记
|
||||
else:
|
||||
db_value = str(value)
|
||||
|
||||
@@ -258,15 +321,15 @@ async def sync_initial_settings():
|
||||
else:
|
||||
logger.info("No setting changes detected between memory and database during initial sync.")
|
||||
|
||||
# 刷新日志等级
|
||||
Logger.update_log_levels(final_memory_settings.get("LOG_LEVEL"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
|
||||
finally:
|
||||
if database.is_connected:
|
||||
try:
|
||||
# Don't disconnect if it's managed elsewhere (e.g., FastAPI lifespan)
|
||||
# await database.disconnect()
|
||||
# logger.info("Database connection closed after initial sync.")
|
||||
pass # Assume connection lifecycle is managed by the application lifespan
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting database after initial sync: {e}")
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""
|
||||
应用程序工厂模块,负责创建和配置FastAPI应用程序实例
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path # Add pathlib import
|
||||
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.log.logger import get_application_logger
|
||||
@@ -11,56 +10,114 @@ 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.core.initialization import initialize_app
|
||||
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.key_checker import start_scheduler, stop_scheduler # 导入调度器函数
|
||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
||||
from app.service.update.update_service import check_for_updates
|
||||
|
||||
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 实例没有直接更新全局变量的方法
|
||||
# 我们需要在请求上下文中传递这些变量,或者修改 Jinja 环境
|
||||
# 更简单的方法是将其存储在 app.state 中,并在渲染时传递
|
||||
app.state.update_info = update_info
|
||||
logger.info(f"Update info stored in app.state: {update_info}")
|
||||
|
||||
|
||||
# --- Helper functions for lifespan ---
|
||||
|
||||
async def _setup_database_and_config(app_settings):
|
||||
"""Initializes database, syncs settings, and initializes KeyManager."""
|
||||
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}")
|
||||
|
||||
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
|
||||
update_info = {
|
||||
"update_available": update_available,
|
||||
"latest_version": latest_version,
|
||||
"error_message": error_message,
|
||||
"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:
|
||||
# 初始化数据库
|
||||
initialize_database()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# 连接到数据库
|
||||
await connect_to_db()
|
||||
|
||||
# 同步初始配置(DB优先,然后同步回DB)
|
||||
await sync_initial_settings()
|
||||
# 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
|
||||
_start_scheduler()
|
||||
|
||||
# 初始化KeyManager (使用可能已从DB更新的settings)
|
||||
await get_key_manager_instance(settings.API_KEYS)
|
||||
logger.info("KeyManager initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize application: {str(e)}")
|
||||
raise
|
||||
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.
|
||||
|
||||
# 启动调度器
|
||||
start_scheduler()
|
||||
logger.info("Scheduler started successfully.")
|
||||
yield # Application runs
|
||||
|
||||
yield # 应用程序运行期间
|
||||
|
||||
# 关闭事件
|
||||
# Shutdown events
|
||||
logger.info("Application shutting down...")
|
||||
|
||||
# 停止调度器
|
||||
stop_scheduler()
|
||||
logger.info("Scheduler stopped.")
|
||||
|
||||
# 断开数据库连接
|
||||
await disconnect_from_db()
|
||||
_stop_scheduler()
|
||||
await _shutdown_database()
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
@@ -69,20 +126,33 @@ def create_app() -> FastAPI:
|
||||
Returns:
|
||||
FastAPI: 配置好的FastAPI应用程序实例
|
||||
"""
|
||||
# 初始化应用程序
|
||||
initialize_app()
|
||||
|
||||
# Removed: initialize_app() call
|
||||
|
||||
# 创建FastAPI应用
|
||||
# Read version from file for consistency
|
||||
current_version = get_current_version() # Use imported function
|
||||
app = FastAPI(
|
||||
title="Gemini Balance API",
|
||||
description="Gemini API代理服务,支持负载均衡和密钥管理",
|
||||
version="1.0.0",
|
||||
version=current_version,
|
||||
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
|
||||
}
|
||||
|
||||
# 配置静态文件
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# 配置中间件
|
||||
setup_middlewares(app)
|
||||
|
||||
|
||||
@@ -40,3 +40,40 @@ DEFAULT_STREAM_CHUNK_SIZE = 5
|
||||
# 正则表达式模式
|
||||
IMAGE_URL_PATTERN = r'!\[(.*?)\]\((.*?)\)'
|
||||
DATA_URL_PATTERN = r'data:([^;]+);base64,(.+)'
|
||||
|
||||
# Audio/Video Settings
|
||||
SUPPORTED_AUDIO_FORMATS = ["wav", "mp3", "flac", "ogg"]
|
||||
SUPPORTED_VIDEO_FORMATS = ["mp4", "mov", "avi", "webm"]
|
||||
MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024 # Example: 50MB limit for Base64 payload
|
||||
MAX_VIDEO_SIZE_BYTES = 200 * 1024 * 1024 # Example: 200MB limit
|
||||
|
||||
# Optional: Define MIME type mappings if needed, or handle directly in converter
|
||||
AUDIO_FORMAT_TO_MIMETYPE = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"flac": "audio/flac",
|
||||
"ogg": "audio/ogg",
|
||||
}
|
||||
|
||||
VIDEO_FORMAT_TO_MIMETYPE = {
|
||||
"mp4": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"avi": "video/x-msvideo",
|
||||
"webm": "video/webm",
|
||||
}
|
||||
|
||||
GEMINI_2_FLASH_EXP_SAFETY_SETTINGS = [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
|
||||
]
|
||||
|
||||
DEFAULT_SAFETY_SETTINGS = [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
|
||||
]
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
应用程序初始化模块
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from app.log.logger import get_initialization_logger
|
||||
|
||||
logger = get_initialization_logger()
|
||||
|
||||
|
||||
def ensure_directories_exist(directories: List[str]) -> None:
|
||||
"""
|
||||
确保指定的目录存在,如果不存在则创建
|
||||
|
||||
Args:
|
||||
directories: 要确保存在的目录列表
|
||||
"""
|
||||
for directory in directories:
|
||||
try:
|
||||
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Ensured directory exists: {directory}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directory {directory}: {str(e)}")
|
||||
|
||||
|
||||
def initialize_app() -> None:
|
||||
"""
|
||||
初始化应用程序,确保所需的目录和文件都存在
|
||||
"""
|
||||
# 确保必要的目录存在
|
||||
required_directories = [
|
||||
"app/static/css",
|
||||
"app/static/js",
|
||||
"app/static/icons",
|
||||
"app/templates",
|
||||
]
|
||||
|
||||
ensure_directories_exist(required_directories)
|
||||
logger.info("core initialization completed")
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
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
|
||||
@@ -31,7 +32,9 @@ Base = declarative_base(metadata=metadata)
|
||||
# databases 库会自动处理连接失效后的重连尝试。
|
||||
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins
|
||||
|
||||
# 移除了 SessionLocal 和 get_db 函数
|
||||
|
||||
# --- Async connection functions for lifespan/async routes ---
|
||||
async def connect_to_db():
|
||||
"""
|
||||
连接到数据库
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""
|
||||
数据库服务模块
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, desc, asc, select, insert, update, delete
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from datetime import datetime # Keep this import
|
||||
|
||||
from sqlalchemy import select, insert, update, func
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog
|
||||
from app.database.models import Settings, ErrorLog, RequestLog
|
||||
from app.log.logger import get_database_logger
|
||||
|
||||
logger = get_database_logger()
|
||||
@@ -157,25 +155,39 @@ async def get_error_logs(
|
||||
offset: int = 0,
|
||||
key_search: Optional[str] = None,
|
||||
error_search: Optional[str] = None,
|
||||
error_code_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None,
|
||||
sort_by: str = 'id', # 新增排序字段
|
||||
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取错误日志,支持搜索和日期过滤
|
||||
获取错误日志,支持搜索、日期过滤和排序
|
||||
|
||||
Args:
|
||||
limit (int): 限制数量
|
||||
offset (int): 偏移量
|
||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||
start_date (Optional[datetime]): 开始日期时间
|
||||
end_date (Optional[datetime]): 结束日期时间
|
||||
sort_by (str): 排序字段 (例如 'id', 'request_time')
|
||||
sort_order (str): 排序顺序 ('asc' or 'desc')
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 错误日志列表
|
||||
"""
|
||||
try:
|
||||
query = select(ErrorLog)
|
||||
query = select(
|
||||
ErrorLog.id,
|
||||
ErrorLog.gemini_key,
|
||||
ErrorLog.model_name,
|
||||
ErrorLog.error_type,
|
||||
ErrorLog.error_log,
|
||||
ErrorLog.error_code,
|
||||
ErrorLog.request_time
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if key_search:
|
||||
@@ -190,10 +202,28 @@ async def get_error_logs(
|
||||
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
|
||||
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)
|
||||
|
||||
# Apply ordering, limit, and offset
|
||||
query = query.order_by(ErrorLog.request_time.desc()).limit(limit).offset(offset)
|
||||
|
||||
result = await database.fetch_all(query)
|
||||
return [dict(row) for row in result]
|
||||
except Exception as e:
|
||||
@@ -204,6 +234,7 @@ async def get_error_logs(
|
||||
async def get_error_logs_count(
|
||||
key_search: Optional[str] = None,
|
||||
error_search: Optional[str] = None,
|
||||
error_code_search: Optional[str] = None, # Added error code search
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> int:
|
||||
@@ -213,6 +244,7 @@ async def get_error_logs_count(
|
||||
Args:
|
||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||
start_date (Optional[datetime]): 开始日期时间
|
||||
end_date (Optional[datetime]): 结束日期时间
|
||||
|
||||
@@ -235,6 +267,16 @@ async def get_error_logs_count(
|
||||
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
|
||||
@@ -242,6 +284,99 @@ async def get_error_logs_count(
|
||||
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
|
||||
raise
|
||||
|
||||
|
||||
# 新增函数:获取单条错误日志详情
|
||||
async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据 ID 获取单个错误日志的详细信息
|
||||
|
||||
Args:
|
||||
log_id (int): 错误日志的 ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 包含日志详细信息的字典,如果未找到则返回 None
|
||||
"""
|
||||
try:
|
||||
query = select(ErrorLog).where(ErrorLog.id == log_id)
|
||||
result = await database.fetch_one(query)
|
||||
if result:
|
||||
# 将 request_msg (JSONB) 转换为字符串以便在 API 中返回
|
||||
log_dict = dict(result)
|
||||
if 'request_msg' in log_dict and log_dict['request_msg'] is not None:
|
||||
# 确保即使是 None 或非 JSON 数据也能处理
|
||||
try:
|
||||
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
|
||||
except TypeError:
|
||||
log_dict['request_msg'] = str(log_dict['request_msg']) # Fallback to string
|
||||
return log_dict
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
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:
|
||||
"""
|
||||
根据提供的 ID 列表批量删除错误日志 (异步)。
|
||||
|
||||
Args:
|
||||
log_ids: 要删除的错误日志 ID 列表。
|
||||
|
||||
Returns:
|
||||
int: 实际删除的日志数量。
|
||||
"""
|
||||
if not log_ids:
|
||||
return 0
|
||||
try:
|
||||
# 使用 databases 执行删除
|
||||
query = delete(ErrorLog).where(ErrorLog.id.in_(log_ids))
|
||||
# execute 返回受影响的行数,但 databases 库的 execute 不直接返回 rowcount
|
||||
# 我们需要先查询是否存在,或者依赖数据库约束/触发器(如果适用)
|
||||
# 或者,我们可以执行删除并假设成功,除非抛出异常
|
||||
# 为了简单起见,我们执行删除并记录日志,不精确返回删除数量
|
||||
# 如果需要精确数量,需要先执行 SELECT COUNT(*)
|
||||
await database.execute(query)
|
||||
# 注意:databases 的 execute 不返回 rowcount,所以我们不能直接返回删除的数量
|
||||
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
|
||||
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
|
||||
return len(log_ids) # 返回尝试删除的数量
|
||||
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
|
||||
|
||||
async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
"""
|
||||
根据 ID 删除单个错误日志 (异步)。
|
||||
|
||||
Args:
|
||||
log_id: 要删除的错误日志 ID。
|
||||
|
||||
Returns:
|
||||
bool: 如果成功删除返回 True,否则返回 False。
|
||||
"""
|
||||
try:
|
||||
# 先检查是否存在 (可选,但更明确)
|
||||
check_query = select(ErrorLog.id).where(ErrorLog.id == log_id)
|
||||
exists = await database.fetch_one(check_query)
|
||||
|
||||
if not exists:
|
||||
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
|
||||
return False # 或者可以抛出 404 异常,由路由处理
|
||||
|
||||
# 执行删除
|
||||
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
|
||||
await database.execute(delete_query)
|
||||
logger.info(f"Successfully deleted error log with ID: {log_id}")
|
||||
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 (保持异步) ---
|
||||
|
||||
# 新增函数:添加请求日志
|
||||
async def add_request_log(
|
||||
model_name: Optional[str],
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
from typing import List, Optional, Dict, Any, Literal, Union
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.constants import DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
|
||||
|
||||
|
||||
class SafetySetting(BaseModel):
|
||||
category: Optional[Literal["HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_CIVIC_INTEGRITY"]] = None
|
||||
threshold: Optional[Literal["HARM_BLOCK_THRESHOLD_UNSPECIFIED", "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH", "BLOCK_NONE", "OFF"]] = None
|
||||
category: Optional[
|
||||
Literal[
|
||||
"HARM_CATEGORY_HATE_SPEECH",
|
||||
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"HARM_CATEGORY_HARASSMENT",
|
||||
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"HARM_CATEGORY_CIVIC_INTEGRITY",
|
||||
]
|
||||
] = None
|
||||
threshold: Optional[
|
||||
Literal[
|
||||
"HARM_BLOCK_THRESHOLD_UNSPECIFIED",
|
||||
"BLOCK_LOW_AND_ABOVE",
|
||||
"BLOCK_MEDIUM_AND_ABOVE",
|
||||
"BLOCK_ONLY_HIGH",
|
||||
"BLOCK_NONE",
|
||||
"OFF",
|
||||
]
|
||||
] = None
|
||||
|
||||
|
||||
class GenerationConfig(BaseModel):
|
||||
@@ -26,7 +44,7 @@ class GenerationConfig(BaseModel):
|
||||
|
||||
class SystemInstruction(BaseModel):
|
||||
role: str = "system"
|
||||
parts: List[Dict[str, Any]]
|
||||
parts: List[Dict[str, Any]] | Dict[str, Any]
|
||||
|
||||
|
||||
class GeminiContent(BaseModel):
|
||||
@@ -37,6 +55,24 @@ class GeminiContent(BaseModel):
|
||||
class GeminiRequest(BaseModel):
|
||||
contents: List[GeminiContent] = []
|
||||
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
|
||||
safetySettings: Optional[List[SafetySetting]] = None
|
||||
generationConfig: Optional[GenerationConfig] = None
|
||||
systemInstruction: Optional[SystemInstruction] = None
|
||||
safetySettings: Optional[List[SafetySetting]] = Field(
|
||||
default=None, alias="safety_settings"
|
||||
)
|
||||
generationConfig: Optional[GenerationConfig] = Field(
|
||||
default=None, alias="generation_config"
|
||||
)
|
||||
systemInstruction: Optional[SystemInstruction] = Field(
|
||||
default=None, alias="system_instruction"
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class ResetSelectedKeysRequest(BaseModel):
|
||||
keys: List[str]
|
||||
key_type: str
|
||||
|
||||
|
||||
class VerifySelectedKeysRequest(BaseModel):
|
||||
keys: List[str]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from app.core.constants import DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
|
||||
|
||||
@@ -9,11 +9,14 @@ class ChatRequest(BaseModel):
|
||||
model: str = DEFAULT_MODEL
|
||||
temperature: Optional[float] = DEFAULT_TEMPERATURE
|
||||
stream: Optional[bool] = False
|
||||
tools: Optional[List[dict]] = []
|
||||
max_tokens: Optional[int] = None
|
||||
top_p: Optional[float] = DEFAULT_TOP_P
|
||||
top_k: Optional[int] = DEFAULT_TOP_K
|
||||
stop: Optional[List[str]] = []
|
||||
stop: Optional[Union[List[str],str]] = None
|
||||
reasoning_effort: Optional[str] = None
|
||||
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
|
||||
tool_choice: Optional[str] = None
|
||||
response_format: Optional[dict] = None
|
||||
|
||||
|
||||
class EmbeddingRequest(BaseModel):
|
||||
@@ -23,10 +26,10 @@ class EmbeddingRequest(BaseModel):
|
||||
|
||||
|
||||
class ImageGenerationRequest(BaseModel):
|
||||
model: str = "DALL-E-3"
|
||||
model: str = "imagen-3.0-generate-002"
|
||||
prompt: str = ""
|
||||
n: int = 1
|
||||
size: Optional[str] = "1024x1024"
|
||||
quality: Optional[str] = ""
|
||||
style: Optional[str] = ""
|
||||
response_format: Optional[str] = "url"
|
||||
quality: Optional[str] = None
|
||||
style: Optional[str] = None
|
||||
response_format: Optional[str] = "b64_json"
|
||||
|
||||
32
app/handler/error_handler.py
Normal file
32
app/handler/error_handler.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException
|
||||
import logging
|
||||
|
||||
@asynccontextmanager
|
||||
async def handle_route_errors(logger: logging.Logger, operation_name: str, success_message: str = None, failure_message: str = None):
|
||||
"""
|
||||
一个异步上下文管理器,用于统一处理 FastAPI 路由中的常见错误和日志记录。
|
||||
|
||||
Args:
|
||||
logger: 用于记录日志的 Logger 实例。
|
||||
operation_name: 操作的名称,用于日志记录和错误详情。
|
||||
success_message: 操作成功时记录的自定义消息 (可选)。
|
||||
failure_message: 操作失败时记录的自定义消息 (可选)。
|
||||
"""
|
||||
default_success_msg = f"{operation_name} request successful"
|
||||
default_failure_msg = f"{operation_name} request failed"
|
||||
|
||||
logger.info("-" * 50 + operation_name + "-" * 50)
|
||||
try:
|
||||
yield
|
||||
logger.info(success_message or default_success_msg)
|
||||
except HTTPException as http_exc:
|
||||
# 如果已经是 HTTPException,直接重新抛出,保留原始状态码和详情
|
||||
logger.error(f"{failure_message or default_failure_msg}: {http_exc.detail} (Status: {http_exc.status_code})")
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
# 对于其他所有异常,记录错误并抛出标准的 500 错误
|
||||
logger.error(f"{failure_message or default_failure_msg}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Internal server error during {operation_name}"
|
||||
) from e
|
||||
@@ -1,62 +1,70 @@
|
||||
# app/services/chat/message_converter.py
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
import requests
|
||||
import base64
|
||||
|
||||
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, SUPPORTED_ROLES
|
||||
import requests
|
||||
|
||||
from app.core.constants import (
|
||||
AUDIO_FORMAT_TO_MIMETYPE,
|
||||
DATA_URL_PATTERN,
|
||||
IMAGE_URL_PATTERN,
|
||||
MAX_AUDIO_SIZE_BYTES,
|
||||
MAX_VIDEO_SIZE_BYTES,
|
||||
SUPPORTED_AUDIO_FORMATS,
|
||||
SUPPORTED_ROLES,
|
||||
SUPPORTED_VIDEO_FORMATS,
|
||||
VIDEO_FORMAT_TO_MIMETYPE,
|
||||
)
|
||||
from app.log.logger import get_message_converter_logger
|
||||
|
||||
logger = get_message_converter_logger()
|
||||
|
||||
|
||||
class MessageConverter(ABC):
|
||||
"""消息转换器基类"""
|
||||
|
||||
@abstractmethod
|
||||
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
def convert(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
pass
|
||||
|
||||
|
||||
def _get_mime_type_and_data(base64_string):
|
||||
"""
|
||||
从 base64 字符串中提取 MIME 类型和数据。
|
||||
|
||||
|
||||
参数:
|
||||
base64_string (str): 可能包含 MIME 类型信息的 base64 字符串
|
||||
|
||||
|
||||
返回:
|
||||
tuple: (mime_type, encoded_data)
|
||||
"""
|
||||
# 检查字符串是否以 "data:" 格式开始
|
||||
if base64_string.startswith('data:'):
|
||||
if base64_string.startswith("data:"):
|
||||
# 提取 MIME 类型和数据
|
||||
pattern = DATA_URL_PATTERN
|
||||
match = re.match(pattern, base64_string)
|
||||
if match:
|
||||
mime_type = "image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
|
||||
mime_type = (
|
||||
"image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
|
||||
)
|
||||
encoded_data = match.group(2)
|
||||
return mime_type, encoded_data
|
||||
|
||||
|
||||
# 如果不是预期格式,假定它只是数据部分
|
||||
return None, base64_string
|
||||
|
||||
|
||||
def _convert_image(image_url: str) -> Dict[str, Any]:
|
||||
if image_url.startswith("data:image"):
|
||||
mime_type, encoded_data = _get_mime_type_and_data(image_url)
|
||||
return {
|
||||
"inline_data": {
|
||||
"mime_type": mime_type,
|
||||
"data": encoded_data
|
||||
}
|
||||
}
|
||||
return {"inline_data": {"mime_type": mime_type, "data": encoded_data}}
|
||||
else:
|
||||
encoded_data = _convert_image_to_base64(image_url)
|
||||
return {
|
||||
"inline_data": {
|
||||
"mime_type": "image/png",
|
||||
"data": encoded_data
|
||||
}
|
||||
}
|
||||
return {"inline_data": {"mime_type": "image/png", "data": encoded_data}}
|
||||
|
||||
|
||||
def _convert_image_to_base64(url: str) -> str:
|
||||
@@ -70,7 +78,7 @@ def _convert_image_to_base64(url: str) -> str:
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
# 将图片内容转换为base64
|
||||
img_data = base64.b64encode(response.content).decode('utf-8')
|
||||
img_data = base64.b64encode(response.content).decode("utf-8")
|
||||
return img_data
|
||||
else:
|
||||
raise Exception(f"Failed to fetch image: {response.status_code}")
|
||||
@@ -94,12 +102,9 @@ def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
||||
# 将URL对应的图片转换为base64
|
||||
try:
|
||||
base64_data = _convert_image_to_base64(img_url)
|
||||
parts.append({
|
||||
"inlineData": {
|
||||
"mimeType": "image/png",
|
||||
"data": base64_data
|
||||
}
|
||||
})
|
||||
parts.append(
|
||||
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
|
||||
)
|
||||
except Exception:
|
||||
# 如果转换失败,回退到文本模式
|
||||
parts.append({"text": text})
|
||||
@@ -112,42 +117,215 @@ def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
||||
class OpenAIMessageConverter(MessageConverter):
|
||||
"""OpenAI消息格式转换器"""
|
||||
|
||||
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
def _validate_media_data(
|
||||
self, format: str, data: str, supported_formats: List[str], max_size: int
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Validates format and size of Base64 media data."""
|
||||
if format.lower() not in supported_formats:
|
||||
logger.error(
|
||||
f"Unsupported media format: {format}. Supported: {supported_formats}"
|
||||
)
|
||||
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
|
||||
if len(decoded_data) > max_size:
|
||||
logger.error(
|
||||
f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)."
|
||||
)
|
||||
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}")
|
||||
raise ValueError("Invalid Base64 data")
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating media data: {e}")
|
||||
raise
|
||||
|
||||
def convert(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
converted_messages = []
|
||||
system_instruction_parts = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role", "")
|
||||
|
||||
parts = []
|
||||
# 特别处理最后一个assistant的消息,按\n\n分割
|
||||
if "content" in msg and isinstance(msg["content"], str) and msg["content"] and role == "assistant" and idx == len(messages) - 2:
|
||||
# 按\n\n分割消息
|
||||
content_parts = msg["content"].split("\n\n")
|
||||
for part in content_parts:
|
||||
if not part.strip(): # 跳过空内容
|
||||
|
||||
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)}"
|
||||
)
|
||||
continue
|
||||
# 处理可能包含图片的文本
|
||||
parts.extend(_process_text_with_image(part))
|
||||
elif "content" in msg and isinstance(msg["content"], str) and msg["content"]:
|
||||
# 请求 gemini 接口时如果包含 content 字段但内容为空时会返回 400 错误,所以需要判断是否为空并移除
|
||||
|
||||
content_type = content_item.get("type")
|
||||
|
||||
if content_type == "text" and content_item.get("text"):
|
||||
parts.append({"text": content_item["text"]})
|
||||
elif content_type == "image_url" and content_item.get(
|
||||
"image_url", {}
|
||||
).get("url"):
|
||||
try:
|
||||
parts.append(
|
||||
_convert_image(content_item["image_url"]["url"])
|
||||
)
|
||||
except Exception as e:
|
||||
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"
|
||||
):
|
||||
audio_info = content_item["input_audio"]
|
||||
audio_data = audio_info.get("data")
|
||||
audio_format = audio_info.get("format", "").lower()
|
||||
|
||||
if not audio_data or not audio_format:
|
||||
logger.warning(
|
||||
"Skipping audio part due to missing data or format."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
# Validate size and format
|
||||
validated_data = self._validate_media_data(
|
||||
audio_format,
|
||||
audio_data,
|
||||
SUPPORTED_AUDIO_FORMATS,
|
||||
MAX_AUDIO_SIZE_BYTES,
|
||||
)
|
||||
|
||||
# Get MIME type
|
||||
mime_type = AUDIO_FORMAT_TO_MIMETYPE.get(audio_format)
|
||||
if not mime_type:
|
||||
# Should not happen if format validation passed, but double-check
|
||||
logger.error(
|
||||
f"Could not find MIME type for supported format: {audio_format}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Internal error: MIME type mapping missing for {audio_format}"
|
||||
)
|
||||
|
||||
parts.append(
|
||||
{
|
||||
"inline_data": {
|
||||
"mimeType": mime_type,
|
||||
"data": validated_data, # Use the validated Base64 data
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
f"Successfully added audio part (format: {audio_format})"
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
f"Skipping audio part due to validation error: {e}"
|
||||
)
|
||||
parts.append({"text": f"[Error processing audio: {e}]"})
|
||||
except Exception:
|
||||
logger.exception("Unexpected error processing audio part.")
|
||||
parts.append(
|
||||
{"text": "[Unexpected error processing audio]"}
|
||||
)
|
||||
|
||||
elif content_type == "input_video" and content_item.get(
|
||||
"input_video"
|
||||
):
|
||||
video_info = content_item["input_video"]
|
||||
video_data = video_info.get("data")
|
||||
video_format = video_info.get("format", "").lower()
|
||||
|
||||
if not video_data or not video_format:
|
||||
logger.warning(
|
||||
"Skipping video part due to missing data or format."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
validated_data = self._validate_media_data(
|
||||
video_format,
|
||||
video_data,
|
||||
SUPPORTED_VIDEO_FORMATS,
|
||||
MAX_VIDEO_SIZE_BYTES,
|
||||
)
|
||||
mime_type = VIDEO_FORMAT_TO_MIMETYPE.get(video_format)
|
||||
if not mime_type:
|
||||
raise ValueError(
|
||||
f"Internal error: MIME type mapping missing for {video_format}"
|
||||
)
|
||||
|
||||
parts.append(
|
||||
{
|
||||
"inline_data": {
|
||||
"mimeType": mime_type,
|
||||
"data": validated_data,
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
f"Successfully added video part (format: {video_format})"
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
f"Skipping video part due to validation error: {e}"
|
||||
)
|
||||
parts.append({"text": f"[Error processing video: {e}]"})
|
||||
except Exception:
|
||||
logger.exception("Unexpected error processing video part.")
|
||||
parts.append(
|
||||
{"text": "[Unexpected error processing video]"}
|
||||
)
|
||||
|
||||
else:
|
||||
# Log unrecognized but present types
|
||||
if content_type:
|
||||
logger.warning(
|
||||
f"Unsupported content type or missing data in structured content: {content_type}"
|
||||
)
|
||||
|
||||
elif (
|
||||
"content" in msg and isinstance(msg["content"], str) and msg["content"]
|
||||
):
|
||||
parts.extend(_process_text_with_image(msg["content"]))
|
||||
elif "content" in msg and isinstance(msg["content"], list):
|
||||
for content in msg["content"]:
|
||||
if isinstance(content, str) and content:
|
||||
parts.append({"text": content})
|
||||
elif isinstance(content, dict):
|
||||
if content["type"] == "text" and content["text"]:
|
||||
parts.append({"text": content["text"]})
|
||||
elif content["type"] == "image_url":
|
||||
parts.append(_convert_image(content["image_url"]["url"]))
|
||||
elif "tool_calls" in msg and isinstance(msg["tool_calls"], list):
|
||||
# Keep existing tool call processing
|
||||
for tool_call in msg["tool_calls"]:
|
||||
function_call = tool_call.get("function",{})
|
||||
function_call["args"] = json.loads(function_call.get("arguments","{}"))
|
||||
del function_call["arguments"]
|
||||
function_call = tool_call.get("function", {})
|
||||
# Sanitize arguments loading
|
||||
arguments_str = function_call.get("arguments", "{}")
|
||||
try:
|
||||
function_call["args"] = json.loads(arguments_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"Failed to decode tool call arguments: {arguments_str}"
|
||||
)
|
||||
function_call["args"] = {}
|
||||
if "arguments" in function_call:
|
||||
if "arguments" in function_call:
|
||||
del function_call["arguments"]
|
||||
|
||||
parts.append({"functionCall": function_call})
|
||||
|
||||
|
||||
if role not in SUPPORTED_ROLES:
|
||||
if role == "tool":
|
||||
role = "user"
|
||||
@@ -159,7 +337,14 @@ class OpenAIMessageConverter(MessageConverter):
|
||||
role = "model"
|
||||
if parts:
|
||||
if role == "system":
|
||||
system_instruction_parts.extend(parts)
|
||||
text_only_parts = [p for p in parts if "text" in p]
|
||||
if len(text_only_parts) != len(parts):
|
||||
logger.warning(
|
||||
"Non-text parts found in system message; discarding them."
|
||||
)
|
||||
if text_only_parts:
|
||||
system_instruction_parts.extend(text_only_parts)
|
||||
|
||||
else:
|
||||
converted_messages.append({"role": role, "parts": parts})
|
||||
|
||||
@@ -171,4 +356,4 @@ class OpenAIMessageConverter(MessageConverter):
|
||||
"parts": system_instruction_parts,
|
||||
}
|
||||
)
|
||||
return converted_messages, system_instruction
|
||||
return converted_messages, system_instruction
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# app/services/chat/response_handler.py
|
||||
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, List, Optional
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.config.config import settings
|
||||
from app.utils.uploader import ImageUploaderFactory
|
||||
|
||||
@@ -16,7 +15,9 @@ class ResponseHandler(ABC):
|
||||
"""响应处理器基类"""
|
||||
|
||||
@abstractmethod
|
||||
def handle_response(self, response: Dict[str, Any], model: str, stream: bool = False) -> Dict[str, Any]:
|
||||
def handle_response(
|
||||
self, response: Dict[str, Any], model: str, stream: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
|
||||
@@ -27,14 +28,20 @@ class GeminiResponseHandler(ResponseHandler):
|
||||
self.thinking_first = True
|
||||
self.thinking_status = False
|
||||
|
||||
def handle_response(self, response: Dict[str, Any], model: str, stream: bool = False) -> Dict[str, Any]:
|
||||
def handle_response(
|
||||
self, response: Dict[str, Any], model: str, stream: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
if stream:
|
||||
return _handle_gemini_stream_response(response, model, stream)
|
||||
return _handle_gemini_normal_response(response, model, stream)
|
||||
|
||||
|
||||
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
|
||||
def _handle_openai_stream_response(
|
||||
response: Dict[str, Any], model: str, finish_reason: str
|
||||
) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(
|
||||
response, model, stream=True, gemini_format=False
|
||||
)
|
||||
if not text and not tool_calls:
|
||||
delta = {}
|
||||
else:
|
||||
@@ -51,8 +58,12 @@ def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_
|
||||
}
|
||||
|
||||
|
||||
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
|
||||
def _handle_openai_normal_response(
|
||||
response: Dict[str, Any], model: str, finish_reason: str
|
||||
) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(
|
||||
response, model, stream=False, gemini_format=False
|
||||
)
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion",
|
||||
@@ -61,7 +72,11 @@ def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": text,
|
||||
"tool_calls": tool_calls,
|
||||
},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
@@ -78,59 +93,67 @@ class OpenAIResponseHandler(ResponseHandler):
|
||||
self.thinking_status = False
|
||||
|
||||
def handle_response(
|
||||
self,
|
||||
response: Dict[str, Any],
|
||||
model: str,
|
||||
stream: bool = False,
|
||||
finish_reason: str = None
|
||||
self,
|
||||
response: Dict[str, Any],
|
||||
model: str,
|
||||
stream: bool = False,
|
||||
finish_reason: str = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if stream:
|
||||
return _handle_openai_stream_response(response, model, finish_reason)
|
||||
return _handle_openai_normal_response(response, model, finish_reason)
|
||||
|
||||
def handle_image_chat_response(self, image_str: str, model: str, stream=False, finish_reason="stop"):
|
||||
|
||||
def handle_image_chat_response(
|
||||
self, image_str: str, model: str, stream=False, finish_reason="stop"
|
||||
):
|
||||
if stream:
|
||||
return _handle_openai_stream_image_response(image_str,model,finish_reason)
|
||||
return _handle_openai_normal_image_response(image_str,model,finish_reason)
|
||||
|
||||
|
||||
def _handle_openai_stream_image_response(image_str: str,model: str,finish_reason: str) -> Dict[str, Any]:
|
||||
return _handle_openai_stream_image_response(image_str, model, finish_reason)
|
||||
return _handle_openai_normal_image_response(image_str, model, finish_reason)
|
||||
|
||||
|
||||
def _handle_openai_stream_image_response(
|
||||
image_str: str, model: str, finish_reason: str
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": image_str} if image_str else {},
|
||||
"finish_reason": finish_reason
|
||||
}]
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"content": image_str} if image_str else {},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _handle_openai_normal_image_response(image_str: str,model: str,finish_reason: str) -> Dict[str, Any]:
|
||||
def _handle_openai_normal_image_response(
|
||||
image_str: str, model: str, finish_reason: str
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": image_str
|
||||
},
|
||||
"finish_reason": finish_reason
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": image_str},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}
|
||||
|
||||
|
||||
def _extract_result(response: Dict[str, Any], model: str, stream: bool = False, gemini_format: bool = False) -> tuple[str, List[Dict[str, Any]]]:
|
||||
def _extract_result(
|
||||
response: Dict[str, Any],
|
||||
model: str,
|
||||
stream: bool = False,
|
||||
gemini_format: bool = False,
|
||||
) -> tuple[str, List[Dict[str, Any]]]:
|
||||
text, tool_calls = "", []
|
||||
if stream:
|
||||
if response.get("candidates"):
|
||||
@@ -146,13 +169,9 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
|
||||
elif "codeExecution" in parts[0]:
|
||||
text = _format_code_block(parts[0]["codeExecution"])
|
||||
elif "executableCodeResult" in parts[0]:
|
||||
text = _format_execution_result(
|
||||
parts[0]["executableCodeResult"]
|
||||
)
|
||||
text = _format_execution_result(parts[0]["executableCodeResult"])
|
||||
elif "codeExecutionResult" in parts[0]:
|
||||
text = _format_execution_result(
|
||||
parts[0]["codeExecutionResult"]
|
||||
)
|
||||
text = _format_execution_result(parts[0]["codeExecutionResult"])
|
||||
elif "inlineData" in parts[0]:
|
||||
text = _extract_image_data(parts[0])
|
||||
else:
|
||||
@@ -166,10 +185,10 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
|
||||
if settings.SHOW_THINKING_PROCESS:
|
||||
if len(candidate["content"]["parts"]) == 2:
|
||||
text = (
|
||||
"> thinking\n\n"
|
||||
+ candidate["content"]["parts"][0]["text"]
|
||||
+ "\n\n---\n> output\n\n"
|
||||
+ candidate["content"]["parts"][1]["text"]
|
||||
"> thinking\n\n"
|
||||
+ candidate["content"]["parts"][0]["text"]
|
||||
+ "\n\n---\n> output\n\n"
|
||||
+ candidate["content"]["parts"][1]["text"]
|
||||
)
|
||||
else:
|
||||
text = candidate["content"]["parts"][0]["text"]
|
||||
@@ -187,34 +206,47 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
|
||||
elif "inlineData" in part:
|
||||
text += _extract_image_data(part)
|
||||
|
||||
|
||||
text = _add_search_link_text(model, candidate, text)
|
||||
tool_calls = _extract_tool_calls(candidate["content"]["parts"], gemini_format)
|
||||
tool_calls = _extract_tool_calls(
|
||||
candidate["content"]["parts"], gemini_format
|
||||
)
|
||||
else:
|
||||
text = "暂无返回"
|
||||
return text, tool_calls
|
||||
|
||||
|
||||
def _extract_image_data(part: dict) -> str:
|
||||
image_uploader = None
|
||||
if settings.UPLOAD_PROVIDER == "smms":
|
||||
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,api_key=settings.SMMS_SECRET_TOKEN)
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER, api_key=settings.SMMS_SECRET_TOKEN
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "picgo":
|
||||
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,api_key=settings.PICGO_API_KEY)
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER, api_key=settings.PICGO_API_KEY
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "cloudflare_imgbed":
|
||||
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,base_url=settings.CLOUDFLARE_IMGBED_URL,auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE)
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER,
|
||||
base_url=settings.CLOUDFLARE_IMGBED_URL,
|
||||
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
|
||||
)
|
||||
current_date = time.strftime("%Y/%m/%d")
|
||||
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||
base64_data = part["inlineData"]["data"]
|
||||
#将base64_data转成bytes数组
|
||||
# 将base64_data转成bytes数组
|
||||
bytes_data = base64.b64decode(base64_data)
|
||||
upload_response = image_uploader.upload(bytes_data,filename)
|
||||
upload_response = image_uploader.upload(bytes_data, filename)
|
||||
if upload_response.success:
|
||||
text = f"\n\n\n\n"
|
||||
else:
|
||||
text = ""
|
||||
return text
|
||||
|
||||
def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def _extract_tool_calls(
|
||||
parts: List[Dict[str, Any]], gemini_format: bool
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""提取工具调用信息"""
|
||||
if not parts or not isinstance(parts, list):
|
||||
return []
|
||||
@@ -250,8 +282,12 @@ def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> Lis
|
||||
return tool_calls
|
||||
|
||||
|
||||
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
||||
def _handle_gemini_stream_response(
|
||||
response: Dict[str, Any], model: str, stream: bool
|
||||
) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(
|
||||
response, model, stream=stream, gemini_format=True
|
||||
)
|
||||
if tool_calls:
|
||||
content = {"parts": tool_calls, "role": "model"}
|
||||
else:
|
||||
@@ -260,8 +296,12 @@ def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream:
|
||||
return response
|
||||
|
||||
|
||||
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
||||
def _handle_gemini_normal_response(
|
||||
response: Dict[str, Any], model: str, stream: bool
|
||||
) -> Dict[str, Any]:
|
||||
text, tool_calls = _extract_result(
|
||||
response, model, stream=stream, gemini_format=True
|
||||
)
|
||||
if tool_calls:
|
||||
content = {"parts": tool_calls, "role": "model"}
|
||||
else:
|
||||
@@ -279,10 +319,10 @@ def _format_code_block(code_data: dict) -> str:
|
||||
|
||||
def _add_search_link_text(model: str, candidate: dict, text: str) -> str:
|
||||
if (
|
||||
settings.SHOW_SEARCH_LINK
|
||||
and model.endswith("-search")
|
||||
and "groundingMetadata" in candidate
|
||||
and "groundingChunks" in candidate["groundingMetadata"]
|
||||
settings.SHOW_SEARCH_LINK
|
||||
and model.endswith("-search")
|
||||
and "groundingMetadata" in candidate
|
||||
and "groundingChunks" in candidate["groundingMetadata"]
|
||||
):
|
||||
grounding_chunks = candidate["groundingMetadata"]["groundingChunks"]
|
||||
text += "\n\n---\n\n"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# app/services/chat/retry_handler.py
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable, TypeVar
|
||||
@@ -23,21 +22,26 @@ class RetryHandler:
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
retries = attempt + 1
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
logger.warning(
|
||||
f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}"
|
||||
f"API call failed with error: {str(e)}. Attempt {retries} of {self.max_retries}"
|
||||
)
|
||||
|
||||
# 从函数参数中获取 key_manager
|
||||
key_manager = kwargs.get("key_manager")
|
||||
if key_manager:
|
||||
old_key = kwargs.get(self.key_arg)
|
||||
new_key = await key_manager.handle_api_failure(old_key)
|
||||
kwargs[self.key_arg] = new_key
|
||||
logger.info(f"Switched to new API key: {new_key}")
|
||||
new_key = await key_manager.handle_api_failure(old_key, retries)
|
||||
if new_key:
|
||||
kwargs[self.key_arg] = new_key
|
||||
logger.info(f"Switched to new API key: {new_key}")
|
||||
else:
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break
|
||||
|
||||
logger.error(
|
||||
f"All retry attempts failed, raising final exception: {str(last_exception)}"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# app/services/chat/stream_optimizer.py
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
@@ -107,15 +106,11 @@ class StreamOptimizer:
|
||||
|
||||
# 计算智能延迟时间
|
||||
delay = self.calculate_delay(len(text))
|
||||
# if self.logger:
|
||||
# self.logger.info(f"Text length: {len(text)}, delay: {delay:.4f}s")
|
||||
|
||||
# 根据文本长度决定输出方式
|
||||
if len(text) >= self.long_text_threshold:
|
||||
# 长文本:分块输出
|
||||
chunks = self.split_text_into_chunks(text)
|
||||
# if self.logger:
|
||||
# self.logger.info(f"Long text: splitting into {len(chunks)} chunks")
|
||||
for chunk_text in chunks:
|
||||
chunk_response = create_response_chunk(chunk_text)
|
||||
yield format_chunk(chunk_response)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import logging
|
||||
import platform
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
import platform
|
||||
|
||||
# ANSI转义序列颜色代码
|
||||
COLORS = {
|
||||
'DEBUG': '\033[34m', # 蓝色
|
||||
'INFO': '\033[32m', # 绿色
|
||||
'WARNING': '\033[33m', # 黄色
|
||||
'ERROR': '\033[31m', # 红色
|
||||
'CRITICAL': '\033[1;31m' # 红色加粗
|
||||
"DEBUG": "\033[34m", # 蓝色
|
||||
"INFO": "\033[32m", # 绿色
|
||||
"WARNING": "\033[33m", # 黄色
|
||||
"ERROR": "\033[31m", # 红色
|
||||
"CRITICAL": "\033[1;31m", # 红色加粗
|
||||
}
|
||||
|
||||
# Windows系统启用ANSI支持
|
||||
if platform.system() == 'Windows':
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
@@ -27,15 +27,17 @@ class ColoredFormatter(logging.Formatter):
|
||||
|
||||
def format(self, record):
|
||||
# 获取对应级别的颜色代码
|
||||
color = COLORS.get(record.levelname, '')
|
||||
color = COLORS.get(record.levelname, "")
|
||||
# 添加颜色代码和重置代码
|
||||
record.levelname = f"{color}{record.levelname}\033[0m"
|
||||
# 创建包含文件名和行号的固定宽度字符串
|
||||
record.fileloc = f"[{record.filename}:{record.lineno}]"
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# 日志格式
|
||||
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
|
||||
FORMATTER = ColoredFormatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s"
|
||||
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
|
||||
)
|
||||
|
||||
# 日志级别映射
|
||||
@@ -55,21 +57,28 @@ class Logger:
|
||||
_loggers: Dict[str, logging.Logger] = {}
|
||||
|
||||
@staticmethod
|
||||
def setup_logger(
|
||||
name: str,
|
||||
level: str = "debug",
|
||||
) -> logging.Logger:
|
||||
def setup_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
设置并获取logger
|
||||
:param name: logger名称
|
||||
:param level: 日志级别
|
||||
:return: logger实例
|
||||
"""
|
||||
# 导入 settings 对象
|
||||
from app.config.config import settings
|
||||
|
||||
# 从全局配置获取日志级别
|
||||
log_level_str = settings.LOG_LEVEL.lower()
|
||||
level = LOG_LEVELS.get(log_level_str, logging.INFO)
|
||||
|
||||
if name in Logger._loggers:
|
||||
return Logger._loggers[name]
|
||||
# 如果 logger 已存在,检查并更新其级别(如果需要)
|
||||
existing_logger = Logger._loggers[name]
|
||||
if existing_logger.level != level:
|
||||
existing_logger.setLevel(level)
|
||||
return existing_logger
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(LOG_LEVELS.get(level.lower(), logging.INFO))
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False
|
||||
|
||||
# 添加控制台输出
|
||||
@@ -89,6 +98,22 @@ class Logger:
|
||||
"""
|
||||
return Logger._loggers.get(name)
|
||||
|
||||
@staticmethod
|
||||
def update_log_levels(log_level: str):
|
||||
"""
|
||||
根据当前的全局配置更新所有已创建 logger 的日志级别。
|
||||
"""
|
||||
log_level_str = log_level.lower()
|
||||
new_level = LOG_LEVELS.get(log_level_str, logging.INFO)
|
||||
|
||||
updated_count = 0
|
||||
for logger_name, logger_instance in Logger._loggers.items():
|
||||
if logger_instance.level != new_level:
|
||||
logger_instance.setLevel(new_level)
|
||||
# 可选:记录级别变更日志,但注意避免在日志模块内部产生过多日志
|
||||
# print(f"Updated log level for logger '{logger_name}' to {log_level_str.upper()}")
|
||||
updated_count += 1
|
||||
|
||||
|
||||
# 预定义的loggers
|
||||
def get_openai_logger():
|
||||
@@ -172,4 +197,24 @@ def get_log_routes_logger():
|
||||
|
||||
|
||||
def get_stats_logger():
|
||||
return Logger.setup_logger("stats")
|
||||
return Logger.setup_logger("stats")
|
||||
|
||||
|
||||
def get_update_logger():
|
||||
return Logger.setup_logger("update_service")
|
||||
|
||||
|
||||
def get_scheduler_routes():
|
||||
return Logger.setup_logger("scheduler_routes")
|
||||
|
||||
|
||||
def get_message_converter_logger():
|
||||
return Logger.setup_logger("message_converter")
|
||||
|
||||
|
||||
def get_api_client_logger():
|
||||
return Logger.setup_logger("api_client")
|
||||
|
||||
|
||||
def get_openai_compatible_logger():
|
||||
return Logger.setup_logger("openai_compatible")
|
||||
@@ -1,18 +1,11 @@
|
||||
"""
|
||||
应用程序入口模块
|
||||
"""
|
||||
|
||||
import uvicorn
|
||||
|
||||
from app.core.application import create_app
|
||||
from app.log.logger import get_main_logger
|
||||
|
||||
# 创建应用程序实例
|
||||
app = create_app()
|
||||
|
||||
# 配置日志
|
||||
logger = get_main_logger()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger = get_main_logger()
|
||||
logger.info("Starting application server...")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
@@ -30,6 +30,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
and not request.url.path.startswith(f"/{API_VERSION}")
|
||||
and not request.url.path.startswith("/health")
|
||||
and not request.url.path.startswith("/hf")
|
||||
and not request.url.path.startswith("/openai")
|
||||
and not request.url.path.startswith("/api/version/check")
|
||||
):
|
||||
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
|
||||
@@ -26,7 +26,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
f"Formatted request body:\n{json.dumps(formatted_body, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.info("Request body is not valid JSON.")
|
||||
logger.error("Request body is not valid JSON.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading request body: {str(e)}")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_config_routes_logger
|
||||
from app.log.logger import get_config_routes_logger, Logger # 导入 Logger 类
|
||||
from app.service.config.config_service import ConfigService
|
||||
|
||||
# 创建路由
|
||||
@@ -31,8 +31,13 @@ async def update_config(config_data: Dict[str, Any], request: Request):
|
||||
logger.warning("Unauthorized access attempt to config page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
try:
|
||||
return await ConfigService.update_config(config_data)
|
||||
result = await ConfigService.update_config(config_data)
|
||||
# 配置更新成功后,立即更新所有 logger 的级别
|
||||
Logger.update_log_levels(config_data["LOG_LEVEL"])
|
||||
logger.info("Log levels updated after configuration change.") # 添加日志记录
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating config or log levels: {e}", exc_info=True) # 记录详细错误
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
|
||||
201
app/router/error_log_routes.py
Normal file
201
app/router/error_log_routes.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
日志路由模块
|
||||
"""
|
||||
from typing import List, Optional, Dict
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, Request, Query, Path, Body, Response, status
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_log_routes_logger
|
||||
# 假设这些服务函数已更新或添加
|
||||
from app.database.services import (
|
||||
get_error_logs,
|
||||
get_error_logs_count,
|
||||
get_error_log_details,
|
||||
delete_error_logs_by_ids, # 新增导入
|
||||
delete_error_log_by_id # 新增导入
|
||||
)
|
||||
# Removed get_db import comment as it's fully removed now
|
||||
|
||||
# 创建路由
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
logger = get_log_routes_logger()
|
||||
|
||||
|
||||
# Define a response model that includes the total count for pagination
|
||||
# 用于列表响应的模型,假设 get_error_logs 返回包含 error_code 的字典
|
||||
class ErrorLogListItem(BaseModel):
|
||||
id: int
|
||||
gemini_key: Optional[str] = None
|
||||
error_type: Optional[str] = None
|
||||
error_code: Optional[int] = None # 列表显示错误码 (应为整数)
|
||||
model_name: Optional[str] = None
|
||||
request_time: Optional[datetime] = None
|
||||
|
||||
class ErrorLogListResponse(BaseModel):
|
||||
logs: List[ErrorLogListItem] # 使用定义的模型列表
|
||||
total: int
|
||||
|
||||
@router.get("/errors", response_model=ErrorLogListResponse)
|
||||
async def get_error_logs_api(
|
||||
request: Request,
|
||||
limit: int = Query(10, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
|
||||
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
|
||||
error_code_search: Optional[str] = Query(None, description="Search term for error code"), # Added error code search parameter
|
||||
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
|
||||
end_date: Optional[datetime] = Query(None, description="End datetime for filtering"),
|
||||
sort_by: str = Query('id', description="Field to sort by (e.g., 'id', 'request_time')"), # 新增排序参数
|
||||
sort_order: str = Query('desc', description="Sort order ('asc' or 'desc')") # 新增排序参数
|
||||
):
|
||||
"""
|
||||
获取错误日志列表 (返回错误码),支持过滤和排序
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
limit: 限制数量
|
||||
offset: 偏移量
|
||||
key_search: 密钥搜索
|
||||
error_search: 错误搜索 (可能搜索类型或日志内容,由DB层决定)
|
||||
error_code_search: 错误码搜索
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
sort_by: 排序字段
|
||||
sort_order: 排序顺序
|
||||
|
||||
Returns:
|
||||
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to error logs list")
|
||||
# API 返回 401 更合适
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
# 假设 get_error_logs 现在返回包含 error_code 的字典列表
|
||||
# 并且可以接受 include_error_code 参数 (如果需要显式指定)
|
||||
logs_data = await get_error_logs(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
key_search=key_search,
|
||||
error_search=error_search, # 数据库查询需要处理这个
|
||||
error_code_search=error_code_search, # Pass error code search to DB function
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
sort_by=sort_by, # 传递排序参数
|
||||
sort_order=sort_order # 传递排序参数
|
||||
)
|
||||
# Fetch total count with the same search parameters
|
||||
total_count = await get_error_logs_count(
|
||||
key_search=key_search,
|
||||
error_search=error_search,
|
||||
error_code_search=error_code_search, # Pass error code search to DB count function
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
# 验证并转换数据以匹配 Pydantic 模型
|
||||
validated_logs = [ErrorLogListItem(**log) for log in logs_data]
|
||||
return ErrorLogListResponse(logs=validated_logs, total=total_count)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get error logs list: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get error logs list: {str(e)}")
|
||||
|
||||
|
||||
# 新增:获取错误日志详情的路由
|
||||
class ErrorLogDetailResponse(BaseModel):
|
||||
id: int
|
||||
gemini_key: Optional[str] = None
|
||||
error_type: Optional[str] = None
|
||||
error_log: Optional[str] = None # 详情接口返回完整的 error_log
|
||||
request_msg: Optional[str] = None # 详情接口返回 request_msg
|
||||
model_name: Optional[str] = None
|
||||
request_time: Optional[datetime] = None
|
||||
|
||||
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
|
||||
async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=1)):
|
||||
"""
|
||||
根据日志 ID 获取错误日志的详细信息 (包括 error_log 和 request_msg)
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning(f"Unauthorized access attempt to error log details for ID: {log_id}")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
# 假设存在一个函数 get_error_log_details(log_id) 来获取完整信息
|
||||
log_details = await get_error_log_details(log_id=log_id)
|
||||
if not log_details:
|
||||
raise HTTPException(status_code=404, detail="Error log not found")
|
||||
|
||||
# 假设 get_error_log_details 返回一个字典或兼容 Pydantic 的对象
|
||||
return ErrorLogDetailResponse(**log_details)
|
||||
except HTTPException as http_exc:
|
||||
# Re-raise HTTPException (like 404)
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get error log details: {str(e)}")
|
||||
|
||||
|
||||
# 新增:批量删除错误日志
|
||||
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_error_logs_bulk_api(
|
||||
request: Request,
|
||||
payload: Dict[str, List[int]] = Body(...) # Expects {"ids": [1, 2, 3]}
|
||||
# Ensure db dependency is fully removed
|
||||
):
|
||||
"""
|
||||
批量删除错误日志 (异步)
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to bulk delete error logs")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
log_ids = payload.get("ids")
|
||||
if not log_ids:
|
||||
raise HTTPException(status_code=400, detail="No log IDs provided for deletion.")
|
||||
|
||||
try:
|
||||
# 调用异步服务函数
|
||||
deleted_count = await delete_error_logs_by_ids(log_ids)
|
||||
# 注意:异步函数返回的是尝试删除的数量,可能不是精确值
|
||||
logger.info(f"Attempted bulk deletion for {deleted_count} error logs with IDs: {log_ids}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error bulk deleting error logs with IDs {log_ids}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error during bulk deletion")
|
||||
|
||||
|
||||
# 新增:删除单个错误日志
|
||||
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_error_log_api(
|
||||
request: Request,
|
||||
log_id: int = Path(..., ge=1)
|
||||
# Ensure db dependency is fully removed
|
||||
):
|
||||
"""
|
||||
删除单个错误日志 (异步)
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning(f"Unauthorized access attempt to delete error log ID: {log_id}")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
# 调用异步服务函数
|
||||
success = await delete_error_log_by_id(log_id)
|
||||
if not success:
|
||||
# 服务层现在在未找到时返回 False,我们在这里转换为 404
|
||||
raise HTTPException(status_code=404, detail=f"Error log with ID {log_id} not found")
|
||||
logger.info(f"Successfully deleted error log with ID: {log_id}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except HTTPException as http_exc:
|
||||
raise http_exc # Re-raise 404 or other HTTP exceptions
|
||||
except Exception as e:
|
||||
logger.exception(f"Error deleting error log with ID {log_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error during deletion")
|
||||
@@ -1,14 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from copy import deepcopy
|
||||
import asyncio
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_gemini_logger
|
||||
from app.core.security import SecurityService
|
||||
from app.domain.gemini_models import GeminiContent, GeminiRequest
|
||||
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest # 添加导入
|
||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.model.model_service import ModelService
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.core.constants import API_VERSION
|
||||
|
||||
# 路由设置
|
||||
@@ -42,47 +44,57 @@ async def list_models(
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
):
|
||||
"""获取可用的Gemini模型列表"""
|
||||
logger.info("-" * 50 + "list_gemini_models" + "-" * 50)
|
||||
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
|
||||
operation_name = "list_gemini_models"
|
||||
logger.info("-" * 50 + operation_name + "-" * 50)
|
||||
logger.info("Handling Gemini models list request")
|
||||
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
|
||||
models_json = model_service.get_gemini_models(api_key)
|
||||
model_mapping = {x.get("name", "").split("/", maxsplit=1)[1]: x for x in models_json["models"]}
|
||||
|
||||
# 添加搜索模型
|
||||
if model_service.search_models:
|
||||
for name in model_service.search_models:
|
||||
model = model_mapping.get(name)
|
||||
|
||||
try:
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
|
||||
models_data =await model_service.get_gemini_models(api_key)
|
||||
if not models_data or "models" not in models_data:
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
|
||||
|
||||
models_json = deepcopy(models_data) # 操作副本以防修改原始缓存
|
||||
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
|
||||
|
||||
def add_derived_model(base_name, suffix, display_suffix):
|
||||
model = model_mapping.get(base_name)
|
||||
if not model:
|
||||
continue
|
||||
|
||||
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
|
||||
return
|
||||
item = deepcopy(model)
|
||||
item["name"] = f"models/{name}-search"
|
||||
display_name = f'{item.get("displayName")} For Search'
|
||||
item["name"] = f"models/{base_name}{suffix}"
|
||||
display_name = f'{item.get("displayName", base_name)}{display_suffix}'
|
||||
item["displayName"] = display_name
|
||||
item["description"] = display_name
|
||||
|
||||
models_json["models"].append(item)
|
||||
|
||||
# 添加图像生成模型
|
||||
if model_service.image_models:
|
||||
for name in model_service.image_models:
|
||||
model = model_mapping.get(name)
|
||||
if not model:
|
||||
continue
|
||||
|
||||
item = deepcopy(model)
|
||||
item["name"] = f"models/{name}-image"
|
||||
display_name = f'{item.get("displayName")} For Image'
|
||||
item["displayName"] = display_name
|
||||
item["description"] = display_name
|
||||
|
||||
models_json["models"].append(item)
|
||||
|
||||
return models_json
|
||||
|
||||
# 添加衍生模型
|
||||
if settings.SEARCH_MODELS:
|
||||
for name in settings.SEARCH_MODELS:
|
||||
add_derived_model(name, "-search", " For Search")
|
||||
if settings.IMAGE_MODELS:
|
||||
for name in settings.IMAGE_MODELS:
|
||||
add_derived_model(name, "-image", " For Image")
|
||||
if settings.THINKING_MODELS:
|
||||
for name in settings.THINKING_MODELS:
|
||||
add_derived_model(name, "-non-thinking", " Non Thinking")
|
||||
|
||||
logger.info("Gemini models list request successful")
|
||||
return models_json
|
||||
except HTTPException as http_exc:
|
||||
# 重新抛出已知的 HTTP 异常
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Gemini models list: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Internal server error while fetching Gemini models list"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/models/{model_name}:generateContent")
|
||||
@@ -93,27 +105,25 @@ async def generate_content(
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
):
|
||||
"""非流式生成内容"""
|
||||
logger.info("-" * 50 + "gemini_generate_content" + "-" * 50)
|
||||
logger.info(f"Handling Gemini content generation request for model: {model_name}")
|
||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
|
||||
if not model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
try:
|
||||
"""处理 Gemini 非流式内容生成请求。"""
|
||||
operation_name = "gemini_generate_content"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
|
||||
logger.info(f"Handling Gemini content generation request for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
response = await chat_service.generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Chat completion failed after retries: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Chat completion failed") from e
|
||||
|
||||
|
||||
@router.post("/models/{model_name}:streamGenerateContent")
|
||||
@@ -124,27 +134,27 @@ async def stream_generate_content(
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
):
|
||||
"""流式生成内容"""
|
||||
logger.info("-" * 50 + "gemini_stream_generate_content" + "-" * 50)
|
||||
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
|
||||
if not model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
try:
|
||||
"""处理 Gemini 流式内容生成请求。"""
|
||||
operation_name = "gemini_stream_generate_content"
|
||||
# 流式请求的成功/失败日志在流处理中更复杂,这里仅用上下文管理器处理启动错误
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
|
||||
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
response_stream = chat_service.stream_generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
)
|
||||
# 注意:流本身的错误需要在服务层或流迭代中处理,这里只返回流响应
|
||||
return StreamingResponse(response_stream, media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
logger.error(f"Streaming request failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Streaming request failed") from e
|
||||
|
||||
@router.post("/reset-all-fail-counts")
|
||||
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
|
||||
@@ -183,6 +193,62 @@ async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManage
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset key failure counts: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500)
|
||||
|
||||
|
||||
@router.post("/reset-selected-fail-counts")
|
||||
async def reset_selected_key_fail_counts(
|
||||
request: ResetSelectedKeysRequest,
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
):
|
||||
"""批量重置选定Gemini API密钥的失败计数"""
|
||||
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
|
||||
keys_to_reset = request.keys
|
||||
key_type = request.key_type # 获取类型用于日志记录和响应消息
|
||||
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
|
||||
|
||||
if not keys_to_reset:
|
||||
return JSONResponse({"success": False, "message": "没有提供需要重置的密钥"}, status_code=400)
|
||||
|
||||
reset_count = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
for key in keys_to_reset:
|
||||
try:
|
||||
result = await key_manager.reset_key_failure_count(key)
|
||||
if result:
|
||||
reset_count += 1
|
||||
else:
|
||||
# 记录未找到的密钥,但不视为致命错误
|
||||
logger.warning(f"Key not found during selective reset: {key}")
|
||||
except Exception as key_error:
|
||||
# 记录单个密钥重置时的错误
|
||||
logger.error(f"Error resetting key {key}: {str(key_error)}")
|
||||
errors.append(f"Key {key}: {str(key_error)}")
|
||||
|
||||
if errors:
|
||||
# 如果有错误,报告部分成功或完全失败
|
||||
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
|
||||
# 确定最终状态码和成功标志
|
||||
final_success = reset_count > 0
|
||||
status_code = 207 if final_success and errors else 500 # 207 Multi-Status if partially successful, 500 if completely failed
|
||||
return JSONResponse({
|
||||
"success": final_success,
|
||||
"message": error_message,
|
||||
"reset_count": reset_count
|
||||
}, status_code=status_code)
|
||||
|
||||
# 完全成功的情况
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
|
||||
"reset_count": reset_count
|
||||
})
|
||||
except Exception as e:
|
||||
# 捕获循环外的意外错误
|
||||
logger.error(f"Failed to process reset selected key failure counts request: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"批量重置处理失败: {str(e)}"}, status_code=500)
|
||||
|
||||
|
||||
|
||||
@router.post("/reset-fail-count/{api_key}")
|
||||
@@ -234,4 +300,103 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Verification exception for key: {api_key}, incrementing failure count")
|
||||
|
||||
return JSONResponse({"status": "invalid", "error": str(e)})
|
||||
return JSONResponse({"status": "invalid", "error": str(e)})
|
||||
|
||||
|
||||
@router.post("/verify-selected-keys")
|
||||
async def verify_selected_keys(
|
||||
request: VerifySelectedKeysRequest,
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
):
|
||||
"""批量验证选定Gemini API密钥的有效性"""
|
||||
logger.info("-" * 50 + "verify_selected_gemini_keys" + "-" * 50)
|
||||
keys_to_verify = request.keys
|
||||
logger.info(f"Received verification request for {len(keys_to_verify)} selected keys.")
|
||||
|
||||
if not keys_to_verify:
|
||||
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
|
||||
|
||||
successful_keys = []
|
||||
failed_keys = {} # 存储失败的 key 和错误信息
|
||||
|
||||
async def _verify_single_key(api_key: str):
|
||||
"""内部函数,用于验证单个密钥并处理异常"""
|
||||
nonlocal successful_keys, failed_keys # 允许修改外部列表和字典
|
||||
try:
|
||||
# 重用单密钥验证逻辑的核心部分
|
||||
gemini_request = GeminiRequest(
|
||||
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])]
|
||||
)
|
||||
# 注意:这里直接调用 chat_service.generate_content,不依赖于 key_manager 获取密钥
|
||||
await chat_service.generate_content(
|
||||
settings.TEST_MODEL,
|
||||
gemini_request,
|
||||
api_key
|
||||
)
|
||||
# 如果上面没有抛出异常,则认为密钥有效
|
||||
successful_keys.append(api_key)
|
||||
return api_key, "valid", None
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.warning(f"Key verification failed for {api_key}: {error_message}")
|
||||
# 验证失败时增加失败计数 (使用与 /verify-key 一致的逻辑)
|
||||
async with key_manager.failure_count_lock:
|
||||
if api_key in key_manager.key_failure_counts:
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Bulk verification exception for key: {api_key}, incrementing failure count")
|
||||
else:
|
||||
# 如果密钥不在计数中(可能刚添加或从未失败),初始化为1
|
||||
key_manager.key_failure_counts[api_key] = 1
|
||||
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
|
||||
failed_keys[api_key] = error_message # 记录失败的 key 和错误信息
|
||||
return api_key, "invalid", error_message
|
||||
|
||||
# 并发执行所有密钥的验证
|
||||
tasks = [_verify_single_key(key) for key in keys_to_verify]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True) # return_exceptions=True 捕获任务本身的异常
|
||||
|
||||
# 处理并发执行的结果
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
# 捕获 asyncio.gather 可能遇到的异常(例如任务被取消)
|
||||
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
|
||||
# 可以选择如何处理这种任务级别的错误,这里我们简单记录
|
||||
# 也可以将其计入 invalid_count 或单独记录
|
||||
elif result:
|
||||
# result 可能是 (key, status, error) 或 Exception
|
||||
if not isinstance(result, Exception) and result:
|
||||
key, status, error = result
|
||||
# 失败信息已在 _verify_single_key 中记录到 failed_keys
|
||||
elif isinstance(result, Exception):
|
||||
# 记录任务本身的异常,可以关联到一个特定的 key 如果可能的话
|
||||
# 这里简化处理,只记录日志
|
||||
logger.error(f"Task execution error during bulk verification: {result}")
|
||||
|
||||
valid_count = len(successful_keys)
|
||||
invalid_count = len(failed_keys)
|
||||
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
|
||||
|
||||
# 根据是否有失败的 key 决定最终消息和状态
|
||||
if failed_keys:
|
||||
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}。"
|
||||
# 即使有失败也认为是部分成功,返回 200 OK,让前端处理详细结果
|
||||
return JSONResponse({
|
||||
"success": True, # 表示请求处理完成,具体结果看内容
|
||||
"message": message,
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": failed_keys,
|
||||
"valid_count": valid_count, # 保留计数方便前端快速展示
|
||||
"invalid_count": invalid_count
|
||||
})
|
||||
else:
|
||||
# 完全成功
|
||||
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": {},
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": 0
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
"""
|
||||
日志路由模块
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, Request, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_log_routes_logger
|
||||
from app.database.services import get_error_logs, get_error_logs_count
|
||||
|
||||
# 创建路由
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
logger = get_log_routes_logger()
|
||||
|
||||
|
||||
# Define a response model that includes the total count for pagination
|
||||
class ErrorLogResponse(BaseModel):
|
||||
logs: List[Dict[str, Any]]
|
||||
total: int
|
||||
|
||||
@router.get("/errors", response_model=ErrorLogResponse)
|
||||
async def get_error_logs_api(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=1000), # Default to 20 to match frontend
|
||||
offset: int = Query(0, ge=0),
|
||||
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
|
||||
error_search: Optional[str] = Query(None, description="Search term for error type or log message"),
|
||||
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering (YYYY-MM-DDTHH:MM)"),
|
||||
end_date: Optional[datetime] = Query(None, description="End datetime for filtering (YYYY-MM-DDTHH:MM)")
|
||||
):
|
||||
"""
|
||||
获取错误日志
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
limit: 限制数量
|
||||
offset: 偏移量
|
||||
|
||||
Returns:
|
||||
ErrorLogResponse: An object containing the list of logs and the total count.
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to error logs")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
try:
|
||||
# Fetch logs with search parameters
|
||||
logs = await get_error_logs(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
key_search=key_search,
|
||||
error_search=error_search,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
# Fetch total count with the same search parameters
|
||||
total_count = await get_error_logs_count(
|
||||
key_search=key_search,
|
||||
error_search=error_search,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
return ErrorLogResponse(logs=logs, total=total_count)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get error logs: {str(e)}") # Use logger.exception for stack trace
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get error logs: {str(e)}")
|
||||
121
app/router/openai_compatiable_routes.py
Normal file
121
app/router/openai_compatiable_routes.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.config.config import settings
|
||||
from app.core.security import SecurityService
|
||||
from app.domain.openai_models import (
|
||||
ChatRequest,
|
||||
EmbeddingRequest,
|
||||
ImageGenerationRequest,
|
||||
)
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.log.logger import get_openai_compatible_logger
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.openai_compatiable.openai_compatiable_service import OpenAICompatiableService
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_openai_compatible_logger()
|
||||
|
||||
# 初始化服务
|
||||
security_service = SecurityService()
|
||||
|
||||
async def get_key_manager():
|
||||
return await get_key_manager_instance()
|
||||
|
||||
|
||||
async def get_next_working_key_wrapper(
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
return await key_manager.get_next_working_key()
|
||||
|
||||
|
||||
async def get_openai_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""获取OpenAI聊天服务实例"""
|
||||
return OpenAICompatiableService(settings.BASE_URL, key_manager)
|
||||
|
||||
|
||||
@router.get("/openai/v1/models")
|
||||
async def list_models(
|
||||
_=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
):
|
||||
"""获取可用模型列表。"""
|
||||
operation_name = "list_models"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
return await openai_service.get_models(api_key)
|
||||
|
||||
|
||||
@router.post("/openai/v1/chat/completions")
|
||||
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
|
||||
async def chat_completion(
|
||||
request: ChatRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
api_key: str = Depends(get_next_working_key_wrapper),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
):
|
||||
"""处理聊天补全请求,支持流式响应和特定模型切换。"""
|
||||
operation_name = "chat_completion"
|
||||
# 检查是否为图像生成相关的聊天模型,如果是,则使用付费密钥
|
||||
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
|
||||
current_api_key = api_key # 保存原始key(可能是普通key)
|
||||
if is_image_chat:
|
||||
current_api_key = await key_manager.get_paid_key() # 获取付费密钥
|
||||
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {current_api_key}") # 使用 current_api_key
|
||||
|
||||
if is_image_chat:
|
||||
# 图像生成聊天,调用特定服务,不处理流式
|
||||
response = await openai_service.create_image_chat_completion(request, current_api_key)
|
||||
return response # 直接返回结果
|
||||
else:
|
||||
# 普通聊天补全
|
||||
response = await openai_service.create_chat_completion(request, current_api_key)
|
||||
# 处理流式响应
|
||||
if request.stream:
|
||||
# 假设 openai_service.create_chat_completion 在流式时返回异步生成器
|
||||
return StreamingResponse(response, media_type="text/event-stream")
|
||||
# 非流式直接返回结果
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/openai/v1/images/generations")
|
||||
async def generate_image(
|
||||
request: ImageGenerationRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
):
|
||||
"""处理图像生成请求。"""
|
||||
operation_name = "generate_image"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
||||
# 强制使用配置的模型,确保请求中包含正确的模型信息
|
||||
request.model = settings.CREATE_IMAGE_MODEL
|
||||
return await openai_service.generate_images(request)
|
||||
|
||||
|
||||
@router.post("/openai/v1/embeddings")
|
||||
async def embedding(
|
||||
request: EmbeddingRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
):
|
||||
"""处理文本嵌入请求。"""
|
||||
operation_name = "embedding"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling embedding request for model: {request.model}")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
return await openai_service.create_embeddings(
|
||||
input_text=request.input, model=request.model, api_key=api_key
|
||||
)
|
||||
@@ -9,6 +9,7 @@ from app.domain.openai_models import (
|
||||
ImageGenerationRequest,
|
||||
)
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors # 导入共享错误处理器
|
||||
from app.log.logger import get_openai_logger
|
||||
from app.service.chat.openai_chat_service import OpenAIChatService
|
||||
from app.service.embedding.embedding_service import EmbeddingService
|
||||
@@ -47,17 +48,13 @@ async def list_models(
|
||||
_=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
logger.info("-" * 50 + "list_models" + "-" * 50)
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
try:
|
||||
return model_service.get_gemini_openai_models(api_key)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting models list: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Internal server error while fetching models list"
|
||||
) from e
|
||||
"""获取可用的 OpenAI 模型列表 (兼容 Gemini 和 OpenAI)。"""
|
||||
operation_name = "list_models"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
return await model_service.get_gemini_openai_models(api_key)
|
||||
|
||||
|
||||
@router.post("/v1/chat/completions")
|
||||
@@ -70,33 +67,38 @@ async def chat_completion(
|
||||
key_manager: KeyManager = Depends(get_key_manager), # 保留 key_manager 用于获取 paid_key
|
||||
chat_service: OpenAIChatService = Depends(get_openai_chat_service),
|
||||
):
|
||||
# 如果model是imagen3,使用paid_key
|
||||
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
||||
api_key = await key_manager.get_paid_key()
|
||||
logger.info("-" * 50 + "chat_completion" + "-" * 50)
|
||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
"""处理 OpenAI 聊天补全请求,支持流式响应和特定模型切换。"""
|
||||
operation_name = "chat_completion"
|
||||
# 检查是否为图像生成相关的聊天模型
|
||||
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
|
||||
current_api_key = api_key # 保存原始 key
|
||||
if is_image_chat:
|
||||
current_api_key = await key_manager.get_paid_key() # 获取付费密钥
|
||||
|
||||
if not model_service.check_model_support(request.model):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {request.model} is not supported"
|
||||
)
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {current_api_key}")
|
||||
|
||||
try:
|
||||
# 如果model是imagen3,使用paid_key
|
||||
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
||||
response = await chat_service.create_image_chat_completion(request=request)
|
||||
# 检查模型支持性应在错误处理块内,以便捕获并记录错误
|
||||
if not await model_service.check_model_support(request.model):
|
||||
# 使用 HTTPException,会被 handle_route_errors 捕获并记录
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {request.model} is not supported"
|
||||
)
|
||||
|
||||
if is_image_chat:
|
||||
# 图像生成聊天
|
||||
response = await chat_service.create_image_chat_completion(request, current_api_key)
|
||||
return response # 直接返回,不处理流式
|
||||
else:
|
||||
response = await chat_service.create_chat_completion(request, api_key)
|
||||
# 处理流式响应
|
||||
if request.stream:
|
||||
return StreamingResponse(response, media_type="text/event-stream")
|
||||
logger.info("Chat completion request successful")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Chat completion failed after retries: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Chat completion failed") from e
|
||||
# 普通聊天补全
|
||||
response = await chat_service.create_chat_completion(request, current_api_key)
|
||||
# 处理流式响应
|
||||
if request.stream:
|
||||
return StreamingResponse(response, media_type="text/event-stream")
|
||||
# 非流式直接返回结果
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/v1/images/generations")
|
||||
@@ -105,18 +107,14 @@ async def generate_image(
|
||||
request: ImageGenerationRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
):
|
||||
logger.info("-" * 50 + "generate_image" + "-" * 50)
|
||||
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
||||
|
||||
try:
|
||||
"""处理 OpenAI 图像生成请求。"""
|
||||
operation_name = "generate_image"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
||||
# 注意:这里假设 image_create_service.generate_images 是同步函数
|
||||
# 如果它是异步的,需要 await
|
||||
response = image_create_service.generate_images(request)
|
||||
logger.info("Image generation request successful")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Image generation request failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Image generation request failed"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/v1/embeddings")
|
||||
@@ -126,19 +124,16 @@ async def embedding(
|
||||
_=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
logger.info("-" * 50 + "embedding" + "-" * 50)
|
||||
logger.info(f"Handling embedding request for model: {request.model}")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
try:
|
||||
"""处理 OpenAI 文本嵌入请求。"""
|
||||
operation_name = "embedding"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling embedding request for model: {request.model}")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
response = await embedding_service.create_embedding(
|
||||
input_text=request.input, model=request.model, api_key=api_key
|
||||
)
|
||||
logger.info("Embedding request successful")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Embedding request failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Embedding request failed") from e
|
||||
|
||||
|
||||
@router.get("/v1/keys/list")
|
||||
@@ -147,10 +142,10 @@ async def get_keys_list(
|
||||
_=Depends(security_service.verify_auth_token),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""获取有效和无效的API key列表"""
|
||||
logger.info("-" * 50 + "get_keys_list" + "-" * 50)
|
||||
logger.info("Handling keys list request")
|
||||
try:
|
||||
"""获取有效和无效的API key列表 (需要管理 Token 认证)。"""
|
||||
operation_name = "get_keys_list"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info("Handling keys list request")
|
||||
keys_status = await key_manager.get_keys_by_status()
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -160,8 +155,3 @@ async def get_keys_list(
|
||||
},
|
||||
"total": len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting keys list: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Internal server error while fetching keys list"
|
||||
) from e
|
||||
|
||||
@@ -8,9 +8,9 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_routes_logger
|
||||
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes # 新增导入
|
||||
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.stats_service import get_api_usage_stats, get_api_call_details # <-- Import stats service and details function
|
||||
from app.service.stats.stats_service import StatsService
|
||||
|
||||
logger = get_routes_logger()
|
||||
|
||||
@@ -30,8 +30,11 @@ def setup_routers(app: FastAPI) -> None:
|
||||
app.include_router(gemini_routes.router)
|
||||
app.include_router(gemini_routes.router_v1beta)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(log_routes.router)
|
||||
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
||||
app.include_router(error_log_routes.router)
|
||||
app.include_router(scheduler_routes.router)
|
||||
app.include_router(stats_routes.router)
|
||||
app.include_router(version_routes.router)
|
||||
app.include_router(openai_compatiable_routes.router)
|
||||
|
||||
# 添加页面路由
|
||||
setup_page_routes(app)
|
||||
@@ -92,8 +95,8 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
valid_key_count = len(keys_status["valid_keys"])
|
||||
invalid_key_count = len(keys_status["invalid_keys"])
|
||||
|
||||
# Get API usage stats
|
||||
api_stats = await get_api_usage_stats()
|
||||
stats_service = StatsService()
|
||||
api_stats = await stats_service.get_api_usage_stats()
|
||||
logger.info(f"API stats retrieved: {api_stats}")
|
||||
|
||||
logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}")
|
||||
@@ -180,7 +183,9 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
logger.info(f"Fetching API call details for period: {period}")
|
||||
details = await get_api_call_details(period)
|
||||
# Use the service instance here as well
|
||||
stats_service = StatsService() # Create an instance
|
||||
details = await stats_service.get_api_call_details(period)
|
||||
return details
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
|
||||
|
||||
@@ -7,9 +7,9 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.security import verify_auth_token # 导入 verify_auth_token
|
||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
||||
from app.log.logger import get_routes_logger # 使用路由日志记录器
|
||||
from app.log.logger import get_scheduler_routes # 使用路由日志记录器
|
||||
|
||||
logger = get_routes_logger()
|
||||
logger = get_scheduler_routes()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/scheduler",
|
||||
|
||||
58
app/router/stats_routes.py
Normal file
58
app/router/stats_routes.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from starlette import status
|
||||
from app.core.security import verify_auth_token
|
||||
from app.service.stats.stats_service import StatsService
|
||||
from app.log.logger import get_stats_logger
|
||||
|
||||
logger = get_stats_logger()
|
||||
|
||||
|
||||
async def verify_token(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 scheduler API")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api",
|
||||
tags=["stats"],
|
||||
dependencies=[Depends(verify_token)]
|
||||
)
|
||||
|
||||
stats_service = StatsService()
|
||||
|
||||
@router.get("/key-usage-details/{key}",
|
||||
summary="获取指定密钥最近24小时的模型调用次数",
|
||||
description="根据提供的 API 密钥,返回过去24小时内每个模型被调用的次数统计。")
|
||||
async def get_key_usage_details(key: str):
|
||||
"""
|
||||
Retrieves the model usage count for a specific API key within the last 24 hours.
|
||||
|
||||
Args:
|
||||
key: The API key to get usage details for.
|
||||
|
||||
Returns:
|
||||
A dictionary with model names as keys and their call counts as values.
|
||||
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during data retrieval.
|
||||
"""
|
||||
try:
|
||||
usage_details = await stats_service.get_key_usage_details_last_24h(key)
|
||||
if usage_details is None:
|
||||
# Handle case where key might be valid but has no recent usage,
|
||||
# or if the service layer explicitly returns None for other reasons.
|
||||
# Returning an empty dict is usually fine for the frontend.
|
||||
return {}
|
||||
return usage_details
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching key usage details for key {key[:4]}...: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取密钥使用详情时出错: {e}"
|
||||
)
|
||||
38
app/router/version_routes.py
Normal file
38
app/router/version_routes.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
from app.service.update.update_service import check_for_updates
|
||||
from app.utils.helpers import get_current_version
|
||||
from app.log.logger import get_update_logger
|
||||
|
||||
router = APIRouter(prefix="/api/version", tags=["Version"])
|
||||
logger = get_update_logger()
|
||||
|
||||
class VersionInfo(BaseModel):
|
||||
current_version: str = Field(..., description="当前应用程序版本")
|
||||
latest_version: Optional[str] = Field(None, description="可用的最新版本")
|
||||
update_available: bool = Field(False, description="是否有可用更新")
|
||||
error_message: Optional[str] = Field(None, description="检查更新时发生的错误信息")
|
||||
|
||||
@router.get("/check", response_model=VersionInfo, summary="检查应用程序更新")
|
||||
async def get_version_info():
|
||||
"""
|
||||
检查当前应用程序版本与最新的 GitHub release 版本。
|
||||
"""
|
||||
try:
|
||||
current_version = get_current_version() # Use imported function
|
||||
update_available, latest_version, error_message = await check_for_updates()
|
||||
|
||||
# Log the result for debugging
|
||||
logger.info(f"Version check API result: current={current_version}, latest={latest_version}, available={update_available}, error='{error_message}'")
|
||||
|
||||
return VersionInfo(
|
||||
current_version=current_version,
|
||||
latest_version=latest_version,
|
||||
update_available=update_available,
|
||||
error_message=error_message
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in /api/version/check endpoint: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="检查版本信息时发生内部错误")
|
||||
@@ -90,8 +90,10 @@ scheduler_instance = None
|
||||
|
||||
def start_scheduler():
|
||||
global scheduler_instance
|
||||
if scheduler_instance is None:
|
||||
if scheduler_instance is None or not scheduler_instance.running:
|
||||
logger.info("Starting scheduler...")
|
||||
scheduler_instance = setup_scheduler()
|
||||
logger.info("Scheduler is already running.")
|
||||
|
||||
def stop_scheduler():
|
||||
global scheduler_instance
|
||||
|
||||
@@ -81,10 +81,10 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
||||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
|
||||
]
|
||||
return [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
|
||||
]
|
||||
|
||||
@@ -108,6 +108,12 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
||||
if model.endswith("-image") or model.endswith("-image-generation"):
|
||||
payload.pop("systemInstruction")
|
||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||
|
||||
if model.endswith("-non-thinking"):
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
if model in settings.THINKING_BUDGET_MAP:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@@ -156,10 +162,6 @@ class GeminiChatService:
|
||||
|
||||
try:
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
# Assuming success if no exception is raised and response is received
|
||||
# The actual status code might be within the response structure or headers,
|
||||
# but api_client doesn't seem to expose it directly here.
|
||||
# We'll assume 200 for success if no exception.
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
return self.response_handler.handle_response(response, model, stream=False)
|
||||
@@ -178,7 +180,7 @@ class GeminiChatService:
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="gemini_chat_service",
|
||||
error_type="gemini-chat-non-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
@@ -204,96 +206,90 @@ class GeminiChatService:
|
||||
retries = 0
|
||||
max_retries = settings.MAX_RETRIES
|
||||
payload = _build_payload(model, request)
|
||||
start_time = time.perf_counter() # Record start time before loop
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
final_api_key = api_key # Store the initial key
|
||||
final_api_key = api_key
|
||||
|
||||
try:
|
||||
while retries < max_retries:
|
||||
current_attempt_key = api_key # Key used for this attempt
|
||||
final_api_key = current_attempt_key # Update final key used
|
||||
try:
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, current_attempt_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
line = line[6:]
|
||||
response_data = self.response_handler.handle_response(
|
||||
json.loads(line), model, stream=True
|
||||
)
|
||||
text = self._extract_text_from_response(response_data)
|
||||
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
|
||||
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in gemini_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_response(response_data, t),
|
||||
lambda c: "data: " + json.dumps(c) + "\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
yield "data: " + json.dumps(response_data) + "\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
break # Exit loop on success
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False # Mark as failed for this attempt
|
||||
error_log_msg = str(e)
|
||||
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 # Default if parsing fails
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key, # Log key used for this failed attempt
|
||||
model_name=model,
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
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
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
while retries < max_retries:
|
||||
request_datetime = datetime.datetime.now()
|
||||
start_time = time.perf_counter()
|
||||
current_attempt_key = api_key
|
||||
final_api_key = current_attempt_key # Update final key used
|
||||
try:
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, current_attempt_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
line = line[6:]
|
||||
response_data = self.response_handler.handle_response(
|
||||
json.loads(line), model, stream=True
|
||||
)
|
||||
break # Exit loop after max retries
|
||||
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
|
||||
request_time=request_datetime
|
||||
)
|
||||
# If the loop finished due to failure, ensure an exception is raised if not already handled
|
||||
if not is_success and retries >= max_retries:
|
||||
# We need to raise an exception here if the loop exited due to max retries failure
|
||||
# However, the original code structure doesn't explicitly raise here after the loop.
|
||||
# For now, we just log. Consider raising HTTPException if needed.
|
||||
pass
|
||||
text = self._extract_text_from_response(response_data)
|
||||
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
|
||||
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in gemini_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_response(response_data, t),
|
||||
lambda c: "data: " + json.dumps(c) + "\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
yield "data: " + json.dumps(response_data) + "\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
is_success = True
|
||||
status_code = 200
|
||||
break
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
# 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
|
||||
model_name=model,
|
||||
error_type="gemini-chat-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
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
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
)
|
||||
break # Exit loop after max retries
|
||||
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
|
||||
request_time=request_datetime
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# app/services/chat_service.py
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import datetime # Add datetime import
|
||||
import time # Add time import
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
|
||||
from app.config.config import settings
|
||||
from app.database.services import (
|
||||
add_error_log,
|
||||
add_request_log,
|
||||
)
|
||||
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
|
||||
from app.handler.message_converter import OpenAIMessageConverter
|
||||
from app.handler.response_handler import OpenAIResponseHandler
|
||||
@@ -16,17 +20,16 @@ from app.log.logger import get_openai_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.image.image_create_service import ImageCreateService
|
||||
from app.service.key.key_manager import KeyManager
|
||||
from app.database.services import add_error_log, add_request_log # Import add_request_log
|
||||
|
||||
logger = get_openai_logger()
|
||||
|
||||
|
||||
def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
|
||||
"""判断消息是否包含图片部分"""
|
||||
def _has_media_parts(contents: List[Dict[str, Any]]) -> bool:
|
||||
"""判断消息是否包含图片、音频或视频部分 (inline_data)"""
|
||||
for content in contents:
|
||||
if "parts" in content:
|
||||
if content and "parts" in content and isinstance(content["parts"], list):
|
||||
for part in content["parts"]:
|
||||
if "image_url" in part or "inline_data" in part:
|
||||
if isinstance(part, dict) and "inline_data" in part:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -46,9 +49,13 @@ def _build_tools(
|
||||
or model.endswith("-image")
|
||||
or model.endswith("-image-generation")
|
||||
)
|
||||
and not _has_image_parts(messages)
|
||||
and not _has_media_parts(messages) # Use the updated check
|
||||
):
|
||||
tool["codeExecution"] = {}
|
||||
logger.debug("Code execution tool enabled.")
|
||||
elif _has_media_parts(messages):
|
||||
logger.debug("Code execution tool disabled due to media parts presence.")
|
||||
|
||||
if model.endswith("-search"):
|
||||
tool["googleSearch"] = {}
|
||||
|
||||
@@ -62,7 +69,9 @@ def _build_tools(
|
||||
if item.get("type", "") == "function" and item.get("function"):
|
||||
function = deepcopy(item.get("function"))
|
||||
parameters = function.get("parameters", {})
|
||||
if parameters.get("type") == "object" and not parameters.get("properties", {}):
|
||||
if parameters.get("type") == "object" and not parameters.get(
|
||||
"properties", {}
|
||||
):
|
||||
function.pop("parameters", None)
|
||||
|
||||
function_declarations.append(function)
|
||||
@@ -93,20 +102,8 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
||||
# and "gemini-2.0-pro-exp" not in model
|
||||
# ):
|
||||
if model == "gemini-2.0-flash-exp":
|
||||
return [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
|
||||
]
|
||||
return [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
|
||||
]
|
||||
return settings.GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
|
||||
return settings.SAFETY_SETTINGS
|
||||
|
||||
|
||||
def _build_payload(
|
||||
@@ -130,6 +127,12 @@ def _build_payload(
|
||||
payload["generationConfig"]["maxOutputTokens"] = request.max_tokens
|
||||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||
if request.model.endswith("-non-thinking"):
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
if request.model in settings.THINKING_BUDGET_MAP:
|
||||
payload["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
|
||||
}
|
||||
|
||||
if (
|
||||
instruction
|
||||
@@ -201,7 +204,7 @@ class OpenAIChatService:
|
||||
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, finish_reason="stop"
|
||||
)
|
||||
@@ -214,17 +217,17 @@ class OpenAIChatService:
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500 # Default if parsing fails
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key, # Note: Parameter name is gemini_key in add_error_log
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="openai_chat_service", # Indicate service type
|
||||
error_type="openai-chat-non-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
request_msg=payload,
|
||||
)
|
||||
raise e # Re-throw exception
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
@@ -234,7 +237,7 @@ class OpenAIChatService:
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime
|
||||
request_time=request_datetime,
|
||||
)
|
||||
|
||||
async def _handle_stream_completion(
|
||||
@@ -243,118 +246,118 @@ class OpenAIChatService:
|
||||
"""处理流式聊天完成,添加重试逻辑"""
|
||||
retries = 0
|
||||
max_retries = settings.MAX_RETRIES
|
||||
start_time = time.perf_counter() # Record start time before loop
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
final_api_key = api_key # Store the initial key
|
||||
final_api_key = api_key
|
||||
|
||||
try:
|
||||
while retries < max_retries:
|
||||
current_attempt_key = api_key # Key used for this attempt
|
||||
final_api_key = current_attempt_key # Update final key used
|
||||
try:
|
||||
tool_call_flag = False
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, current_attempt_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
chunk = json.loads(line[6:])
|
||||
openai_chunk = self.response_handler.handle_response(
|
||||
chunk, model, stream=True, finish_reason=None
|
||||
)
|
||||
if openai_chunk:
|
||||
# 提取文本内容
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in openai_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_openai_chunk(
|
||||
openai_chunk, t
|
||||
),
|
||||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
if "tool_calls" in json.dumps(openai_chunk):
|
||||
tool_call_flag = True
|
||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||
if tool_call_flag:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
break # 成功后退出循环
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False # Mark as failed for this attempt
|
||||
error_log_msg = str(e)
|
||||
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 # Default if parsing fails
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key, # Note: Parameter name is gemini_key
|
||||
model_name=model,
|
||||
error_type="openai_chat_service", # Indicate service type
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
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)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
else:
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break # Exit loop if no key available
|
||||
else:
|
||||
logger.error("KeyManager not available for retry logic.")
|
||||
break # Exit loop if key manager is missing
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
while retries < max_retries:
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
current_attempt_key = api_key
|
||||
final_api_key = current_attempt_key
|
||||
try:
|
||||
tool_call_flag = False
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, current_attempt_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
chunk = json.loads(line[6:])
|
||||
openai_chunk = self.response_handler.handle_response(
|
||||
chunk, model, stream=True, finish_reason=None
|
||||
)
|
||||
break # Exit loop after max retries
|
||||
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
|
||||
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"
|
||||
if openai_chunk:
|
||||
# 提取文本内容
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in openai_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_openai_chunk(
|
||||
openai_chunk, t
|
||||
),
|
||||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
if "tool_calls" in json.dumps(openai_chunk):
|
||||
tool_call_flag = True
|
||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||
if tool_call_flag:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
is_success = True
|
||||
status_code = 200
|
||||
break # 成功后退出循环
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
# 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,
|
||||
error_type="openai-chat-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
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
|
||||
)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
else:
|
||||
logger.error(
|
||||
f"No valid API key available after {retries} retries."
|
||||
)
|
||||
break
|
||||
else:
|
||||
logger.error("KeyManager not available for retry logic.")
|
||||
break
|
||||
|
||||
if retries >= max_retries:
|
||||
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(
|
||||
model_name=model,
|
||||
api_key=final_api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
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"
|
||||
|
||||
async def create_image_chat_completion(
|
||||
self,
|
||||
request: ChatRequest,
|
||||
self, request: ChatRequest, api_key: str
|
||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||
|
||||
image_generate_request = ImageGenerationRequest()
|
||||
@@ -364,41 +367,131 @@ class OpenAIChatService:
|
||||
)
|
||||
|
||||
if request.stream:
|
||||
return self._handle_stream_image_completion(request.model, image_res)
|
||||
return self._handle_stream_image_completion(
|
||||
request.model, image_res, api_key
|
||||
)
|
||||
else:
|
||||
return self._handle_normal_image_completion(request.model, image_res)
|
||||
return await self._handle_normal_image_completion(
|
||||
request.model, image_res, api_key
|
||||
)
|
||||
|
||||
async def _handle_stream_image_completion(
|
||||
self, model: str, image_data: str
|
||||
self, model: str, image_data: str, api_key: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
if image_data:
|
||||
openai_chunk = self.response_handler.handle_image_chat_response(
|
||||
image_data, model, stream=True, finish_reason=None
|
||||
logger.info(f"Starting stream image completion for model: {model}")
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
|
||||
try:
|
||||
if image_data:
|
||||
openai_chunk = self.response_handler.handle_image_chat_response(
|
||||
image_data, model, stream=True, finish_reason=None
|
||||
)
|
||||
if openai_chunk:
|
||||
# 提取文本内容
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
if text:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in openai_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_openai_chunk(openai_chunk, t),
|
||||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如图片URL等),整块输出
|
||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||
logger.info(
|
||||
f"Stream image completion finished successfully for model: {model}"
|
||||
)
|
||||
is_success = True
|
||||
status_code = 200
|
||||
yield "data: [DONE]\n\n"
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = f"Stream image completion failed for model {model}: {e}"
|
||||
logger.error(error_log_msg)
|
||||
status_code = 500
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="openai-image-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg={
|
||||
"image_data_truncated": image_data[:1000]
|
||||
},
|
||||
)
|
||||
yield f"data: {json.dumps({'error': error_log_msg})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
logger.info(
|
||||
f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}"
|
||||
)
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime,
|
||||
)
|
||||
if openai_chunk:
|
||||
# 提取文本内容
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
if text:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in openai_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_openai_chunk(openai_chunk, t),
|
||||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如图片URL等),整块输出
|
||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
logger.info("Image chat streaming completed successfully")
|
||||
|
||||
def _handle_normal_image_completion(
|
||||
self, model: str, image_data: str
|
||||
async def _handle_normal_image_completion(
|
||||
self, model: str, image_data: str, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
logger.info(f"Starting normal image completion for model: {model}")
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
result = None
|
||||
|
||||
return self.response_handler.handle_image_chat_response(
|
||||
image_data, model, stream=False, finish_reason="stop"
|
||||
)
|
||||
try:
|
||||
result = self.response_handler.handle_image_chat_response(
|
||||
image_data, model, stream=False, finish_reason="stop"
|
||||
)
|
||||
logger.info(
|
||||
f"Normal image completion finished successfully for model: {model}"
|
||||
)
|
||||
is_success = True
|
||||
status_code = 200
|
||||
return result
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = f"Normal image completion failed for model {model}: {e}"
|
||||
logger.error(error_log_msg)
|
||||
status_code = 500
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="openai-image-non-stream",
|
||||
error_log=error_log_msg,
|
||||
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()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
logger.info(
|
||||
f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}"
|
||||
)
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# app/services/chat/api_client.py
|
||||
|
||||
from typing import Dict, Any, AsyncGenerator
|
||||
from typing import Dict, Any, AsyncGenerator, Optional
|
||||
import httpx
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_api_client_logger
|
||||
from app.core.constants import DEFAULT_TIMEOUT
|
||||
|
||||
logger = get_api_client_logger()
|
||||
|
||||
class ApiClient(ABC):
|
||||
"""API客户端基类"""
|
||||
@@ -31,14 +34,47 @@ class GeminiApiClient(ApiClient):
|
||||
model = model[:-7]
|
||||
if model.endswith("-image"):
|
||||
model = model[:-6]
|
||||
|
||||
if model.endswith("-non-thinking"):
|
||||
model = model[:-13]
|
||||
if "-search" in model and "-non-thinking" in model:
|
||||
model = model[:-20]
|
||||
return model
|
||||
|
||||
async def get_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取可用的 Gemini 模型列表"""
|
||||
timeout = httpx.Timeout(timeout=5)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy for getting models: {proxy_to_use}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models?key={api_key}"
|
||||
try:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status() # 如果状态码不是 2xx,则引发 HTTPStatusError
|
||||
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]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy: {proxy_to_use}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
|
||||
response = await client.post(url, json=payload)
|
||||
if response.status_code != 200:
|
||||
@@ -50,7 +86,12 @@ class GeminiApiClient(ApiClient):
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy: {proxy_to_use}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
|
||||
async with client.stream(method="POST", url=url, json=payload) as response:
|
||||
if response.status_code != 200:
|
||||
@@ -59,3 +100,96 @@ class GeminiApiClient(ApiClient):
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
|
||||
|
||||
class OpenaiApiClient(ApiClient):
|
||||
"""OpenAI API客户端"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: int = DEFAULT_TIMEOUT):
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
|
||||
async def get_models(self, api_key: str) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
url = f"{self.base_url}/openai/models"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
return response.json()
|
||||
|
||||
async def generate_content(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy: {proxy_to_use}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/openai/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
return response.json()
|
||||
|
||||
async def stream_generate_content(self, payload: Dict[str, Any], api_key: str) -> AsyncGenerator[str, None]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy: {proxy_to_use}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/openai/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
async with client.stream(method="POST", url=url, json=payload, headers=headers) as response:
|
||||
if response.status_code != 200:
|
||||
error_content = await response.aread()
|
||||
error_msg = error_content.decode("utf-8")
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
|
||||
async def create_embeddings(self, input: str, model: str, api_key: str) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy: {proxy_to_use}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/openai/embeddings"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
payload = {
|
||||
"input": input,
|
||||
"model": model,
|
||||
}
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
return response.json()
|
||||
|
||||
async def generate_images(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy: {proxy_to_use}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/openai/images/generations"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
return response.json()
|
||||
@@ -31,7 +31,7 @@ class ConfigService:
|
||||
for key, value in config_data.items():
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
logger.info(f"Updated setting in memory: {key}")
|
||||
logger.debug(f"Updated setting in memory: {key}")
|
||||
|
||||
# 获取现有设置
|
||||
existing_settings_raw: List[Dict[str, Any]] = await get_all_settings()
|
||||
@@ -47,6 +47,8 @@ class ConfigService:
|
||||
# 处理不同类型的值
|
||||
if isinstance(value, list):
|
||||
db_value = json.dumps(value)
|
||||
elif isinstance(value, dict): # 新增对 dict 类型的处理
|
||||
db_value = json.dumps(value)
|
||||
elif isinstance(value, bool):
|
||||
db_value = str(value).lower()
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import datetime
|
||||
import time
|
||||
import re # For potential status code parsing from generic errors
|
||||
from typing import List, Union
|
||||
|
||||
import openai
|
||||
from openai import APIStatusError # Import specific error type
|
||||
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
|
||||
|
||||
logger = get_embeddings_logger()
|
||||
|
||||
@@ -13,11 +19,64 @@ class EmbeddingService:
|
||||
async def create_embedding(
|
||||
self, input_text: Union[str, List[str]], model: str, api_key: str
|
||||
) -> CreateEmbeddingResponse:
|
||||
"""Create embeddings using OpenAI API"""
|
||||
"""Create embeddings using OpenAI API with database logging"""
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
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:
|
||||
request_msg_log["input_truncated"].append("...")
|
||||
else:
|
||||
request_msg_log = {"input_truncated": input_text[:1000] + "..." if len(input_text) > 1000 else input_text}
|
||||
|
||||
|
||||
try:
|
||||
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
|
||||
response = client.embeddings.create(input=input_text, model=model)
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 OK on success
|
||||
return response
|
||||
except APIStatusError as e:
|
||||
is_success = False
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating embedding: {str(e)}")
|
||||
raise
|
||||
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
|
||||
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
|
||||
)
|
||||
# Log request outcome to database regardless of success/failure
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ logger = get_image_create_logger()
|
||||
class ImageCreateService:
|
||||
def __init__(self, aspect_ratio="1:1"):
|
||||
self.image_model = settings.CREATE_IMAGE_MODEL
|
||||
self.paid_key = settings.PAID_KEY
|
||||
self.aspect_ratio = aspect_ratio
|
||||
|
||||
def parse_prompt_parameters(self, prompt: str) -> tuple:
|
||||
@@ -53,7 +52,7 @@ class ImageCreateService:
|
||||
return prompt, n, aspect_ratio
|
||||
|
||||
def generate_images(self, request: ImageGenerationRequest):
|
||||
client = genai.Client(api_key=self.paid_key)
|
||||
client = genai.Client(api_key=settings.PAID_KEY)
|
||||
|
||||
if request.size == "1024x1024":
|
||||
self.aspect_ratio = "1:1"
|
||||
@@ -89,7 +88,6 @@ class ImageCreateService:
|
||||
aspect_ratio=self.aspect_ratio,
|
||||
safety_filter_level="BLOCK_LOW_AND_ABOVE",
|
||||
person_generation="ALLOW_ADULT",
|
||||
# language="auto"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_model_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
|
||||
logger = get_model_logger()
|
||||
|
||||
|
||||
class ModelService:
|
||||
def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
url = f"{settings.BASE_URL}/models?key={api_key}"
|
||||
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""使用 GeminiApiClient 获取并过滤模型列表"""
|
||||
api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端
|
||||
gemini_models = await api_client.get_models(api_key)
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
gemini_models = response.json()
|
||||
|
||||
filtered_models_list = []
|
||||
for model in gemini_models.get("models", []):
|
||||
model_id = model["name"].split("/")[-1]
|
||||
if model_id not in settings.FILTERED_MODELS:
|
||||
filtered_models_list.append(model)
|
||||
else:
|
||||
logger.info(f"Filtered out model: {model_id}")
|
||||
|
||||
gemini_models["models"] = filtered_models_list
|
||||
return gemini_models
|
||||
else:
|
||||
logger.error(f"Error: {response.status_code}")
|
||||
logger.error(response.text)
|
||||
return None
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
if gemini_models is None:
|
||||
logger.error("从 API 客户端获取模型列表失败。")
|
||||
return None
|
||||
|
||||
def get_gemini_openai_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
gemini_models = self.get_gemini_models(api_key)
|
||||
return self.convert_to_openai_models_format(gemini_models)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
filtered_models_list = []
|
||||
for model in gemini_models.get("models", []):
|
||||
model_id = model["name"].split("/")[-1]
|
||||
if model_id not in settings.FILTERED_MODELS:
|
||||
filtered_models_list.append(model)
|
||||
else:
|
||||
logger.debug(f"Filtered out model: {model_id}")
|
||||
|
||||
gemini_models["models"] = filtered_models_list
|
||||
return gemini_models
|
||||
except Exception as e:
|
||||
logger.error(f"处理模型列表时出错: {e}")
|
||||
return None
|
||||
|
||||
def convert_to_openai_models_format(
|
||||
async def get_gemini_openai_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取 Gemini 模型并转换为 OpenAI 格式"""
|
||||
gemini_models = await self.get_gemini_models(api_key)
|
||||
if gemini_models is None:
|
||||
return None
|
||||
|
||||
return await self.convert_to_openai_models_format(gemini_models)
|
||||
|
||||
async def convert_to_openai_models_format(
|
||||
self, gemini_models: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
openai_format = {"object": "list", "data": [], "success": True}
|
||||
@@ -70,6 +67,10 @@ class ModelService:
|
||||
image_model = openai_model.copy()
|
||||
image_model["id"] = f"{model_id}-image"
|
||||
openai_format["data"].append(image_model)
|
||||
if model_id in settings.THINKING_MODELS:
|
||||
non_thinking_model = openai_model.copy()
|
||||
non_thinking_model["id"] = f"{model_id}-non-thinking"
|
||||
openai_format["data"].append(non_thinking_model)
|
||||
|
||||
if settings.CREATE_IMAGE_MODEL:
|
||||
image_model = openai_model.copy()
|
||||
@@ -77,7 +78,7 @@ class ModelService:
|
||||
openai_format["data"].append(image_model)
|
||||
return openai_format
|
||||
|
||||
def check_model_support(self, model: str) -> bool:
|
||||
async def check_model_support(self, model: str) -> bool:
|
||||
if not model or not isinstance(model, str):
|
||||
return False
|
||||
|
||||
|
||||
197
app/service/openai_compatiable/openai_compatiable_service.py
Normal file
197
app/service/openai_compatiable/openai_compatiable_service.py
Normal file
@@ -0,0 +1,197 @@
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Dict, Union
|
||||
|
||||
from app.config.config import settings
|
||||
from app.database.services import (
|
||||
add_error_log,
|
||||
add_request_log,
|
||||
)
|
||||
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
|
||||
from app.service.client.api_client import OpenaiApiClient
|
||||
from app.service.key.key_manager import KeyManager
|
||||
from app.log.logger import get_openai_compatible_logger
|
||||
|
||||
logger = get_openai_compatible_logger()
|
||||
|
||||
class OpenAICompatiableService:
|
||||
|
||||
def __init__(self, base_url: str, key_manager: KeyManager = None):
|
||||
self.key_manager = key_manager
|
||||
self.base_url = base_url
|
||||
self.api_client = OpenaiApiClient(base_url, settings.TIME_OUT)
|
||||
|
||||
async def get_models(self, api_key: str) -> Dict[str, Any]:
|
||||
return await self.api_client.get_models(api_key)
|
||||
|
||||
async def create_chat_completion(
|
||||
self,
|
||||
request: ChatRequest,
|
||||
api_key: str,
|
||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||
"""创建聊天完成"""
|
||||
request_dict = request.model_dump()
|
||||
# 移除值为null的
|
||||
request_dict = {k: v for k, v in request_dict.items() if v is not None}
|
||||
del request_dict["top_k"] # 删除top_k参数,目前不支持该参数
|
||||
if request.stream:
|
||||
return self._handle_stream_completion(request.model, request_dict, api_key)
|
||||
return await self._handle_normal_completion(request.model, request_dict, api_key)
|
||||
|
||||
async def generate_images(
|
||||
self,
|
||||
request: ImageGenerationRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""生成图片"""
|
||||
request_dict = request.model_dump()
|
||||
# 移除值为null的
|
||||
request_dict = {k: v for k, v in request_dict.items() if v is not None}
|
||||
api_key = settings.PAID_KEY
|
||||
return await self.api_client.generate_images(request_dict, api_key)
|
||||
|
||||
async def create_embeddings(
|
||||
self,
|
||||
input_text: str,
|
||||
model: str,
|
||||
api_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""创建嵌入"""
|
||||
return await self.api_client.create_embeddings(input_text, model, api_key)
|
||||
|
||||
async def _handle_normal_completion(
|
||||
self, model: str, request: dict, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""处理普通聊天完成"""
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
try:
|
||||
response = await self.api_client.generate_content(request, api_key)
|
||||
is_success = True
|
||||
status_code = 200
|
||||
return response
|
||||
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
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="openai-compatiable-non-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=request,
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime,
|
||||
)
|
||||
|
||||
async def _handle_stream_completion(
|
||||
self, model: str, payload: dict, api_key: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""处理流式聊天完成,添加重试逻辑"""
|
||||
retries = 0
|
||||
max_retries = settings.MAX_RETRIES
|
||||
is_success = False
|
||||
status_code = None
|
||||
final_api_key = api_key
|
||||
|
||||
while retries < max_retries:
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
current_attempt_key = api_key
|
||||
final_api_key = current_attempt_key
|
||||
try:
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, current_attempt_key
|
||||
):
|
||||
if line.startswith("data:"):
|
||||
# print(line)
|
||||
yield line + "\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
is_success = True
|
||||
status_code = 200
|
||||
break # 成功后退出循环
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
# 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,
|
||||
error_type="openai-compatiable-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
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
|
||||
)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
else:
|
||||
logger.error(
|
||||
f"No valid API key available after {retries} retries."
|
||||
)
|
||||
break
|
||||
else:
|
||||
logger.error("KeyManager not available for retry logic.")
|
||||
break
|
||||
|
||||
if retries >= max_retries:
|
||||
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(
|
||||
model_name=model,
|
||||
api_key=final_api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
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"
|
||||
|
||||
|
||||
174
app/service/stats/stats_service.py
Normal file
174
app/service/stats/stats_service.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# app/service/stats_service.py
|
||||
|
||||
import datetime
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import RequestLog
|
||||
from app.log.logger import get_stats_logger
|
||||
|
||||
logger = get_stats_logger()
|
||||
|
||||
|
||||
class StatsService:
|
||||
"""Service class for handling statistics related operations."""
|
||||
|
||||
async def get_calls_in_last_seconds(self, seconds: int) -> int:
|
||||
"""获取过去 N 秒内的调用次数 (包括成功和失败)"""
|
||||
try:
|
||||
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
|
||||
query = select(func.count(RequestLog.id)).where(
|
||||
RequestLog.request_time >= cutoff_time
|
||||
)
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
|
||||
return 0 # Return 0 on error
|
||||
|
||||
async def get_calls_in_last_minutes(self, minutes: int) -> int:
|
||||
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
|
||||
return await self.get_calls_in_last_seconds(minutes * 60)
|
||||
|
||||
async def get_calls_in_last_hours(self, hours: int) -> int:
|
||||
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
|
||||
return await self.get_calls_in_last_seconds(hours * 3600)
|
||||
|
||||
async def get_calls_in_current_month(self) -> int:
|
||||
"""获取当前自然月内的调用次数 (包括成功和失败)"""
|
||||
try:
|
||||
now = datetime.datetime.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
query = select(func.count(RequestLog.id)).where(
|
||||
RequestLog.request_time >= start_of_month
|
||||
)
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get calls in current month: {e}")
|
||||
return 0 # Return 0 on error
|
||||
|
||||
async def get_api_usage_stats(self) -> dict:
|
||||
"""获取所有需要的 API 使用统计数据"""
|
||||
try:
|
||||
calls_1m = await self.get_calls_in_last_minutes(1)
|
||||
calls_1h = await self.get_calls_in_last_hours(1)
|
||||
calls_24h = await self.get_calls_in_last_hours(24)
|
||||
calls_month = await self.get_calls_in_current_month()
|
||||
|
||||
return {
|
||||
"calls_1m": calls_1m,
|
||||
"calls_1h": calls_1h,
|
||||
"calls_24h": calls_24h,
|
||||
"calls_month": calls_month,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get API usage stats: {e}")
|
||||
# Return default values on error
|
||||
return {
|
||||
"calls_1m": 0,
|
||||
"calls_1h": 0,
|
||||
"calls_24h": 0,
|
||||
"calls_month": 0,
|
||||
}
|
||||
|
||||
|
||||
async def get_api_call_details(self, period: str) -> list[dict]:
|
||||
"""
|
||||
获取指定时间段内的 API 调用详情
|
||||
|
||||
Args:
|
||||
period: 时间段标识 ('1m', '1h', '24h')
|
||||
|
||||
Returns:
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
||||
|
||||
Raises:
|
||||
ValueError: 如果 period 无效
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
if period == '1m':
|
||||
start_time = now - datetime.timedelta(minutes=1)
|
||||
elif period == '1h':
|
||||
start_time = now - datetime.timedelta(hours=1)
|
||||
elif period == '24h':
|
||||
start_time = now - datetime.timedelta(hours=24)
|
||||
else:
|
||||
raise ValueError(f"无效的时间段标识: {period}")
|
||||
|
||||
try:
|
||||
query = select(
|
||||
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
|
||||
).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 = 'success' if 200 <= row['status_code'] < 300 else 'failure'
|
||||
details.append({
|
||||
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
|
||||
"key": row['key'],
|
||||
"model": row['model'],
|
||||
"status": status
|
||||
})
|
||||
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
|
||||
return details
|
||||
|
||||
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:
|
||||
"""
|
||||
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
|
||||
|
||||
Args:
|
||||
key: 要查询的 API 密钥。
|
||||
|
||||
Returns:
|
||||
一个字典,其中键是模型名称,值是调用次数。
|
||||
如果查询出错或没有找到记录,可能返回 None 或空字典。
|
||||
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
|
||||
"""
|
||||
logger.info(f"Fetching usage details for key ending in ...{key[-4:]} for the last 24h.")
|
||||
cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=24)
|
||||
|
||||
try:
|
||||
query = select(
|
||||
RequestLog.model_name,
|
||||
func.count(RequestLog.id).label("call_count")
|
||||
).where(
|
||||
RequestLog.api_key == key,
|
||||
RequestLog.request_time >= cutoff_time,
|
||||
RequestLog.model_name.isnot(None) # Ensure model_name is not null
|
||||
).group_by(
|
||||
RequestLog.model_name
|
||||
).order_by(
|
||||
func.count(RequestLog.id).desc() # Order by count descending
|
||||
)
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
if not results:
|
||||
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
|
||||
|
||||
usage_details = {row['model_name']: row['call_count'] for row in results}
|
||||
logger.info(f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}")
|
||||
return usage_details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(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
|
||||
@@ -1,123 +0,0 @@
|
||||
# app/service/stats_service.py
|
||||
|
||||
import datetime
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import RequestLog
|
||||
from app.log.logger import get_stats_logger
|
||||
|
||||
logger = get_stats_logger()
|
||||
|
||||
async def get_calls_in_last_seconds(seconds: int) -> int:
|
||||
"""获取过去 N 秒内的调用次数 (包括成功和失败)"""
|
||||
try:
|
||||
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
|
||||
query = select(func.count(RequestLog.id)).where(
|
||||
RequestLog.request_time >= cutoff_time
|
||||
)
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
|
||||
return 0 # Return 0 on error
|
||||
|
||||
async def get_calls_in_last_minutes(minutes: int) -> int:
|
||||
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
|
||||
return await get_calls_in_last_seconds(minutes * 60)
|
||||
|
||||
async def get_calls_in_last_hours(hours: int) -> int:
|
||||
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
|
||||
return await get_calls_in_last_seconds(hours * 3600)
|
||||
|
||||
async def get_calls_in_current_month() -> int:
|
||||
"""获取当前自然月内的调用次数 (包括成功和失败)"""
|
||||
try:
|
||||
now = datetime.datetime.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
query = select(func.count(RequestLog.id)).where(
|
||||
RequestLog.request_time >= start_of_month
|
||||
)
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get calls in current month: {e}")
|
||||
return 0 # Return 0 on error
|
||||
|
||||
async def get_api_usage_stats() -> dict:
|
||||
"""获取所有需要的 API 使用统计数据"""
|
||||
try:
|
||||
calls_1m = await get_calls_in_last_minutes(1)
|
||||
calls_1h = await get_calls_in_last_hours(1)
|
||||
calls_24h = await get_calls_in_last_hours(24)
|
||||
calls_month = await get_calls_in_current_month()
|
||||
|
||||
return {
|
||||
"calls_1m": calls_1m,
|
||||
"calls_1h": calls_1h,
|
||||
"calls_24h": calls_24h,
|
||||
"calls_month": calls_month,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get API usage stats: {e}")
|
||||
# Return default values on error
|
||||
return {
|
||||
"calls_1m": 0,
|
||||
"calls_1h": 0,
|
||||
"calls_24h": 0,
|
||||
"calls_month": 0,
|
||||
}
|
||||
|
||||
|
||||
async def get_api_call_details(period: str) -> list[dict]:
|
||||
"""
|
||||
获取指定时间段内的 API 调用详情
|
||||
|
||||
Args:
|
||||
period: 时间段标识 ('1m', '1h', '24h')
|
||||
|
||||
Returns:
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
||||
|
||||
Raises:
|
||||
ValueError: 如果 period 无效
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
if period == '1m':
|
||||
start_time = now - datetime.timedelta(minutes=1)
|
||||
elif period == '1h':
|
||||
start_time = now - datetime.timedelta(hours=1)
|
||||
elif period == '24h':
|
||||
start_time = now - datetime.timedelta(hours=24)
|
||||
else:
|
||||
raise ValueError(f"无效的时间段标识: {period}")
|
||||
|
||||
try:
|
||||
query = select(
|
||||
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
|
||||
).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 = 'success' if 200 <= row['status_code'] < 300 else 'failure'
|
||||
details.append({
|
||||
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
|
||||
"key": row['key'],
|
||||
"model": row['model'],
|
||||
"status": status
|
||||
})
|
||||
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
|
||||
return details
|
||||
|
||||
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
|
||||
108
app/service/update/update_service.py
Normal file
108
app/service/update/update_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import httpx
|
||||
from packaging import version
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from app.config.config import settings
|
||||
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
|
||||
|
||||
async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
通过比较当前版本与最新的 GitHub release 来检查应用程序更新。
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str], Optional[str]]: 一个元组,包含:
|
||||
- bool: 如果有可用更新则为 True,否则为 False。
|
||||
- Optional[str]: 如果有可用更新,则为最新的版本字符串,否则为 None。
|
||||
- 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:
|
||||
logger.error(f"VERSION file ('{VERSION_FILE_PATH}') is empty.")
|
||||
return False, None, f"VERSION file ('{VERSION_FILE_PATH}') is empty."
|
||||
except FileNotFoundError:
|
||||
logger.error(f"VERSION file not found at '{VERSION_FILE_PATH}'. Make sure it exists in the project root.")
|
||||
return False, None, f"VERSION file not found at '{VERSION_FILE_PATH}'."
|
||||
except IOError as e:
|
||||
logger.error(f"Error reading VERSION file ('{VERSION_FILE_PATH}'): {e}")
|
||||
return False, None, f"Error reading VERSION file ('{VERSION_FILE_PATH}')."
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL
|
||||
response.raise_for_status() # 对错误的 HTTP 状态码(4xx 或 5xx)抛出异常
|
||||
|
||||
latest_release = response.json()
|
||||
latest_v_str = latest_release.get("tag_name")
|
||||
|
||||
if not latest_v_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:]
|
||||
|
||||
logger.info(f"在 GitHub 上找到的最新版本: {latest_v_str}")
|
||||
|
||||
# 比较版本
|
||||
current_version = version.parse(current_v)
|
||||
latest_version = version.parse(latest_v_str)
|
||||
|
||||
if latest_version > current_version:
|
||||
logger.info(f"有可用更新: {current_v} -> {latest_v_str}")
|
||||
return True, latest_v_str, None
|
||||
else:
|
||||
logger.info("应用程序已是最新版本。")
|
||||
return False, None, None
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"检查更新时发生 HTTP 错误: {e.response.status_code} - {e.response.text}")
|
||||
# 避免向用户显示详细的错误文本
|
||||
error_msg = f"获取更新信息失败 (HTTP {e.response.status_code})。"
|
||||
if e.response.status_code == 404:
|
||||
error_msg += " 请检查仓库名称是否正确或仓库是否有发布版本。"
|
||||
elif e.response.status_code == 403:
|
||||
error_msg += " API 速率限制或权限问题。"
|
||||
return False, None, error_msg
|
||||
except httpx.RequestError as e:
|
||||
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, "遇到无效的版本格式。"
|
||||
except Exception as e:
|
||||
logger.error(f"更新检查期间发生意外错误: {e}", exc_info=True)
|
||||
return False, None, "发生意外错误。"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,14 @@ let currentPage = 1;
|
||||
let pageSize = 10;
|
||||
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
|
||||
let errorLogs = []; // Store fetched logs for details view
|
||||
let currentSort = { // 新增:存储当前排序状态
|
||||
field: 'id', // 默认按 ID 排序
|
||||
order: 'desc' // 默认降序
|
||||
};
|
||||
let currentSearch = { // Store current search parameters
|
||||
key: '',
|
||||
error: '',
|
||||
errorCode: '', // Added error code search
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
};
|
||||
@@ -36,11 +41,24 @@ let logDetailModal;
|
||||
let modalCloseBtns; // Collection of close buttons for the modal
|
||||
let keySearchInput;
|
||||
let errorSearchInput;
|
||||
let errorCodeSearchInput; // Added error code input
|
||||
let startDateInput;
|
||||
let endDateInput;
|
||||
let searchBtn;
|
||||
let pageInput; // 新增:页码输入框
|
||||
let goToPageBtn; // 新增:跳转按钮
|
||||
let pageInput;
|
||||
let goToPageBtn;
|
||||
let selectAllCheckbox; // 新增:全选复选框
|
||||
let copySelectedKeysBtn; // 新增:复制选中按钮
|
||||
let deleteSelectedBtn; // 新增:批量删除按钮
|
||||
let sortByIdHeader; // 新增:ID 排序表头
|
||||
let sortIcon; // 新增:排序图标
|
||||
let selectedCountSpan; // 新增:选中计数显示
|
||||
let deleteConfirmModal; // 新增:删除确认模态框
|
||||
let closeDeleteConfirmModalBtn; // 新增:关闭删除模态框按钮
|
||||
let cancelDeleteBtn; // 新增:取消删除按钮
|
||||
let confirmDeleteBtn; // 新增:确认删除按钮
|
||||
let deleteConfirmMessage; // 新增:删除确认消息元素
|
||||
let idsToDeleteGlobally = []; // 新增:存储待删除的ID
|
||||
|
||||
// 页面加载完成后执行
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -57,11 +75,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
|
||||
keySearchInput = document.getElementById('keySearch');
|
||||
errorSearchInput = document.getElementById('errorSearch');
|
||||
errorCodeSearchInput = document.getElementById('errorCodeSearch'); // Get error code input
|
||||
startDateInput = document.getElementById('startDate');
|
||||
endDateInput = document.getElementById('endDate');
|
||||
searchBtn = document.getElementById('searchBtn');
|
||||
pageInput = document.getElementById('pageInput'); // 新增
|
||||
goToPageBtn = document.getElementById('goToPageBtn'); // 新增
|
||||
pageInput = document.getElementById('pageInput');
|
||||
goToPageBtn = document.getElementById('goToPageBtn');
|
||||
selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增
|
||||
copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增
|
||||
deleteSelectedBtn = document.getElementById('deleteSelectedBtn'); // 新增
|
||||
sortByIdHeader = document.getElementById('sortById'); // 新增
|
||||
if (sortByIdHeader) {
|
||||
sortIcon = sortByIdHeader.querySelector('i'); // 新增
|
||||
}
|
||||
selectedCountSpan = document.getElementById('selectedCount'); // 新增
|
||||
deleteConfirmModal = document.getElementById('deleteConfirmModal'); // 新增
|
||||
closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn'); // 新增
|
||||
cancelDeleteBtn = document.getElementById('cancelDeleteBtn'); // 新增
|
||||
confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); // 新增
|
||||
deleteConfirmMessage = document.getElementById('deleteConfirmMessage'); // 新增
|
||||
|
||||
// Initialize page size selector
|
||||
if (pageSizeSelector) {
|
||||
@@ -81,6 +113,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Update search parameters from input fields
|
||||
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
|
||||
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
|
||||
currentSearch.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; // Get error code value
|
||||
currentSearch.startDate = startDateInput ? startDateInput.value : '';
|
||||
currentSearch.endDate = endDateInput ? endDateInput.value : '';
|
||||
currentPage = 1; // Reset to first page on new search
|
||||
@@ -104,8 +137,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initial load of error logs
|
||||
loadErrorLogs();
|
||||
|
||||
// Add event listeners for copy buttons inside the modal
|
||||
setupCopyButtons();
|
||||
// Add event listeners for copy buttons inside the modal and table
|
||||
setupCopyButtons(); // This will now also handle table copy buttons if called after render
|
||||
|
||||
// Add event listeners for bulk selection
|
||||
setupBulkSelectionListeners(); // 新增:设置批量选择监听器
|
||||
|
||||
// 新增:为页码跳转按钮添加事件监听器
|
||||
if (goToPageBtn && pageInput) {
|
||||
@@ -132,8 +168,63 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 新增:为批量删除按钮添加事件监听器
|
||||
if (deleteSelectedBtn) {
|
||||
deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
|
||||
}
|
||||
|
||||
// 新增:为 ID 排序表头添加事件监听器
|
||||
if (sortByIdHeader) {
|
||||
sortByIdHeader.addEventListener('click', handleSortById);
|
||||
}
|
||||
|
||||
// 新增:为删除确认模态框按钮添加事件监听器
|
||||
if (closeDeleteConfirmModalBtn) {
|
||||
closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal);
|
||||
}
|
||||
if (cancelDeleteBtn) {
|
||||
cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal);
|
||||
}
|
||||
if (confirmDeleteBtn) {
|
||||
confirmDeleteBtn.addEventListener('click', handleConfirmDelete);
|
||||
}
|
||||
// Optional: Close modal if clicking outside the content
|
||||
if (deleteConfirmModal) {
|
||||
deleteConfirmModal.addEventListener('click', function(event) {
|
||||
if (event.target === deleteConfirmModal) {
|
||||
hideDeleteConfirmModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 新增:显示删除确认模态框
|
||||
function showDeleteConfirmModal(message) {
|
||||
if (deleteConfirmModal && deleteConfirmMessage) {
|
||||
deleteConfirmMessage.textContent = message;
|
||||
deleteConfirmModal.classList.add('show');
|
||||
document.body.style.overflow = 'hidden'; // Prevent body scrolling
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:隐藏删除确认模态框
|
||||
function hideDeleteConfirmModal() {
|
||||
if (deleteConfirmModal) {
|
||||
deleteConfirmModal.classList.remove('show');
|
||||
document.body.style.overflow = ''; // Restore body scrolling
|
||||
idsToDeleteGlobally = []; // 清空待删除ID
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:处理确认删除按钮点击
|
||||
function handleConfirmDelete() {
|
||||
if (idsToDeleteGlobally.length > 0) {
|
||||
performActualDelete(idsToDeleteGlobally);
|
||||
}
|
||||
hideDeleteConfirmModal(); // 关闭模态框
|
||||
}
|
||||
|
||||
// Fallback copy function using document.execCommand
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement("textarea");
|
||||
@@ -174,44 +265,315 @@ function handleCopyResult(buttonElement, success) {
|
||||
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
|
||||
}
|
||||
|
||||
// Function to set up copy button listeners (using modern API with fallback)
|
||||
function setupCopyButtons() {
|
||||
const copyButtons = document.querySelectorAll('.copy-btn');
|
||||
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
|
||||
function setupCopyButtons(containerSelector = 'body') {
|
||||
// Find buttons within the specified container (defaults to body)
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
const copyButtons = container.querySelectorAll('.copy-btn');
|
||||
copyButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
const textToCopy = targetElement.textContent;
|
||||
let copySuccess = false;
|
||||
|
||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
handleCopyResult(this, true); // Use helper for feedback
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed, attempting fallback:', err);
|
||||
// Attempt fallback if modern API fails
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
||||
});
|
||||
} else {
|
||||
// Use fallback if modern API is not available or context is insecure
|
||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
||||
}
|
||||
} else {
|
||||
console.error('Target element not found:', targetId);
|
||||
showNotification('复制出错:找不到目标元素', 'error');
|
||||
}
|
||||
});
|
||||
// Remove existing listener to prevent duplicates if called multiple times
|
||||
button.removeEventListener('click', handleCopyButtonClick);
|
||||
// Add the listener
|
||||
button.addEventListener('click', handleCopyButtonClick);
|
||||
});
|
||||
}
|
||||
|
||||
// Extracted click handler logic for reusability and removing listeners
|
||||
function handleCopyButtonClick() {
|
||||
const button = this; // 'this' refers to the button clicked
|
||||
const targetId = button.getAttribute('data-target');
|
||||
const textToCopyDirect = button.getAttribute('data-copy-text'); // For direct text copy (e.g., table key)
|
||||
let textToCopy = '';
|
||||
|
||||
if (textToCopyDirect) {
|
||||
textToCopy = textToCopyDirect;
|
||||
} else if (targetId) {
|
||||
const targetElement = document.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
textToCopy = targetElement.textContent;
|
||||
} else {
|
||||
console.error('Target element not found:', targetId);
|
||||
showNotification('复制出错:找不到目标元素', 'error');
|
||||
return; // Exit if target element not found
|
||||
}
|
||||
} else {
|
||||
console.error('No data-target or data-copy-text attribute found on button:', button);
|
||||
showNotification('复制出错:未指定复制内容', 'error');
|
||||
return; // Exit if no source specified
|
||||
}
|
||||
|
||||
|
||||
if (textToCopy) {
|
||||
let copySuccess = false;
|
||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
handleCopyResult(button, true); // Use helper for feedback
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed, attempting fallback:', err);
|
||||
// Attempt fallback if modern API fails
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(button, copySuccess); // Use helper for feedback
|
||||
});
|
||||
} else {
|
||||
// Use fallback if modern API is not available or context is insecure
|
||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(button, copySuccess); // Use helper for feedback
|
||||
}
|
||||
} else {
|
||||
console.warn('No text found to copy for target:', targetId || 'direct text');
|
||||
showNotification('没有内容可复制', 'warning');
|
||||
}
|
||||
} // End of handleCopyButtonClick function
|
||||
|
||||
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
|
||||
function setupCopyButtons(containerSelector = 'body') {
|
||||
// Find buttons within the specified container (defaults to body)
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
const copyButtons = container.querySelectorAll('.copy-btn');
|
||||
copyButtons.forEach(button => {
|
||||
// Remove existing listener to prevent duplicates if called multiple times
|
||||
button.removeEventListener('click', handleCopyButtonClick);
|
||||
// Add the listener
|
||||
button.addEventListener('click', handleCopyButtonClick);
|
||||
});
|
||||
}
|
||||
|
||||
// 新增:设置批量选择相关的事件监听器
|
||||
function setupBulkSelectionListeners() {
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', handleSelectAllChange);
|
||||
}
|
||||
|
||||
if (tableBody) {
|
||||
// 使用事件委托处理行复选框的点击
|
||||
tableBody.addEventListener('change', handleRowCheckboxChange);
|
||||
}
|
||||
|
||||
if (copySelectedKeysBtn) {
|
||||
copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys);
|
||||
}
|
||||
|
||||
// 新增:为批量删除按钮添加事件监听器 (如果尚未添加)
|
||||
// 通常在 DOMContentLoaded 中添加一次即可
|
||||
// if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) {
|
||||
// deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
|
||||
// deleteSelectedBtn.hasListener = true; // 标记已添加
|
||||
// }
|
||||
}
|
||||
|
||||
// 新增:处理“全选”复选框变化的函数
|
||||
function handleSelectAllChange() {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
|
||||
rowCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = isChecked;
|
||||
});
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
// 新增:处理行复选框变化的函数 (事件委托)
|
||||
function handleRowCheckboxChange(event) {
|
||||
if (event.target.classList.contains('row-checkbox')) {
|
||||
updateSelectedState();
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:更新选中状态(计数、按钮状态、全选框状态)
|
||||
function updateSelectedState() {
|
||||
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
|
||||
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||
const selectedCount = selectedCheckboxes.length;
|
||||
|
||||
// 移除了数字显示,不再更新selectedCountSpan
|
||||
// 仍然更新复制按钮的禁用状态
|
||||
if (copySelectedKeysBtn) {
|
||||
copySelectedKeysBtn.disabled = selectedCount === 0;
|
||||
|
||||
// 可选:根据选中项数量更新按钮标题属性
|
||||
copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`);
|
||||
}
|
||||
// 新增:更新批量删除按钮的禁用状态
|
||||
if (deleteSelectedBtn) {
|
||||
deleteSelectedBtn.disabled = selectedCount === 0;
|
||||
deleteSelectedBtn.setAttribute('title', `删除${selectedCount}项选中日志`);
|
||||
}
|
||||
|
||||
// 更新“全选”复选框的状态
|
||||
if (selectAllCheckbox) {
|
||||
if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (selectedCount > 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true; // 部分选中状态
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:处理“复制选中密钥”按钮点击的函数
|
||||
function handleCopySelectedKeys() {
|
||||
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||
const keysToCopy = [];
|
||||
selectedCheckboxes.forEach(checkbox => {
|
||||
const key = checkbox.getAttribute('data-key');
|
||||
if (key) {
|
||||
keysToCopy.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (keysToCopy.length > 0) {
|
||||
const textToCopy = keysToCopy.join('\n'); // 每行一个密钥
|
||||
copyTextToClipboard(textToCopy, copySelectedKeysBtn); // 使用通用复制函数
|
||||
} else {
|
||||
showNotification('没有选中的密钥可复制', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:通用的文本复制函数(结合现有逻辑)
|
||||
function copyTextToClipboard(text, buttonElement = null) {
|
||||
let copySuccess = false;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (buttonElement) handleCopyResult(buttonElement, true);
|
||||
else showNotification('已复制到剪贴板', 'success');
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed, attempting fallback:', err);
|
||||
copySuccess = fallbackCopyTextToClipboard(text);
|
||||
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
|
||||
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
|
||||
});
|
||||
} else {
|
||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||
copySuccess = fallbackCopyTextToClipboard(text);
|
||||
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
|
||||
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 修改:处理批量删除按钮点击的函数 - 改为显示模态框
|
||||
function handleDeleteSelected() {
|
||||
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||
const logIdsToDelete = [];
|
||||
selectedCheckboxes.forEach(checkbox => {
|
||||
const logId = checkbox.getAttribute('data-log-id'); // 需要在渲染时添加 data-log-id
|
||||
if (logId) {
|
||||
logIdsToDelete.push(parseInt(logId));
|
||||
}
|
||||
});
|
||||
|
||||
if (logIdsToDelete.length === 0) {
|
||||
showNotification('没有选中的日志可删除', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (logIdsToDelete.length === 0) {
|
||||
showNotification('没有选中的日志可删除', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 存储待删除ID并显示模态框
|
||||
idsToDeleteGlobally = logIdsToDelete;
|
||||
const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`;
|
||||
showDeleteConfirmModal(message);
|
||||
}
|
||||
|
||||
// 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow)
|
||||
async function performActualDelete(logIds) {
|
||||
if (!logIds || logIds.length === 0) return;
|
||||
|
||||
const isSingleDelete = logIds.length === 1;
|
||||
const url = isSingleDelete ? `/api/logs/errors/${logIds[0]}` : '/api/logs/errors';
|
||||
const method = 'DELETE';
|
||||
const body = isSingleDelete ? null : JSON.stringify({ ids: logIds });
|
||||
const headers = isSingleDelete ? {} : { 'Content-Type': 'application/json' };
|
||||
|
||||
try {
|
||||
// Rename 'response' to 'deleteResponse' and remove duplicate fetch
|
||||
const deleteResponse = await fetch(url, {
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body,
|
||||
});
|
||||
// Removed duplicate fetch call below
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
let errorData;
|
||||
try { errorData = await deleteResponse.json(); } catch (e) { /* ignore */ }
|
||||
const actionText = isSingleDelete ? `删除该条日志` : `批量删除 ${logIds.length} 条日志`;
|
||||
throw new Error(errorData?.detail || `${actionText}失败: ${deleteResponse.statusText}`);
|
||||
}
|
||||
|
||||
const successMessage = isSingleDelete ? `成功删除该日志` : `成功删除 ${logIds.length} 条日志`;
|
||||
showNotification(successMessage, 'success');
|
||||
// 取消全选
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||
// 重新加载当前页数据
|
||||
loadErrorLogs();
|
||||
} catch (error) {
|
||||
console.error('批量删除错误日志失败:', error);
|
||||
showNotification(`批量删除失败: ${error.message}`, 'error', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 修改:处理单行删除按钮点击的函数 - 改为显示模态框
|
||||
function handleDeleteLogRow(logId) {
|
||||
if (!logId) return;
|
||||
|
||||
// 存储待删除ID并显示模态框
|
||||
idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组
|
||||
// 使用通用确认消息,不显示具体ID
|
||||
const message = `确定要删除这条日志吗?此操作不可恢复!`;
|
||||
showDeleteConfirmModal(message);
|
||||
}
|
||||
|
||||
// 新增:处理 ID 排序点击的函数
|
||||
function handleSortById() {
|
||||
if (currentSort.field === 'id') {
|
||||
// 如果当前是按 ID 排序,切换顺序
|
||||
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序
|
||||
currentSort.field = 'id';
|
||||
currentSort.order = 'desc';
|
||||
}
|
||||
// 更新图标
|
||||
updateSortIcon();
|
||||
// 重新加载第一页数据
|
||||
currentPage = 1;
|
||||
loadErrorLogs();
|
||||
}
|
||||
|
||||
// 新增:更新排序图标的函数
|
||||
function updateSortIcon() {
|
||||
if (!sortIcon) return;
|
||||
// 移除所有可能的排序类
|
||||
sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600');
|
||||
|
||||
if (currentSort.field === 'id') {
|
||||
sortIcon.classList.add(currentSort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
|
||||
sortIcon.classList.add('text-primary-600'); // 高亮显示
|
||||
} else {
|
||||
// 如果不是按 ID 排序,显示默认图标
|
||||
sortIcon.classList.add('fa-sort', 'text-gray-400');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载错误日志数据
|
||||
async function loadErrorLogs() {
|
||||
// 重置选择状态
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||
if (selectAllCheckbox) selectAllCheckbox.indeterminate = false;
|
||||
updateSelectedState(); // 更新按钮状态和计数
|
||||
|
||||
showLoading(true);
|
||||
showError(false);
|
||||
showNoData(false);
|
||||
@@ -219,14 +581,21 @@ async function loadErrorLogs() {
|
||||
const offset = (currentPage - 1) * pageSize;
|
||||
|
||||
try {
|
||||
// Construct the API URL with search parameters
|
||||
// Construct the API URL with search and sort parameters
|
||||
let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`;
|
||||
// 添加排序参数
|
||||
apiUrl += `&sort_by=${currentSort.field}&sort_order=${currentSort.order}`;
|
||||
|
||||
// 添加搜索参数
|
||||
if (currentSearch.key) {
|
||||
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
|
||||
}
|
||||
if (currentSearch.error) {
|
||||
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
|
||||
}
|
||||
if (currentSearch.errorCode) { // Add error code to API request
|
||||
apiUrl += `&error_code_search=${encodeURIComponent(currentSearch.errorCode)}`;
|
||||
}
|
||||
if (currentSearch.startDate) {
|
||||
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
|
||||
}
|
||||
@@ -246,18 +615,13 @@ async function loadErrorLogs() {
|
||||
throw new Error(errorData?.detail || `网络响应异常: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// Assuming the API returns an object like { logs: [], total: count }
|
||||
// If it only returns an array, we can't get the total count accurately for pagination
|
||||
if (Array.isArray(data)) {
|
||||
errorLogs = data;
|
||||
renderErrorLogs(errorLogs); // Pass data directly
|
||||
updatePagination(errorLogs.length, -1); // Indicate unknown total
|
||||
} else if (data && Array.isArray(data.logs)) {
|
||||
errorLogs = data.logs;
|
||||
renderErrorLogs(errorLogs); // Pass logs array
|
||||
updatePagination(errorLogs.length, data.total || -1); // Pass total count if available
|
||||
// API 现在返回 { logs: [], total: count }
|
||||
if (data && Array.isArray(data.logs)) {
|
||||
errorLogs = data.logs; // Store the list data (contains error_code)
|
||||
renderErrorLogs(errorLogs);
|
||||
updatePagination(errorLogs.length, data.total || -1);
|
||||
} else {
|
||||
throw new Error('无法识别的API响应格式');
|
||||
throw new Error('无法识别的API响应格式');
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +643,12 @@ function renderErrorLogs(logs) {
|
||||
if (!tableBody) return;
|
||||
tableBody.innerHTML = ''; // Clear previous entries
|
||||
|
||||
// 重置全选复选框状态(在清空表格后)
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
// Handled by showNoData
|
||||
return;
|
||||
@@ -302,8 +672,8 @@ function renderErrorLogs(logs) {
|
||||
} catch (e) { console.error("Error formatting date:", e); }
|
||||
|
||||
|
||||
// Truncate error log content for display
|
||||
const errorLogContent = log.error_log ? log.error_log.substring(0, 100) + (log.error_log.length > 100 ? '...' : '') : '无';
|
||||
// Display error code instead of truncated log
|
||||
const errorCodeContent = log.error_code || '无';
|
||||
|
||||
// Mask the Gemini key for display in the table
|
||||
const maskKey = (key) => {
|
||||
@@ -311,17 +681,30 @@ function renderErrorLogs(logs) {
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
const maskedKey = maskKey(log.gemini_key);
|
||||
const fullKey = log.gemini_key || ''; // Store the full key
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${sequentialId}</td> <!-- Use sequential ID -->
|
||||
<td title="${log.gemini_key || ''}">${maskedKey}</td>
|
||||
<td class="text-center px-3 py-3"> <!-- Checkbox column -->
|
||||
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}" data-log-id="${log.id}"> <!-- 添加 data-log-id -->
|
||||
</td>
|
||||
<td>${sequentialId}</td> <!-- 显示从1开始的序号 -->
|
||||
<td class="relative group" title="${fullKey}"> <!-- Added relative/group for button positioning -->
|
||||
${maskedKey}
|
||||
<!-- Added copy button for the key in the table row -->
|
||||
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${log.gemini_key || ''}" title="复制完整密钥">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>${log.error_type || '未知'}</td>
|
||||
<td class="error-log-content" title="${log.error_log || ''}">${errorLogContent}</td>
|
||||
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
|
||||
<td>${log.model_name || '未知'}</td>
|
||||
<td>${formattedTime}</td>
|
||||
<td>
|
||||
<button class="btn-view-details" data-log-id="${log.id}">
|
||||
查看详情
|
||||
<button class="btn-view-details mr-2" data-log-id="${log.id}"> <!-- 添加 mr-2 -->
|
||||
<i class="fas fa-eye mr-1"></i>详情
|
||||
</button>
|
||||
<button class="btn-delete-row text-danger-600 hover:text-danger-800" data-log-id="${log.id}" title="删除此日志">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
@@ -336,59 +719,106 @@ function renderErrorLogs(logs) {
|
||||
showLogDetails(logId);
|
||||
});
|
||||
});
|
||||
|
||||
// 新增:为新渲染的删除按钮添加事件监听器
|
||||
document.querySelectorAll('.btn-delete-row').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const logId = this.getAttribute('data-log-id');
|
||||
handleDeleteLogRow(logId);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-initialize copy buttons specifically for the newly rendered table rows
|
||||
setupCopyButtons('#errorLogsTable');
|
||||
// Update selected state after rendering
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
// 显示错误日志详情 (Custom Modal Logic)
|
||||
function showLogDetails(logId) {
|
||||
const log = errorLogs.find(l => l.id === logId);
|
||||
if (!log || !logDetailModal) return;
|
||||
// 显示错误日志详情 (从 API 获取)
|
||||
async function showLogDetails(logId) {
|
||||
if (!logDetailModal) return;
|
||||
|
||||
// Format date
|
||||
let formattedTime = 'N/A';
|
||||
try {
|
||||
const requestTime = new Date(log.request_time);
|
||||
if (!isNaN(requestTime)) {
|
||||
formattedTime = requestTime.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("Error formatting date:", e); }
|
||||
// Show loading state in modal (optional)
|
||||
// Clear previous content and show a spinner or message
|
||||
document.getElementById('modalGeminiKey').textContent = '加载中...';
|
||||
document.getElementById('modalErrorType').textContent = '加载中...';
|
||||
document.getElementById('modalErrorLog').textContent = '加载中...';
|
||||
document.getElementById('modalRequestMsg').textContent = '加载中...';
|
||||
document.getElementById('modalModelName').textContent = '加载中...';
|
||||
document.getElementById('modalRequestTime').textContent = '加载中...';
|
||||
|
||||
|
||||
// Format request message (handle potential JSON)
|
||||
let formattedRequestMsg = '无';
|
||||
if (log.request_msg) {
|
||||
try {
|
||||
// Check if it's already an object/array
|
||||
if (typeof log.request_msg === 'object' && log.request_msg !== null) {
|
||||
formattedRequestMsg = JSON.stringify(log.request_msg, null, 2);
|
||||
}
|
||||
// Check if it's a JSON string
|
||||
else if (typeof log.request_msg === 'string' && log.request_msg.trim().startsWith('{') || log.request_msg.trim().startsWith('[')) {
|
||||
formattedRequestMsg = JSON.stringify(JSON.parse(log.request_msg), null, 2);
|
||||
}
|
||||
else {
|
||||
formattedRequestMsg = String(log.request_msg);
|
||||
}
|
||||
} catch (e) {
|
||||
formattedRequestMsg = String(log.request_msg); // Fallback to string
|
||||
console.warn("Could not parse request_msg as JSON:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate modal content (show full key in modal)
|
||||
document.getElementById('modalGeminiKey').textContent = log.gemini_key || '无';
|
||||
document.getElementById('modalErrorType').textContent = log.error_type || '未知';
|
||||
document.getElementById('modalErrorLog').textContent = log.error_log || '无';
|
||||
document.getElementById('modalRequestMsg').textContent = formattedRequestMsg;
|
||||
document.getElementById('modalModelName').textContent = log.model_name || '未知';
|
||||
document.getElementById('modalRequestTime').textContent = formattedTime;
|
||||
|
||||
// Show the modal
|
||||
logDetailModal.classList.add('show');
|
||||
// Optional: Prevent body scrolling when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden'; // Prevent body scrolling
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/logs/errors/${logId}/details`);
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch (e) { /* ignore */ }
|
||||
throw new Error(errorData?.detail || `获取日志详情失败: ${response.statusText}`);
|
||||
}
|
||||
const logDetails = await response.json();
|
||||
|
||||
// Format date
|
||||
let formattedTime = 'N/A';
|
||||
try {
|
||||
const requestTime = new Date(logDetails.request_time);
|
||||
if (!isNaN(requestTime)) {
|
||||
formattedTime = requestTime.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("Error formatting date:", e); }
|
||||
|
||||
// Format request message (handle potential JSON)
|
||||
let formattedRequestMsg = '无';
|
||||
if (logDetails.request_msg) {
|
||||
try {
|
||||
if (typeof logDetails.request_msg === 'object' && logDetails.request_msg !== null) {
|
||||
formattedRequestMsg = JSON.stringify(logDetails.request_msg, null, 2);
|
||||
} else if (typeof logDetails.request_msg === 'string') {
|
||||
// Try parsing if it looks like JSON, otherwise display as string
|
||||
const trimmedMsg = logDetails.request_msg.trim();
|
||||
if (trimmedMsg.startsWith('{') || trimmedMsg.startsWith('[')) {
|
||||
formattedRequestMsg = JSON.stringify(JSON.parse(logDetails.request_msg), null, 2);
|
||||
} else {
|
||||
formattedRequestMsg = logDetails.request_msg;
|
||||
}
|
||||
} else {
|
||||
formattedRequestMsg = String(logDetails.request_msg);
|
||||
}
|
||||
} catch (e) {
|
||||
formattedRequestMsg = String(logDetails.request_msg); // Fallback
|
||||
console.warn("Could not parse request_msg as JSON:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate modal content with fetched details
|
||||
document.getElementById('modalGeminiKey').textContent = logDetails.gemini_key || '无';
|
||||
document.getElementById('modalErrorType').textContent = logDetails.error_type || '未知';
|
||||
document.getElementById('modalErrorLog').textContent = logDetails.error_log || '无'; // Full error log
|
||||
document.getElementById('modalRequestMsg').textContent = formattedRequestMsg; // Full request message
|
||||
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
|
||||
document.getElementById('modalRequestTime').textContent = formattedTime;
|
||||
|
||||
// Re-initialize copy buttons specifically for the modal after content is loaded
|
||||
setupCopyButtons('#logDetailModal');
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取日志详情失败:', error);
|
||||
// Show error in modal
|
||||
document.getElementById('modalGeminiKey').textContent = '错误';
|
||||
document.getElementById('modalErrorType').textContent = '错误';
|
||||
document.getElementById('modalErrorLog').textContent = `加载失败: ${error.message}`;
|
||||
document.getElementById('modalRequestMsg').textContent = '错误';
|
||||
document.getElementById('modalModelName').textContent = '错误';
|
||||
document.getElementById('modalRequestTime').textContent = '错误';
|
||||
// Optionally show a notification
|
||||
showNotification(`加载日志详情失败: ${error.message}`, 'error', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Close Log Detail Modal
|
||||
@@ -521,10 +951,17 @@ function showError(show, message = '加载错误日志失败,请稍后重试
|
||||
|
||||
// Function to show temporary status notifications (like copy success)
|
||||
function showNotification(message, type = 'success', duration = 3000) {
|
||||
const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed
|
||||
if (!notificationElement) return;
|
||||
const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html
|
||||
if (!notificationElement) {
|
||||
console.error("Notification element with ID 'notification' not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set message and type class
|
||||
notificationElement.textContent = message;
|
||||
// Remove previous type classes before adding the new one
|
||||
notificationElement.classList.remove('success', 'error', 'warning', 'info');
|
||||
notificationElement.classList.add(type); // Add the type class for styling
|
||||
notificationElement.className = `notification ${type} show`; // Add 'show' class
|
||||
|
||||
// Hide after duration
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,13 +17,27 @@ self.addEventListener('install', event => {
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
// 1. 尝试从缓存获取
|
||||
return cache.match(event.request).then(responseFromCache => {
|
||||
// 2. 同时从网络获取 (后台进行)
|
||||
const fetchPromise = fetch(event.request).then(responseFromNetwork => {
|
||||
// 3. 网络请求成功,更新缓存
|
||||
cache.put(event.request, responseFromNetwork.clone());
|
||||
return responseFromNetwork;
|
||||
}).catch(err => {
|
||||
// 网络请求失败时,可以选择记录错误或不执行任何操作
|
||||
console.error('Network fetch failed:', err);
|
||||
// 确保即使网络失败,如果缓存存在,我们仍然返回缓存
|
||||
// 如果缓存也不存在,则此 Promise 会 reject
|
||||
throw err;
|
||||
});
|
||||
|
||||
// 4. 如果缓存存在,立即返回缓存;否则等待网络响应
|
||||
// 后台的网络请求仍在进行,用于更新缓存
|
||||
return responseFromCache || fetchPromise;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-weight: 500; /* font-medium */
|
||||
z-index: 50;
|
||||
z-index: 1000; /* Increased z-index */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||
}
|
||||
@@ -184,7 +184,6 @@
|
||||
{% block head_extra_scripts %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- 底部版权 -->
|
||||
@@ -195,7 +194,17 @@
|
||||
</a> |
|
||||
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
||||
<i class="fab fa-github"></i> GitHub
|
||||
</a> |
|
||||
<a href="https://afdian.com/a/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
||||
<i class="fas fa-drumstick-bite text-yellow-600"></i> 给作者加鸡腿
|
||||
</a>
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-xs text-yellow-600 font-semibold">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
|
||||
</span>
|
||||
<span id="version-info-container" class="inline-block">
|
||||
<!-- Version info will be loaded here by JavaScript -->
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 通用JS -->
|
||||
@@ -259,6 +268,48 @@
|
||||
}, 300); // Short delay to show spinner
|
||||
}
|
||||
|
||||
// --- Version Check ---
|
||||
const versionInfoContainer = document.getElementById('version-info-container');
|
||||
|
||||
async function fetchVersionInfo() {
|
||||
if (!versionInfoContainer) return;
|
||||
versionInfoContainer.innerHTML = '<span class="mx-1">|</span><span class="text-xs text-gray-400">检查更新中...</span>'; // Initial loading state
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/version/check');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
let versionHtml = `<span class="mx-1">|</span><span class="text-xs text-gray-500">v${data.current_version}</span>`;
|
||||
if (data.update_available) {
|
||||
versionHtml += `
|
||||
<span class="mx-1">|</span>
|
||||
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
|
||||
<i class="fas fa-arrow-up"></i> 新版本: v${data.latest_version}
|
||||
</a>`;
|
||||
} else if (data.error_message) {
|
||||
versionHtml += `
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-xs text-red-500" title="${data.error_message}">更新检查失败</span>`;
|
||||
} else {
|
||||
versionHtml += `<span class="mx-1">|</span><span class="text-xs text-green-500">已是最新</span>`; // Indicate up-to-date
|
||||
}
|
||||
versionInfoContainer.innerHTML = versionHtml;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching version info:', error);
|
||||
versionInfoContainer.innerHTML = `<span class="mx-1">|</span><span class="text-xs text-red-500" title="无法连接到服务器或解析响应">更新检查失败</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch immediately on load
|
||||
fetchVersionInfo();
|
||||
|
||||
// Fetch periodically (e.g., every hour)
|
||||
setInterval(fetchVersionInfo, 3600000); // 3600000 ms = 1 hour
|
||||
|
||||
</script>
|
||||
{% block body_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
@apply: bg-primary-600;
|
||||
background-color: #4F46E5;
|
||||
}
|
||||
/* 统一通知样式为黑色半透明,确保与 keys_status 一致 */
|
||||
.notification {
|
||||
background: rgba(0,0,0,0.8) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -87,6 +92,9 @@
|
||||
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="scheduler">
|
||||
定时任务
|
||||
</button>
|
||||
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="logging">
|
||||
日志配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Save Status Banner (Removed - using notification component now) -->
|
||||
@@ -108,7 +116,10 @@
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="API_KEYS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="bulkDeleteApiKeyBtn">
|
||||
<i class="fas fa-trash-alt"></i> 删除密钥
|
||||
</button>
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addApiKeyBtn">
|
||||
<i class="fas fa-plus"></i> 添加密钥
|
||||
</button>
|
||||
@@ -133,7 +144,14 @@
|
||||
<!-- 认证令牌 -->
|
||||
<div class="mb-6">
|
||||
<label for="AUTH_TOKEN" class="block font-semibold mb-2 text-gray-700">认证令牌</label>
|
||||
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center flex-grow border border-gray-300 rounded-md focus-within:border-primary-500 focus-within:ring focus-within:ring-primary-200 focus-within:ring-opacity-50">
|
||||
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none">
|
||||
<button type="button" id="generateAuthTokenBtn" class="generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors" title="生成随机令牌">
|
||||
<i class="fas fa-dice"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">用于API认证的令牌</small>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +182,22 @@
|
||||
<input type="number" id="MAX_RETRIES" name="MAX_RETRIES" min="0" max="10" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">API请求失败后的最大重试次数</small>
|
||||
</div>
|
||||
<!-- 代理服务器列表 -->
|
||||
<div class="mb-6">
|
||||
<label for="PROXIES" class="block font-semibold mb-2 text-gray-700">代理服务器列表</label>
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="PROXIES_container">
|
||||
<!-- 代理项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="bulkDeleteProxyBtn">
|
||||
<i class="fas fa-trash-alt"></i> 删除代理
|
||||
</button>
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addProxyBtn">
|
||||
<i class="fas fa-plus"></i> 添加代理
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">代理服务器列表,支持 http 和 socks5 格式,例如: http://user:pass@host:port 或 socks5://host:port。点击按钮可批量添加或删除。</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型相关配置 -->
|
||||
@@ -247,8 +281,52 @@
|
||||
<label for="SHOW_THINKING_PROCESS" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 思考模型列表 -->
|
||||
<div class="mb-6">
|
||||
<label for="THINKING_MODELS" class="block font-semibold mb-2 text-gray-700">思考模型列表</label>
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="THINKING_MODELS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('THINKING_MODELS')">
|
||||
<i class="fas fa-plus"></i> 添加模型
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">用于“思考过程”的模型列表</small>
|
||||
</div>
|
||||
|
||||
<!-- 思考模型预算映射 -->
|
||||
<div class="mb-6">
|
||||
<label for="THINKING_BUDGET_MAP" class="block font-semibold mb-2 text-gray-700">思考模型预算映射</label>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-2 space-y-3" id="THINKING_BUDGET_MAP_container">
|
||||
<!-- 键值对将在这里动态添加 -->
|
||||
<div class="text-gray-500 text-sm italic">请先在上方添加思考模型,然后在此处配置预算。</div>
|
||||
</div>
|
||||
<!-- 移除添加预算映射按钮 -->
|
||||
<!-- <div class="flex justify-end">
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addBudgetMapItemBtn">
|
||||
<i class="fas fa-plus"></i> 添加预算映射
|
||||
</button>
|
||||
</div> -->
|
||||
<small class="text-gray-500 mt-1 block">为每个思考模型设置预算(整数,最大值 24576),此项与上方模型列表自动关联。</small>
|
||||
</div>
|
||||
<!-- 安全设置 -->
|
||||
<div class="mb-6">
|
||||
<label for="SAFETY_SETTINGS" class="block font-semibold mb-2 text-gray-700">安全设置 (Safety Settings)</label>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-2 space-y-3" id="SAFETY_SETTINGS_container">
|
||||
<!-- 安全设置项将在这里动态添加 -->
|
||||
<div class="text-gray-500 text-sm italic">定义模型的安全过滤阈值。</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addSafetySettingBtn">
|
||||
<i class="fas fa-plus"></i> 添加安全设置
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">配置模型的安全过滤级别,例如 HARM_CATEGORY_HARASSMENT: BLOCK_NONE。</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 图像生成相关配置 -->
|
||||
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="image-section">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
@@ -275,7 +353,7 @@
|
||||
<select id="UPLOAD_PROVIDER" name="UPLOAD_PROVIDER" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
|
||||
<option value="smms" selected>SM.MS</option>
|
||||
<option value="picgo">PicGo</option>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
<option value="cloudflare_imgbed">Cloudflare</option>
|
||||
</select>
|
||||
<small class="text-gray-500 mt-1 block">图片上传服务提供商</small>
|
||||
</div>
|
||||
@@ -295,14 +373,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare图床URL -->
|
||||
<div class="mb-6 provider-config" data-provider="cloudflare">
|
||||
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
|
||||
<label for="CLOUDFLARE_IMGBED_URL" class="block font-semibold mb-2 text-gray-700">Cloudflare图床URL</label>
|
||||
<input type="text" id="CLOUDFLARE_IMGBED_URL" name="CLOUDFLARE_IMGBED_URL" placeholder="https://xxxxxxx.pages.dev/upload" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">Cloudflare图床的URL</small>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare认证码 -->
|
||||
<div class="mb-6 provider-config" data-provider="cloudflare">
|
||||
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
|
||||
<label for="CLOUDFLARE_IMGBED_AUTH_CODE" class="block font-semibold mb-2 text-gray-700">Cloudflare认证码</label>
|
||||
<input type="text" id="CLOUDFLARE_IMGBED_AUTH_CODE" name="CLOUDFLARE_IMGBED_AUTH_CODE" placeholder="xxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
|
||||
@@ -380,6 +458,26 @@
|
||||
<small class="text-gray-500 mt-1 block">定时任务使用的时区,格式如 "Asia/Shanghai" 或 "UTC"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志配置 -->
|
||||
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="logging-section">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-file-alt text-primary-600"></i> 日志配置
|
||||
</h2>
|
||||
|
||||
<!-- 日志级别 -->
|
||||
<div class="mb-6">
|
||||
<label for="LOG_LEVEL" class="block font-semibold mb-2 text-gray-700">日志级别</label>
|
||||
<select id="LOG_LEVEL" name="LOG_LEVEL" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
<small class="text-gray-500 mt-1 block">设置应用程序的日志记录详细程度</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col md:flex-row justify-center gap-4 mt-8">
|
||||
@@ -425,7 +523,61 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Bulk Delete API Key Modal -->
|
||||
<div id="bulkDeleteApiKeyModal" class="modal">
|
||||
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">批量删除 API 密钥</h2>
|
||||
<button id="closeBulkDeleteModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">每行粘贴一个或多个密钥,将自动提取有效密钥并从列表中删除。</p>
|
||||
<textarea id="bulkDeleteApiKeyInput" rows="10" placeholder="在此处粘贴要删除的 API 密钥..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-danger-500 focus:ring focus:ring-danger-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" id="confirmBulkDeleteApiKeyBtn" class="bg-danger-600 hover:bg-danger-700 text-white px-6 py-2 rounded-lg font-medium transition">确认删除</button>
|
||||
<button type="button" id="cancelBulkDeleteApiKeyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Add Modal -->
|
||||
<div id="proxyModal" class="modal">
|
||||
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">批量添加代理服务器</h2>
|
||||
<button id="closeProxyModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">每行粘贴一个或多个代理地址,将自动提取有效地址并去重。</p>
|
||||
<textarea id="proxyBulkInput" rows="10" placeholder="在此处粘贴代理地址 (例如 http://user:pass@host:port 或 socks5://host:port)..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" id="confirmAddProxyBtn" class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-2 rounded-lg font-medium transition">确认添加</button>
|
||||
<button type="button" id="cancelAddProxyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Proxy Modal -->
|
||||
<div id="bulkDeleteProxyModal" class="modal">
|
||||
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">批量删除代理服务器</h2>
|
||||
<button id="closeBulkDeleteProxyModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">每行粘贴一个或多个代理地址,将自动提取有效地址并从列表中删除。</p>
|
||||
<textarea id="bulkDeleteProxyInput" rows="10" placeholder="在此处粘贴要删除的代理地址..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-danger-500 focus:ring focus:ring-danger-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" id="confirmBulkDeleteProxyBtn" class="bg-danger-600 hover:bg-danger-700 text-white px-6 py-2 rounded-lg font-medium transition">确认删除</button>
|
||||
<button type="button" id="cancelBulkDeleteProxyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Confirmation Modal -->
|
||||
<div id="resetConfirmModal" class="modal">
|
||||
<div class="w-full max-w-md mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
|
||||
@@ -48,6 +48,26 @@
|
||||
}
|
||||
}
|
||||
/* Modal styles are in base.html */
|
||||
|
||||
/* 确保输入框和按钮高度一致 */
|
||||
input[type="text"], input[type="datetime-local"], select, button {
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
/* 日期选择器样式优化 */
|
||||
.date-range-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 确保所有输入框在小屏幕上正确显示 */
|
||||
@media (max-width: 640px) {
|
||||
input[type="datetime-local"] {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -83,32 +103,56 @@
|
||||
<!-- 控制区域 (Refresh button removed, page size moved below) -->
|
||||
<!-- Removed the original controls div -->
|
||||
|
||||
<!-- 搜索控件 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
|
||||
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
||||
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
||||
<div class="flex items-center gap-2 col-span-1 lg:col-span-2">
|
||||
<input type="datetime-local" id="startDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
||||
<span class="text-gray-700">至</span>
|
||||
<input type="datetime-local" id="endDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
||||
<!-- 搜索与操作控件 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"> <!-- 修改为items-center -->
|
||||
<!-- Left side: Search inputs and date range -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"> <!-- 修改为3列布局 -->
|
||||
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="errorCodeSearch" placeholder="搜索错误码" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<!-- 日期选择器单独一行 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 col-span-1 sm:col-span-2 lg:col-span-3 mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700 whitespace-nowrap">开始时间:</label>
|
||||
<input type="datetime-local" id="startDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700 whitespace-nowrap">结束时间:</label>
|
||||
<input type="datetime-local" id="endDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right side: Action buttons -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0"> <!-- 移除上边距 -->
|
||||
<button id="searchBtn" class="flex items-center justify-center px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;">
|
||||
<i class="fas fa-search mr-1.5"></i>搜索
|
||||
</button>
|
||||
<button id="copySelectedKeysBtn" class="flex items-center justify-center px-4 py-1.5 bg-success-600 hover:bg-success-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
|
||||
<i class="far fa-copy mr-1.5"></i>复制
|
||||
</button>
|
||||
<button id="deleteSelectedBtn" class="flex items-center justify-center px-4 py-1.5 bg-danger-600 hover:bg-danger-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
|
||||
<i class="fas fa-trash-alt mr-1.5"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
<button id="searchBtn" class="flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 col-span-1">
|
||||
<i class="fas fa-search"></i> 搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 表格容器 - Enhanced Styling -->
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 mb-6 bg-white"> <!-- Removed shadow, added border -->
|
||||
<table class="styled-table w-full min-w-full text-sm"> <!-- Added text-sm -->
|
||||
<thead>
|
||||
<tr class="bg-primary-50 text-left text-primary-800"> <!-- Changed header background and text color -->
|
||||
<th class="px-5 py-3 font-semibold rounded-tl-lg">ID</th> <!-- Increased padding, adjusted rounding -->
|
||||
<th class="px-3 py-3 font-semibold rounded-tl-lg w-12 text-center"> <!-- Adjusted padding and width -->
|
||||
<input type="checkbox" id="selectAllCheckbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500">
|
||||
</th>
|
||||
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
|
||||
ID <i class="fas fa-sort ml-1 text-gray-400"></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">请求时间</th>
|
||||
<th class="px-5 py-3 font-semibold rounded-tr-lg">操作</th> <!-- Adjusted rounding -->
|
||||
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">操作</th> <!-- Adjusted rounding and centered -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorLogsTable" class="divide-y divide-gray-200">
|
||||
@@ -186,17 +230,23 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
|
||||
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
|
||||
<p id="modalErrorType" class="text-danger-600 font-medium"></p>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalGeminiKey" title="复制密钥">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
|
||||
<p id="modalErrorType" class="text-danger-600 font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorType" title="复制错误类型">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
|
||||
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
|
||||
@@ -204,7 +254,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
|
||||
<pre id="modalRequestMsg" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestMsg" title="复制请求消息">
|
||||
@@ -212,14 +262,20 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
|
||||
<p id="modalModelName" class="font-medium"></p>
|
||||
<p id="modalModelName" class="font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalModelName" title="复制模型名称">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
|
||||
<p id="modalRequestTime" class="font-medium"></p>
|
||||
<p id="modalRequestTime" class="font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestTime" title="复制请求时间">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,10 +286,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<div id="deleteConfirmModal" class="modal">
|
||||
<div class="w-full max-w-md mx-auto bg-white rounded-xl shadow-xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 pb-3 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">确认删除</h2>
|
||||
<button id="closeDeleteConfirmModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p id="deleteConfirmMessage" class="text-gray-700 mb-6">你确定要删除选中的项目吗?此操作不可恢复!</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button id="cancelDeleteBtn" type="button" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-5 py-2 rounded-lg font-medium transition">取消</button>
|
||||
<button id="confirmDeleteBtn" type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-5 py-2 rounded-lg font-medium transition">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script src="/static/js/error_logs.js') }}"></script>
|
||||
<script src="/static/js/error_logs.js"></script>
|
||||
<script>
|
||||
// error_logs.html specific JS initialization (if any)
|
||||
// e.g., initialize date pickers or other elements if needed
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
<style>
|
||||
/* keys_status.html specific styles */
|
||||
.key-content {
|
||||
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out; /* Added padding transition */
|
||||
overflow: hidden; /* Keep hidden initially and during collapse */
|
||||
}
|
||||
.key-content.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
max-height: 0 !important; /* Use important to override inline style during transition */
|
||||
opacity: 0;
|
||||
padding-top: 0 !important; /* Collapse padding */
|
||||
padding-bottom: 0 !important; /* Collapse padding */
|
||||
/* overflow: hidden; */ /* Already set above */
|
||||
}
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
@@ -30,13 +33,13 @@
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-dashboard {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stats-card {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
@@ -48,11 +51,11 @@
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
.stats-card:hover {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.stats-card-header {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -60,8 +63,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap; /* Allow wrapping for smaller screens */
|
||||
gap: 0.5rem; /* Add gap between items */
|
||||
}
|
||||
|
||||
|
||||
.stats-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -69,19 +74,19 @@
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
|
||||
.stats-card-title i {
|
||||
margin-right: 0.5rem;
|
||||
color: #4F46E5;
|
||||
}
|
||||
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
/* 统计项样式 */
|
||||
.stat-item {
|
||||
padding: 0.75rem;
|
||||
@@ -95,7 +100,7 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.stat-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -105,15 +110,15 @@
|
||||
z-index: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
.stat-item:hover::before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
|
||||
.stat-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
@@ -121,7 +126,7 @@
|
||||
position: relative;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -132,7 +137,7 @@
|
||||
position: relative;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
|
||||
.stat-icon {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
@@ -142,63 +147,68 @@
|
||||
transform: rotate(12deg);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
.stat-item:hover .stat-icon {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.1) rotate(0deg);
|
||||
}
|
||||
|
||||
|
||||
/* 统计类型样式 */
|
||||
.stat-primary {
|
||||
color: #4F46E5;
|
||||
background-color: rgba(238, 242, 255, 0.5);
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
color: #10B981;
|
||||
background-color: rgba(236, 253, 245, 0.5);
|
||||
}
|
||||
|
||||
.stat-danger {
|
||||
color: #EF4444;
|
||||
background-color: rgba(254, 242, 242, 0.5);
|
||||
}
|
||||
|
||||
.stat-warning {
|
||||
color: #F59E0B;
|
||||
background-color: rgba(255, 251, 235, 0.5);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
color: #3B82F6;
|
||||
background-color: rgba(239, 246, 255, 0.5);
|
||||
}
|
||||
|
||||
.stat-primary { color: #4F46E5; background-color: rgba(238, 242, 255, 0.5); }
|
||||
.stat-success { color: #10B981; background-color: rgba(236, 253, 245, 0.5); }
|
||||
.stat-danger { color: #EF4444; background-color: rgba(254, 242, 242, 0.5); }
|
||||
.stat-warning { color: #F59E0B; background-color: rgba(255, 251, 235, 0.5); }
|
||||
.stat-info { color: #3B82F6; background-color: rgba(239, 246, 255, 0.5); }
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 640px) {
|
||||
.stats-dashboard {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.625rem;
|
||||
.stats-dashboard { gap: 1rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; padding: 0.5rem; }
|
||||
.stat-item { padding: 0.5rem; }
|
||||
.stat-value { font-size: 1.25rem; }
|
||||
.stat-label { font-size: 0.625rem; }
|
||||
.stats-card-header { padding: 0.5rem 0.75rem; } /* Adjust header padding */
|
||||
.key-content ul { grid-template-columns: 1fr; } /* Stack keys vertically on small screens */
|
||||
}
|
||||
/* Tailwind Toggle Switch Helper CSS */
|
||||
.toggle-checkbox:checked {
|
||||
@apply: right-0 border-primary-600;
|
||||
right: 0;
|
||||
border-color: #4F46E5;
|
||||
}
|
||||
.toggle-checkbox:checked + .toggle-label {
|
||||
@apply: bg-primary-600;
|
||||
background-color: #4F46E5;
|
||||
}
|
||||
|
||||
/* Pagination Controls */
|
||||
#validPaginationControls, #invalidPaginationControls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1rem; /* mt-4 */
|
||||
gap: 0.5rem; /* space-x-2 */
|
||||
}
|
||||
|
||||
/* Ensure list items are flex for alignment */
|
||||
#validKeys li, #invalidKeys li {
|
||||
display: flex;
|
||||
align-items: flex-start; /* Align checkbox with top of content */
|
||||
gap: 0.75rem; /* gap-3 */
|
||||
}
|
||||
/* Ensure grid layout for key lists */
|
||||
#validKeys, #invalidKeys {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Default single column */
|
||||
gap: 0.75rem; /* gap-3 */
|
||||
}
|
||||
@media (min-width: 768px) { /* md breakpoint */
|
||||
#validKeys, #invalidKeys {
|
||||
grid-template-columns: repeat(2, 1fr); /* Two columns on medium screens and up */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -210,9 +220,20 @@
|
||||
{% block content %}
|
||||
<div class="container max-w-6xl mx-auto px-4"> <!-- Increased max-width -->
|
||||
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
|
||||
<button class="absolute top-6 right-6 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<div class="absolute top-6 right-6 flex items-center gap-3">
|
||||
<!-- 自动刷新开关 -->
|
||||
<div class="flex items-center text-sm text-gray-600 select-none">
|
||||
<span class="mr-2">自动刷新</span>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="autoRefreshToggle" id="autoRefreshToggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
|
||||
<label for="autoRefreshToggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 手动刷新按钮 -->
|
||||
<button class="bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)" title="手动刷新">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
@@ -261,7 +282,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- API调用统计卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-header">
|
||||
@@ -290,145 +311,217 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 有效密钥区域 -->
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'validKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Left side: Title and Toggle Icon -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-check-circle text-success-500"></i>
|
||||
<h2 class="text-lg font-semibold">有效密钥列表 ({{ valid_key_count }})</h2>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<label for="failCountThreshold" class="text-sm text-gray-600 select-none">失败次数≥</label>
|
||||
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500">
|
||||
<h2 class="text-lg font-semibold whitespace-nowrap">有效密钥列表 ({{ valid_key_count }})</h2>
|
||||
</div>
|
||||
<!-- Middle: Filters and Search (Allow wrapping) -->
|
||||
<div class="flex items-center gap-x-4 gap-y-2 flex-grow flex-wrap justify-start md:justify-center"> <!-- Allow wrapping, center on medium+ -->
|
||||
<!-- 失败次数筛选 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="failCountThreshold" class="text-sm text-gray-600 select-none whitespace-nowrap">失败次数≥</label>
|
||||
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
|
||||
</div>
|
||||
<!-- 密钥搜索 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="keySearchInput" class="text-sm text-gray-600 select-none whitespace-nowrap"><i class="fas fa-search mr-1"></i>搜索</label>
|
||||
<input type="search" id="keySearchInput" placeholder="输入密钥..." class="form-input h-7 w-32 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
|
||||
</div>
|
||||
<!-- 每页显示数量 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="itemsPerPageSelect" class="text-sm text-gray-600 select-none whitespace-nowrap">每页</label>
|
||||
<select id="itemsPerPageSelect" class="form-select h-7 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500 bg-white" onclick="event.stopPropagation();">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span class="text-sm text-gray-600 select-none">项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('valid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
</button>
|
||||
<!-- Right side: Select All -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0" onclick="event.stopPropagation();"> <!-- Prevent shrinking -->
|
||||
<input type="checkbox" id="selectAllValid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('valid', this.checked)">
|
||||
<label for="selectAllValid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 批量操作按钮组 (仅在选中时显示) -->
|
||||
<div id="validBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
|
||||
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="validSelectedCount">0</span> 项</span>
|
||||
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('valid', event)" disabled>
|
||||
<i class="fas fa-check-double"></i> 批量验证
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid" disabled>
|
||||
<i class="fas fa-redo-alt"></i> 批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('valid')" disabled>
|
||||
<i class="fas fa-copy"></i> 批量复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<!-- Key list will be populated by JS -->
|
||||
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{# Initial keys rendered by server-side for non-JS users or initial load #}
|
||||
{# JS will replace this content with paginated/filtered results #}
|
||||
{% if valid_keys %}
|
||||
{% for key, fail_count in valid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
|
||||
<i class="fas fa-check mr-1"></i> 有效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}" data-key="{{ key }}">
|
||||
<!-- Checkbox -->
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="valid" value="{{ key }}">
|
||||
<!-- Key Info -->
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
|
||||
<i class="fas fa-check mr-1"></i> 有效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无有效密钥</li>
|
||||
<li class="text-center text-gray-500 py-4 col-span-full">暂无有效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- 有效密钥分页控件容器 -->
|
||||
<div id="validPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
|
||||
<!-- Pagination controls will be generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 无效密钥区域 -->
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.4s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Left side: Title and Toggle Icon -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-times-circle text-danger-500"></i>
|
||||
<h2 class="text-lg font-semibold">无效密钥列表 ({{ invalid_key_count }})</h2>
|
||||
<h2 class="text-lg font-semibold whitespace-nowrap">无效密钥列表 ({{ invalid_key_count }})</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('invalid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
</button>
|
||||
<!-- Right side: Select All -->
|
||||
<div class="flex items-center gap-1 ml-auto flex-shrink-0" onclick="event.stopPropagation();"> <!-- Use ml-auto, Prevent shrinking -->
|
||||
<input type="checkbox" id="selectAllInvalid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('invalid', this.checked)">
|
||||
<label for="selectAllInvalid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 批量操作按钮组 (仅在选中时显示) -->
|
||||
<div id="invalidBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
|
||||
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="invalidSelectedCount">0</span> 项</span>
|
||||
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('invalid', event)" disabled>
|
||||
<i class="fas fa-check-double"></i> 批量验证
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid" disabled>
|
||||
<i class="fas fa-redo-alt"></i> 批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('invalid')" disabled>
|
||||
<i class="fas fa-copy"></i> 批量复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<!-- Key list will be populated by JS -->
|
||||
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{# Initial keys rendered by server-side #}
|
||||
{# JS will replace this content with paginated results #}
|
||||
{% if invalid_keys %}
|
||||
{% for key, fail_count in invalid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
|
||||
<i class="fas fa-times mr-1"></i> 无效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1" data-key="{{ key }}">
|
||||
<!-- Checkbox -->
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="invalid" value="{{ key }}">
|
||||
<!-- Key Info -->
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
|
||||
<i class="fas fa-times mr-1"></i> 无效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无无效密钥</li>
|
||||
<li class="text-center text-gray-500 py-4 col-span-full">暂无无效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- 无效密钥分页控件容器 -->
|
||||
<div id="invalidPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
|
||||
<!-- Pagination controls will be generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed old total keys display -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Scroll buttons are now in base.html -->
|
||||
<div class="scroll-buttons">
|
||||
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
|
||||
@@ -438,7 +531,7 @@
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notification component is now in base.html (use id="notification") -->
|
||||
<div id="notification" class="notification"></div>
|
||||
<!-- 重置确认模态框 -->
|
||||
@@ -463,22 +556,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作结果模态框 -->
|
||||
<div id="resultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<!-- 验证确认模态框 -->
|
||||
<div id="verifyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800" id="resultModalTitle">操作结果</h3>
|
||||
<button onclick="closeResultModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
|
||||
<h3 class="text-lg font-semibold text-gray-800" id="verifyModalTitle">批量验证密钥</h3>
|
||||
<button onclick="closeVerifyModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-6 text-center">
|
||||
<div id="resultIcon" class="text-5xl mb-3"></div>
|
||||
<p class="text-gray-600" id="resultModalMessage"></p>
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-600" id="verifyModalMessage"></p>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button id="resultModalConfirmBtn" onclick="closeResultModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button onclick="closeVerifyModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 rounded-lg transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button id="confirmVerifyBtn" class="px-4 py-2 bg-teal-500 hover:bg-teal-600 text-white rounded-lg transition-colors">
|
||||
确认验证
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作结果模态框 -->
|
||||
<div id="resultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200">
|
||||
<div class="flex items-center justify-between px-6 pt-6 pb-2 border-b">
|
||||
<h3 class="text-xl font-bold text-gray-800 text-center w-full" id="resultModalTitle" style="letter-spacing:0.05em;">操作结果</h3>
|
||||
<button onclick="closeResultModal()" class="absolute right-6 top-6 text-gray-400 hover:text-gray-700 focus:outline-none text-2xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col items-center px-8 pt-6 pb-2">
|
||||
<div id="resultIcon" class="text-6xl mb-3"></div>
|
||||
</div>
|
||||
<div class="px-8 pb-2 w-full">
|
||||
<div id="resultModalMessage"
|
||||
class="text-gray-700 text-base leading-relaxed break-words whitespace-pre-line max-h-80 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50 p-4 shadow-inner"
|
||||
style="font-family: 'JetBrains Mono', 'Fira Mono', 'Consolas', 'monospace';">
|
||||
<!-- Content is dynamically generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center px-8 pb-6 pt-2">
|
||||
<button id="resultModalConfirmBtn" onclick="closeResultModal()" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-semibold text-base shadow transition-colors">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
@@ -508,71 +629,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 密钥使用详情模态框 -->
|
||||
<div id="keyUsageDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-lg w-full animate-fade-in"> <!-- Adjusted max-width -->
|
||||
<div class="flex items-center justify-between mb-4 border-b pb-3">
|
||||
<h3 class="text-xl font-semibold text-gray-800" id="keyUsageDetailsModalTitle">密钥请求详情</h3>
|
||||
<button onclick="closeKeyUsageDetailsModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none text-xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="keyUsageDetailsContent" class="mb-6 max-h-[50vh] overflow-y-auto pr-2"> <!-- Adjusted max-height -->
|
||||
<!-- 详细数据将加载到这里 -->
|
||||
<div class="text-center py-10">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4 border-t">
|
||||
<button onclick="closeKeyUsageDetailsModal()" class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors text-sm font-medium">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer is now in base.html -->
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
// keys_status.html specific JavaScript initialization
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Filter functionality based on fail count threshold
|
||||
const thresholdInput = document.getElementById('failCountThreshold');
|
||||
const validKeysList = document.getElementById('validKeys');
|
||||
|
||||
function filterValidKeys() {
|
||||
const threshold = parseInt(thresholdInput.value, 10);
|
||||
if (isNaN(threshold)) return; // Do nothing if input is not a number
|
||||
|
||||
const keys = validKeysList.querySelectorAll('li');
|
||||
let visibleCount = 0;
|
||||
keys.forEach(keyItem => {
|
||||
// Check if it's a key item (has data-fail-count) before processing
|
||||
if (keyItem.hasAttribute('data-fail-count')) {
|
||||
const failCount = parseInt(keyItem.getAttribute('data-fail-count'), 10);
|
||||
if (failCount >= threshold) {
|
||||
keyItem.style.display = ''; // Show item
|
||||
visibleCount++;
|
||||
} else {
|
||||
keyItem.style.display = 'none'; // Hide item
|
||||
}
|
||||
}
|
||||
});
|
||||
// Optional: Show a message if no keys match the filter
|
||||
const noMatchMsgId = 'no-valid-keys-msg';
|
||||
let noMatchMsg = validKeysList.querySelector(`#${noMatchMsgId}`);
|
||||
if (visibleCount === 0 && keys.length > 0) { // Only show if there were keys initially
|
||||
if (!noMatchMsg) {
|
||||
noMatchMsg = document.createElement('li');
|
||||
noMatchMsg.id = noMatchMsgId;
|
||||
noMatchMsg.className = 'text-center text-gray-500 py-4';
|
||||
noMatchMsg.textContent = '没有符合条件的有效密钥';
|
||||
validKeysList.appendChild(noMatchMsg);
|
||||
}
|
||||
noMatchMsg.style.display = '';
|
||||
} else if (noMatchMsg) {
|
||||
noMatchMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (thresholdInput && validKeysList) {
|
||||
thresholdInput.addEventListener('input', filterValidKeys);
|
||||
// Initial filter on load
|
||||
filterValidKeys();
|
||||
}
|
||||
|
||||
// Initialize other elements or event listeners if needed
|
||||
// The main logic (verifyKey, resetKeyFailCount, copyKey, etc.) is in keys_status.js
|
||||
// The toggleSection logic is now specific to this page
|
||||
window.toggleSection = function(header, sectionId) {
|
||||
const toggleIcon = header.querySelector('.toggle-icon');
|
||||
const content = header.nextElementSibling; // Assumes content is immediately after header
|
||||
if (toggleIcon && content) {
|
||||
toggleIcon.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
});
|
||||
// keys_status.html specific JavaScript initialization is now handled by keys_status.js
|
||||
// The DOMContentLoaded listener in keys_status.js will execute after the DOM is ready.
|
||||
// No inline script needed here anymore.
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,9 +6,19 @@ import re
|
||||
import base64
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
@@ -146,3 +156,21 @@ def is_valid_api_key(key: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
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
|
||||
try:
|
||||
# Use Path object's open method
|
||||
with version_file.open('r', encoding='utf-8') as f:
|
||||
version = f.read().strip()
|
||||
if not version:
|
||||
helper_logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
return version
|
||||
except FileNotFoundError:
|
||||
helper_logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
except IOError as e:
|
||||
helper_logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
version: '3'
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
services:
|
||||
gemini-balance:
|
||||
build: .
|
||||
image: ghcr.io/snailyp/gemini-balance:latest
|
||||
container_name: gemini-balance
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import requests; exit(0) if requests.get('http://localhost:8000/health').status_code == 200 else exit(1)\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
mysql:
|
||||
image: mysql:8
|
||||
container_name: gemini-balance-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: your_root_password
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
||||
MYSQL_USER: ${MYSQL_USER}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
# ports:
|
||||
# - "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
|
||||
interval: 10s # 每隔10秒检查一次
|
||||
timeout: 5s # 每次检查的超时时间为5秒
|
||||
retries: 3 # 重试3次失败后标记为 unhealthy
|
||||
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查
|
||||
@@ -1,5 +1,5 @@
|
||||
fastapi
|
||||
httpx
|
||||
httpx[socks]
|
||||
openai
|
||||
pydantic
|
||||
pydantic_settings
|
||||
@@ -16,4 +16,5 @@ sqlalchemy
|
||||
aiomysql
|
||||
databases
|
||||
python-dotenv
|
||||
apscheduler # 添加定时任务库
|
||||
apscheduler
|
||||
packaging
|
||||
|
||||
Reference in New Issue
Block a user