mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-04 22:31:31 +08:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea3452b17 | ||
|
|
11e45fca37 | ||
|
|
c85fe979e5 | ||
|
|
a47edf1661 | ||
|
|
814a2e66c0 | ||
|
|
a7d548a849 | ||
|
|
b6a54190ed | ||
|
|
920228d3aa | ||
|
|
f1f568afca | ||
|
|
30bf666a57 | ||
|
|
c65d5244d6 | ||
|
|
4ad18e43ef | ||
|
|
f17cd66127 | ||
|
|
e1c068ed9e | ||
|
|
b86eac839d | ||
|
|
83252cbf33 | ||
|
|
12f6665519 | ||
|
|
1ff494416b | ||
|
|
8ec1d16e9d | ||
|
|
f13a4fba5f | ||
|
|
d4a3ed3a57 | ||
|
|
a6a1e7fb52 | ||
|
|
c01bc242aa | ||
|
|
ab06627d3f | ||
|
|
631d054d9e | ||
|
|
d835085e61 | ||
|
|
7c3ebe7e8b | ||
|
|
7e76d07e28 | ||
|
|
d21fb6c455 | ||
|
|
56f6f5e198 | ||
|
|
929592bbc4 | ||
|
|
2225a40bbe | ||
|
|
3480fa3b0f | ||
|
|
d7113f5fc4 | ||
|
|
2072f54ca1 | ||
|
|
7c9b721164 | ||
|
|
83ce50975a | ||
|
|
7da9110704 | ||
|
|
e9d19de7c6 | ||
|
|
e822831178 | ||
|
|
775930edce | ||
|
|
cb40848c04 | ||
|
|
7098c8755f | ||
|
|
705d602dee | ||
|
|
cd257a9406 | ||
|
|
cd54650431 | ||
|
|
a5602c602e | ||
|
|
dd70fd4c44 | ||
|
|
dbe50628b3 |
26
.env.example
26
.env.example
@@ -1,12 +1,15 @@
|
|||||||
# MySQL数据库配置
|
# 数据库配置
|
||||||
|
DATABASE_TYPE=mysql
|
||||||
|
#SQLITE_DATABASE=default_db
|
||||||
MYSQL_HOST=gemini-balance-mysql
|
MYSQL_HOST=gemini-balance-mysql
|
||||||
|
#MYSQL_SOCKET=/run/mysqld/mysqld.sock
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
MYSQL_USER=gemini
|
MYSQL_USER=gemini
|
||||||
MYSQL_PASSWORD=change_me
|
MYSQL_PASSWORD=change_me
|
||||||
MYSQL_DATABASE=default_db
|
MYSQL_DATABASE=default_db
|
||||||
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
|
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
|
||||||
ALLOWED_TOKENS=["sk-123456"]
|
ALLOWED_TOKENS=["sk-123456"]
|
||||||
# AUTH_TOKEN=sk-123456
|
AUTH_TOKEN=sk-123456
|
||||||
TEST_MODEL=gemini-1.5-flash
|
TEST_MODEL=gemini-1.5-flash
|
||||||
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
|
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
|
||||||
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
|
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
|
||||||
@@ -23,6 +26,9 @@ CHECK_INTERVAL_HOURS=1
|
|||||||
TIMEZONE=Asia/Shanghai
|
TIMEZONE=Asia/Shanghai
|
||||||
# 请求超时时间(秒)
|
# 请求超时时间(秒)
|
||||||
TIME_OUT=300
|
TIME_OUT=300
|
||||||
|
# 代理服务器配置 (支持 http 和 socks5)
|
||||||
|
# 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"]
|
||||||
|
PROXIES=[]
|
||||||
#########################image_generate 相关配置###########################
|
#########################image_generate 相关配置###########################
|
||||||
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
|
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
|
||||||
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
|
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
|
||||||
@@ -43,4 +49,20 @@ STREAM_CHUNK_SIZE=5
|
|||||||
######################### 日志配置 #######################################
|
######################### 日志配置 #######################################
|
||||||
# 日志级别 (debug, info, warning, error, critical),默认为 info
|
# 日志级别 (debug, info, warning, error, critical),默认为 info
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
# 是否开启自动删除错误日志
|
||||||
|
AUTO_DELETE_ERROR_LOGS_ENABLED=true
|
||||||
|
# 自动删除多少天前的错误日志 (1, 7, 30)
|
||||||
|
AUTO_DELETE_ERROR_LOGS_DAYS=7
|
||||||
|
# 是否开启自动删除请求日志
|
||||||
|
AUTO_DELETE_REQUEST_LOGS_ENABLED=false
|
||||||
|
# 自动删除多少天前的请求日志 (1, 7, 30)
|
||||||
|
AUTO_DELETE_REQUEST_LOGS_DAYS=30
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
# 假流式配置 (Fake Streaming Configuration)
|
||||||
|
FAKE_STREAM_ENABLED=True # 是否启用假流式输出
|
||||||
|
FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS=5 # 假流式发送空数据的间隔时间(秒)
|
||||||
|
|
||||||
|
# 安全设置 (JSON 字符串格式)
|
||||||
|
# 注意:这里的示例值可能需要根据实际模型支持情况调整
|
||||||
|
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"}]'
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -257,4 +257,5 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
|
default_db
|
||||||
53
README.md
53
README.md
@@ -67,6 +67,7 @@ app/
|
|||||||
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
|
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||||
* **模型列表自动维护**: 支持openai和gemini模型列表获取,与newapi自动获取模型列表完美兼容,无需手动填写。
|
* **模型列表自动维护**: 支持openai和gemini模型列表获取,与newapi自动获取模型列表完美兼容,无需手动填写。
|
||||||
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
|
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
|
||||||
|
* **代理支持**: 支持配置 HTTP/SOCKS5 代理服务器 (`PROXIES`),用于访问 Gemini API,方便在特殊网络环境下使用。支持批量添加代理。
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
@@ -90,6 +91,12 @@ app/
|
|||||||
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
|
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
|
||||||
* `--env-file .env`: 使用 `.env` 文件设置环境变量。
|
* `--env-file .env`: 使用 `.env` 文件设置环境变量。
|
||||||
|
|
||||||
|
> 注意:如果使用 SQLite 数据库,需要挂载数据卷以持久化数据:
|
||||||
|
> ```bash
|
||||||
|
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data gemini-balance
|
||||||
|
> ```
|
||||||
|
> 其中 `/path/to/data` 是主机上的数据存储路径,`/app/data` 是容器内的数据目录。
|
||||||
|
|
||||||
#### b) 用现有的docker镜像部署
|
#### b) 用现有的docker镜像部署
|
||||||
|
|
||||||
1. **拉取镜像**:
|
1. **拉取镜像**:
|
||||||
@@ -108,6 +115,12 @@ app/
|
|||||||
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口 (根据需要调整)。
|
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口 (根据需要调整)。
|
||||||
* `--env-file .env`: 使用 `.env` 文件设置环境变量 (确保 `.env` 文件存在于执行命令的目录)。
|
* `--env-file .env`: 使用 `.env` 文件设置环境变量 (确保 `.env` 文件存在于执行命令的目录)。
|
||||||
|
|
||||||
|
> 注意:如果使用 SQLite 数据库,需要挂载数据卷以持久化数据:
|
||||||
|
> ```bash
|
||||||
|
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data ghcr.io/snailyp/gemini-balance:latest
|
||||||
|
> ```
|
||||||
|
> 其中 `/path/to/data` 是主机上的数据存储路径,`/app/data` 是容器内的数据目录。
|
||||||
|
|
||||||
### 本地运行 (适用于开发和测试)
|
### 本地运行 (适用于开发和测试)
|
||||||
|
|
||||||
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作:
|
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作:
|
||||||
@@ -115,7 +128,7 @@ app/
|
|||||||
1. **确保已完成准备工作**:
|
1. **确保已完成准备工作**:
|
||||||
* 克隆仓库到本地。
|
* 克隆仓库到本地。
|
||||||
* 安装 Python 3.9 或更高版本。
|
* 安装 Python 3.9 或更高版本。
|
||||||
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的“配置环境变量”部分)。
|
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的"配置环境变量"部分)。
|
||||||
* 安装项目依赖:
|
* 安装项目依赖:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -142,15 +155,18 @@ app/
|
|||||||
| 配置项 | 说明 | 默认值 |
|
| 配置项 | 说明 | 默认值 |
|
||||||
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
|
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
|
||||||
| **数据库配置** | | |
|
| **数据库配置** | | |
|
||||||
| `MYSQL_HOST` | 必填,MySQL 数据库主机地址 | `localhost` |
|
| `DATABASE_TYPE` | 可选,数据库类型,支持 `mysql` 或 `sqlite` | `mysql` |
|
||||||
| `MYSQL_PORT` | 必填,MySQL 数据库端口 | `3306` |
|
| `SQLITE_DATABASE` | 可选,当使用 `sqlite` 时必填,SQLite 数据库文件路径 | `default_db` |
|
||||||
| `MYSQL_USER` | 必填,MySQL 数据库用户名 | `your_db_user` |
|
| `MYSQL_HOST` | 当使用 `mysql` 时必填,MySQL 数据库主机地址 | `localhost` |
|
||||||
| `MYSQL_PASSWORD` | 必填,MySQL 数据库密码 | `your_db_password` |
|
| `MYSQL_SOCKET` | 可选,MySQL 数据库 socket 地址 | `/var/run/mysqld/mysqld.sock` |
|
||||||
| `MYSQL_DATABASE` | 必填,MySQL 数据库名称 | `defaultdb` |
|
| `MYSQL_PORT` | 当使用 `mysql` 时必填,MySQL 数据库端口 | `3306` |
|
||||||
|
| `MYSQL_USER` | 当使用 `mysql` 时必填,MySQL 数据库用户名 | `your_db_user` |
|
||||||
|
| `MYSQL_PASSWORD` | 当使用 `mysql` 时必填,MySQL 数据库密码 | `your_db_password` |
|
||||||
|
| `MYSQL_DATABASE` | 当使用 `mysql` 时必填,MySQL 数据库名称 | `defaultdb` |
|
||||||
| **API 相关配置** | | |
|
| **API 相关配置** | | |
|
||||||
| `API_KEYS` | 必填,Gemini API 密钥列表,用于负载均衡 | `["your-gemini-api-key-1", "your-gemini-api-key-2"]` |
|
| `API_KEYS` | 必填,Gemini API 密钥列表,用于负载均衡 | `["your-gemini-api-key-1", "your-gemini-api-key-2"]` |
|
||||||
| `ALLOWED_TOKENS` | 必填,允许访问的 Token 列表 | `["your-access-token-1", "your-access-token-2"]` |
|
| `ALLOWED_TOKENS` | 必填,允许访问的 Token 列表 | `["your-access-token-1", "your-access-token-2"]` |
|
||||||
| `AUTH_TOKEN` | 可选,超级管理员token,具有所有权限,不填默认使用 ALLOWED_TOKENS 的第一个 | `""` |
|
| `AUTH_TOKEN` | 可选,超级管理员token,具有所有权限,不填默认使用 ALLOWED_TOKENS 的第一个 | `sk-123456` |
|
||||||
| `TEST_MODEL` | 可选,用于测试密钥是否可用的模型名 | `gemini-1.5-flash` |
|
| `TEST_MODEL` | 可选,用于测试密钥是否可用的模型名 | `gemini-1.5-flash` |
|
||||||
| `IMAGE_MODELS` | 可选,支持绘图功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
| `IMAGE_MODELS` | 可选,支持绘图功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||||
| `SEARCH_MODELS` | 可选,支持搜索功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
| `SEARCH_MODELS` | 可选,支持搜索功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||||
@@ -166,7 +182,13 @@ app/
|
|||||||
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
|
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
|
||||||
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
|
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
|
||||||
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
|
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
|
||||||
|
| `PROXIES` | 可选,代理服务器列表 (例如 `http://user:pass@host:port`, `socks5://host:port`) | `[]` |
|
||||||
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
|
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
|
||||||
|
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | 可选,是否开启自动删除错误日志 | `true` |
|
||||||
|
| `AUTO_DELETE_ERROR_LOGS_DAYS` | 可选,自动删除多少天前的错误日志 (例如 1, 7, 30) | `7` |
|
||||||
|
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 可选,是否开启自动删除请求日志 | `false` |
|
||||||
|
| `AUTO_DELETE_REQUEST_LOGS_DAYS` | 可选,自动删除多少天前的请求日志 (例如 1, 7, 30) | `30` |
|
||||||
|
| `SAFETY_SETTINGS` | 可选,安全设置 (JSON 字符串格式),用于配置内容安全阈值。示例值可能需要根据实际模型支持情况调整。 | `[{"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"}]` |
|
||||||
| **图像生成相关** | | |
|
| **图像生成相关** | | |
|
||||||
| `PAID_KEY` | 可选,付费版API Key,用于图片生成等高级功能 | `your-paid-api-key` |
|
| `PAID_KEY` | 可选,付费版API Key,用于图片生成等高级功能 | `your-paid-api-key` |
|
||||||
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
|
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
|
||||||
@@ -182,6 +204,9 @@ app/
|
|||||||
| `STREAM_SHORT_TEXT_THRESHOLD`| 可选,短文本阈值 | `10` |
|
| `STREAM_SHORT_TEXT_THRESHOLD`| 可选,短文本阈值 | `10` |
|
||||||
| `STREAM_LONG_TEXT_THRESHOLD` | 可选,长文本阈值 | `50` |
|
| `STREAM_LONG_TEXT_THRESHOLD` | 可选,长文本阈值 | `50` |
|
||||||
| `STREAM_CHUNK_SIZE` | 可选,流式输出块大小 | `5` |
|
| `STREAM_CHUNK_SIZE` | 可选,流式输出块大小 | `5` |
|
||||||
|
| **伪流式 (Fake Stream) 相关** | | |
|
||||||
|
| `FAKE_STREAM_ENABLED` | 可选,是否启用伪流式传输,用于不支持流式的模型或场景 | `false` |
|
||||||
|
| `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS` | 可选,伪流式传输时发送心跳空数据的间隔秒数 | `5` |
|
||||||
|
|
||||||
## ⚙️ API 端点
|
## ⚙️ API 端点
|
||||||
|
|
||||||
@@ -193,12 +218,16 @@ app/
|
|||||||
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
|
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
|
||||||
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
|
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
|
||||||
|
|
||||||
### OpenAI API 相关 (`(/hf)/v1`)
|
### OpenAI API 相关
|
||||||
|
|
||||||
* `GET /v1/models`: 列出可用的 OpenAI 模型。
|
* `GET (/hf)/v1/models`: 列出可用的模型 (底层用的gemini格式)。
|
||||||
* `POST /v1/chat/completions`: 通过 OpenAI API 进行聊天补全。
|
* `POST (/hf)/v1/chat/completions`: 进行聊天补全 (底层用的gemini格式, 支持流式传输)。
|
||||||
* `POST /v1/images/generations`: 通过 OpenAI API 生成图像。
|
* `POST (/hf)/v1/embeddings`: 创建文本嵌入 (底层用的gemini格式)。
|
||||||
* `POST /v1/embeddings`: 通过 OpenAI API 创建文本嵌入。
|
* `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格式)。
|
||||||
|
|
||||||
## 🤝 贡献
|
## 🤝 贡献
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
"""
|
"""
|
||||||
应用程序配置模块
|
应用程序配置模块
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
from typing import List, Any, Dict, Type
|
from typing import Any, Dict, List, Type
|
||||||
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError, ValidationInfo, field_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from sqlalchemy import insert, update, select
|
from sqlalchemy import insert, select, update
|
||||||
|
|
||||||
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.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
|
from app.log.logger import Logger
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
MYSQL_HOST: str
|
DATABASE_TYPE: str = "mysql" # sqlite 或 mysql
|
||||||
MYSQL_PORT: int
|
SQLITE_DATABASE: str = "default_db"
|
||||||
MYSQL_USER: str
|
MYSQL_HOST: str = ""
|
||||||
MYSQL_PASSWORD: str
|
MYSQL_PORT: int = 3306
|
||||||
MYSQL_DATABASE: str
|
MYSQL_USER: str = ""
|
||||||
|
MYSQL_PASSWORD: str = ""
|
||||||
|
MYSQL_DATABASE: str = ""
|
||||||
|
MYSQL_SOCKET: str = ""
|
||||||
|
|
||||||
|
# 验证 MySQL 配置
|
||||||
|
@field_validator(
|
||||||
|
"MYSQL_HOST", "MYSQL_PORT", "MYSQL_USER", "MYSQL_PASSWORD", "MYSQL_DATABASE"
|
||||||
|
)
|
||||||
|
def validate_mysql_config(cls, v: Any, info: ValidationInfo) -> Any:
|
||||||
|
if info.data.get("DATABASE_TYPE") == "mysql":
|
||||||
|
if v is None or v == "":
|
||||||
|
raise ValueError(
|
||||||
|
"MySQL configuration is required when DATABASE_TYPE is 'mysql'"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
# API相关配置
|
# API相关配置
|
||||||
API_KEYS: List[str]
|
API_KEYS: List[str]
|
||||||
ALLOWED_TOKENS: List[str]
|
ALLOWED_TOKENS: List[str]
|
||||||
@@ -30,7 +59,8 @@ class Settings(BaseSettings):
|
|||||||
TEST_MODEL: str = DEFAULT_MODEL
|
TEST_MODEL: str = DEFAULT_MODEL
|
||||||
TIME_OUT: int = DEFAULT_TIMEOUT
|
TIME_OUT: int = DEFAULT_TIMEOUT
|
||||||
MAX_RETRIES: int = MAX_RETRIES
|
MAX_RETRIES: int = MAX_RETRIES
|
||||||
|
PROXIES: List[str] = [] # 新增:代理服务器列表
|
||||||
|
|
||||||
# 模型相关配置
|
# 模型相关配置
|
||||||
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
||||||
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
||||||
@@ -38,9 +68,9 @@ class Settings(BaseSettings):
|
|||||||
TOOLS_CODE_EXECUTION_ENABLED: bool = False
|
TOOLS_CODE_EXECUTION_ENABLED: bool = False
|
||||||
SHOW_SEARCH_LINK: bool = True
|
SHOW_SEARCH_LINK: bool = True
|
||||||
SHOW_THINKING_PROCESS: bool = True
|
SHOW_THINKING_PROCESS: bool = True
|
||||||
THINKING_MODELS: List[str] = [] # 新增:用于思考过程的模型列表
|
THINKING_MODELS: List[str] = [] # 新增:用于思考过程的模型列表
|
||||||
THINKING_BUDGET_MAP: Dict[str, float] = {} # 新增:模型对应的预算映射
|
THINKING_BUDGET_MAP: Dict[str, float] = {} # 新增:模型对应的预算映射
|
||||||
|
|
||||||
# 图像生成相关配置
|
# 图像生成相关配置
|
||||||
PAID_KEY: str = ""
|
PAID_KEY: str = ""
|
||||||
CREATE_IMAGE_MODEL: str = DEFAULT_CREATE_IMAGE_MODEL
|
CREATE_IMAGE_MODEL: str = DEFAULT_CREATE_IMAGE_MODEL
|
||||||
@@ -49,7 +79,7 @@ class Settings(BaseSettings):
|
|||||||
PICGO_API_KEY: str = ""
|
PICGO_API_KEY: str = ""
|
||||||
CLOUDFLARE_IMGBED_URL: str = ""
|
CLOUDFLARE_IMGBED_URL: str = ""
|
||||||
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
|
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
|
||||||
|
|
||||||
# 流式输出优化器配置
|
# 流式输出优化器配置
|
||||||
STREAM_OPTIMIZER_ENABLED: bool = False
|
STREAM_OPTIMIZER_ENABLED: bool = False
|
||||||
STREAM_MIN_DELAY: float = DEFAULT_STREAM_MIN_DELAY
|
STREAM_MIN_DELAY: float = DEFAULT_STREAM_MIN_DELAY
|
||||||
@@ -58,16 +88,25 @@ class Settings(BaseSettings):
|
|||||||
STREAM_LONG_TEXT_THRESHOLD: int = DEFAULT_STREAM_LONG_TEXT_THRESHOLD
|
STREAM_LONG_TEXT_THRESHOLD: int = DEFAULT_STREAM_LONG_TEXT_THRESHOLD
|
||||||
STREAM_CHUNK_SIZE: int = DEFAULT_STREAM_CHUNK_SIZE
|
STREAM_CHUNK_SIZE: int = DEFAULT_STREAM_CHUNK_SIZE
|
||||||
|
|
||||||
|
# 假流式配置 (Fake Streaming Configuration)
|
||||||
|
FAKE_STREAM_ENABLED: bool = False # 是否启用假流式输出
|
||||||
|
FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS: int = 5 # 假流式发送空数据的间隔时间(秒)
|
||||||
|
|
||||||
# 调度器配置
|
# 调度器配置
|
||||||
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
|
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
|
||||||
TIMEZONE: str = "Asia/Shanghai" # 默认时区
|
TIMEZONE: str = "Asia/Shanghai" # 默认时区
|
||||||
|
|
||||||
# github
|
# github
|
||||||
GITHUB_REPO_OWNER: str = "snailyp"
|
GITHUB_REPO_OWNER: str = "snailyp"
|
||||||
GITHUB_REPO_NAME: str = "gemini-balance"
|
GITHUB_REPO_NAME: str = "gemini-balance"
|
||||||
|
|
||||||
# 日志配置
|
# 日志配置
|
||||||
LOG_LEVEL: str = "INFO" # 默认日志级别
|
LOG_LEVEL: str = "INFO" # 默认日志级别
|
||||||
|
AUTO_DELETE_ERROR_LOGS_ENABLED: bool = True # 是否开启自动删除错误日志
|
||||||
|
AUTO_DELETE_ERROR_LOGS_DAYS: int = 7 # 自动删除多少天前的错误日志 (1, 7, 30)
|
||||||
|
AUTO_DELETE_REQUEST_LOGS_ENABLED: bool = False # 是否开启自动删除请求日志
|
||||||
|
AUTO_DELETE_REQUEST_LOGS_DAYS: int = 30 # 自动删除多少天前的请求日志 (1, 7, 30)
|
||||||
|
SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS # 新增:安全设置
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@@ -75,13 +114,16 @@ class Settings(BaseSettings):
|
|||||||
if not self.AUTH_TOKEN and self.ALLOWED_TOKENS:
|
if not self.AUTH_TOKEN and self.ALLOWED_TOKENS:
|
||||||
self.AUTH_TOKEN = self.ALLOWED_TOKENS[0]
|
self.AUTH_TOKEN = self.ALLOWED_TOKENS[0]
|
||||||
|
|
||||||
|
|
||||||
# 创建全局配置实例
|
# 创建全局配置实例
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
||||||
"""尝试将数据库字符串值解析为目标 Python 类型"""
|
"""尝试将数据库字符串值解析为目标 Python 类型"""
|
||||||
from app.log.logger import get_config_logger # 函数内导入
|
from app.log.logger import get_config_logger # 函数内导入
|
||||||
logger = get_config_logger() # 函数内初始化
|
|
||||||
|
logger = get_config_logger() # 函数内初始化
|
||||||
try:
|
try:
|
||||||
# 处理 List[str]
|
# 处理 List[str]
|
||||||
if target_type == List[str]:
|
if target_type == List[str]:
|
||||||
@@ -90,9 +132,11 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
|||||||
if isinstance(parsed, list):
|
if isinstance(parsed, list):
|
||||||
return [str(item) for item in parsed]
|
return [str(item) for item in parsed]
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return [item.strip() for item in db_value.split(',') if item.strip()]
|
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.")
|
logger.warning(
|
||||||
return [item.strip() for item in db_value.split(',') if item.strip()]
|
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()]
|
||||||
# 处理 Dict[str, float]
|
# 处理 Dict[str, float]
|
||||||
elif target_type == Dict[str, float]:
|
elif target_type == Dict[str, float]:
|
||||||
parsed_dict = {}
|
parsed_dict = {}
|
||||||
@@ -102,27 +146,71 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
|||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
|
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}")
|
logger.warning(
|
||||||
|
f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}"
|
||||||
|
)
|
||||||
except (json.JSONDecodeError, ValueError, TypeError) as e1:
|
except (json.JSONDecodeError, ValueError, TypeError) as e1:
|
||||||
# Second attempt: try replacing single quotes if JSONDecodeError occurred
|
# Second attempt: try replacing single quotes if JSONDecodeError occurred
|
||||||
if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
|
if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
|
||||||
logger.warning(f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}")
|
logger.warning(
|
||||||
|
f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
corrected_db_value = db_value.replace("'", '"')
|
corrected_db_value = db_value.replace("'", '"')
|
||||||
parsed = json.loads(corrected_db_value)
|
parsed = json.loads(corrected_db_value)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
|
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Parsed DB value (after quote replacement) for key '{key}' is not a dictionary type. Value: {corrected_db_value}")
|
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:
|
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.")
|
logger.error(
|
||||||
|
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Log other errors (ValueError, TypeError) or JSON errors without single quotes
|
# 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.")
|
logger.error(
|
||||||
return parsed_dict # Return the parsed dict or an empty one if all attempts fail
|
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
|
# 处理 bool
|
||||||
elif target_type == bool:
|
elif target_type == bool:
|
||||||
return db_value.lower() in ('true', '1', 'yes', 'on')
|
return db_value.lower() in ("true", "1", "yes", "on")
|
||||||
# 处理 int
|
# 处理 int
|
||||||
elif target_type == int:
|
elif target_type == int:
|
||||||
return int(db_value)
|
return int(db_value)
|
||||||
@@ -133,8 +221,11 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
|||||||
else:
|
else:
|
||||||
return db_value
|
return db_value
|
||||||
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
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.")
|
logger.warning(
|
||||||
return db_value # 解析失败则返回原始字符串
|
f"Failed to parse db_value '{db_value}' for key '{key}' as type {target_type}: {e}. Using original string value."
|
||||||
|
)
|
||||||
|
return db_value # 解析失败则返回原始字符串
|
||||||
|
|
||||||
|
|
||||||
async def sync_initial_settings():
|
async def sync_initial_settings():
|
||||||
"""
|
"""
|
||||||
@@ -143,8 +234,9 @@ async def sync_initial_settings():
|
|||||||
2. 将数据库设置合并到内存 settings (数据库优先)。
|
2. 将数据库设置合并到内存 settings (数据库优先)。
|
||||||
3. 将最终的内存 settings 同步回数据库。
|
3. 将最终的内存 settings 同步回数据库。
|
||||||
"""
|
"""
|
||||||
from app.log.logger import get_config_logger # 函数内导入
|
from app.log.logger import get_config_logger # 函数内导入
|
||||||
logger = get_config_logger() # 函数内初始化
|
|
||||||
|
logger = get_config_logger() # 函数内初始化
|
||||||
# 延迟导入以避免循环依赖和确保数据库连接已初始化
|
# 延迟导入以避免循环依赖和确保数据库连接已初始化
|
||||||
from app.database.connection import database
|
from app.database.connection import database
|
||||||
from app.database.models import Settings as SettingsModel
|
from app.database.models import Settings as SettingsModel
|
||||||
@@ -157,7 +249,9 @@ async def sync_initial_settings():
|
|||||||
await database.connect()
|
await database.connect()
|
||||||
logger.info("Database connection established for initial sync.")
|
logger.info("Database connection established for initial sync.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to database for initial settings sync: {e}. Skipping sync.")
|
logger.error(
|
||||||
|
f"Failed to connect to database for initial settings sync: {e}. Skipping sync."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -166,18 +260,30 @@ async def sync_initial_settings():
|
|||||||
try:
|
try:
|
||||||
query = select(SettingsModel.key, SettingsModel.value)
|
query = select(SettingsModel.key, SettingsModel.value)
|
||||||
results = await database.fetch_all(query)
|
results = await database.fetch_all(query)
|
||||||
db_settings_raw = [{"key": row["key"], "value": row["value"]} for row in results]
|
db_settings_raw = [
|
||||||
|
{"key": row["key"], "value": row["value"]} for row in results
|
||||||
|
]
|
||||||
logger.info(f"Fetched {len(db_settings_raw)} settings from database.")
|
logger.info(f"Fetched {len(db_settings_raw)} settings from database.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch settings from database: {e}. Proceeding with environment/dotenv settings.")
|
logger.error(
|
||||||
|
f"Failed to fetch settings from database: {e}. Proceeding with environment/dotenv settings."
|
||||||
|
)
|
||||||
# 即使数据库读取失败,也要继续执行,确保基于 env/dotenv 的配置能同步到数据库
|
# 即使数据库读取失败,也要继续执行,确保基于 env/dotenv 的配置能同步到数据库
|
||||||
|
|
||||||
db_settings_map: Dict[str, str] = {s['key']: s['value'] for s in db_settings_raw}
|
db_settings_map: Dict[str, str] = {
|
||||||
|
s["key"]: s["value"] for s in db_settings_raw
|
||||||
|
}
|
||||||
|
|
||||||
# 2. 将数据库设置合并到内存 settings (数据库优先)
|
# 2. 将数据库设置合并到内存 settings (数据库优先)
|
||||||
updated_in_memory = False
|
updated_in_memory = False
|
||||||
|
|
||||||
for key, db_value in db_settings_map.items():
|
for key, db_value in db_settings_map.items():
|
||||||
|
if key == "DATABASE_TYPE":
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping update of '{key}' in memory from database. "
|
||||||
|
"This setting is controlled by environment/dotenv."
|
||||||
|
)
|
||||||
|
continue
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
target_type = Settings.__annotations__.get(key)
|
target_type = Settings.__annotations__.get(key)
|
||||||
if target_type:
|
if target_type:
|
||||||
@@ -190,35 +296,52 @@ async def sync_initial_settings():
|
|||||||
if parsed_db_value != memory_value:
|
if parsed_db_value != memory_value:
|
||||||
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
|
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
|
||||||
type_match = False
|
type_match = False
|
||||||
if target_type == List[str] and isinstance(parsed_db_value, list):
|
if target_type == List[str] and isinstance(
|
||||||
|
parsed_db_value, list
|
||||||
|
):
|
||||||
type_match = True
|
type_match = True
|
||||||
elif target_type == Dict[str, float] and isinstance(parsed_db_value, dict):
|
elif target_type == Dict[str, float] and isinstance(
|
||||||
|
parsed_db_value, dict
|
||||||
|
):
|
||||||
type_match = True
|
type_match = True
|
||||||
elif target_type not in (List[str], Dict[str, float]) and isinstance(parsed_db_value, target_type):
|
elif target_type not in (
|
||||||
|
List[str],
|
||||||
|
Dict[str, float],
|
||||||
|
) and isinstance(parsed_db_value, target_type):
|
||||||
type_match = True
|
type_match = True
|
||||||
|
|
||||||
if type_match:
|
if type_match:
|
||||||
setattr(settings, key, parsed_db_value)
|
setattr(settings, key, parsed_db_value)
|
||||||
logger.debug(f"Updated setting '{key}' in memory from database value ({target_type}).")
|
logger.debug(
|
||||||
|
f"Updated setting '{key}' in memory from database value ({target_type})."
|
||||||
|
)
|
||||||
updated_in_memory = True
|
updated_in_memory = True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update.")
|
logger.warning(
|
||||||
|
f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update."
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing database setting for key '{key}': {e}")
|
logger.error(
|
||||||
|
f"Error processing database setting for key '{key}': {e}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Database setting '{key}' not found in Settings model definition. Ignoring.")
|
logger.warning(
|
||||||
|
f"Database setting '{key}' not found in Settings model definition. Ignoring."
|
||||||
|
)
|
||||||
|
|
||||||
# 如果内存中有更新,重新验证 Pydantic 模型(可选但推荐)
|
# 如果内存中有更新,重新验证 Pydantic 模型(可选但推荐)
|
||||||
if updated_in_memory:
|
if updated_in_memory:
|
||||||
try:
|
try:
|
||||||
# 重新加载以确保类型转换和验证
|
# 重新加载以确保类型转换和验证
|
||||||
settings = Settings(**settings.model_dump())
|
settings = Settings(**settings.model_dump())
|
||||||
logger.info("Settings object re-validated after merging database values.")
|
logger.info(
|
||||||
|
"Settings object re-validated after merging database values."
|
||||||
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.error(f"Validation error after merging database settings: {e}. Settings might be inconsistent.")
|
logger.error(
|
||||||
|
f"Validation error after merging database settings: {e}. Settings might be inconsistent."
|
||||||
|
)
|
||||||
|
|
||||||
# 3. 将最终的内存 settings 同步回数据库
|
# 3. 将最终的内存 settings 同步回数据库
|
||||||
final_memory_settings = settings.model_dump()
|
final_memory_settings = settings.model_dump()
|
||||||
@@ -229,21 +352,30 @@ async def sync_initial_settings():
|
|||||||
existing_db_keys = set(db_settings_map.keys())
|
existing_db_keys = set(db_settings_map.keys())
|
||||||
|
|
||||||
for key, value in final_memory_settings.items():
|
for key, value in final_memory_settings.items():
|
||||||
|
if key == "DATABASE_TYPE":
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping synchronization of '{key}' to database. "
|
||||||
|
"This setting is controlled by environment/dotenv."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# 序列化值为字符串或 JSON 字符串
|
# 序列化值为字符串或 JSON 字符串
|
||||||
if isinstance(value, (list, dict)): # 处理列表和字典
|
if isinstance(value, (list, dict)): # 处理列表和字典
|
||||||
db_value = json.dumps(value, ensure_ascii=False) # 使用 ensure_ascii=False 以支持非 ASCII 字符
|
db_value = json.dumps(
|
||||||
|
value, ensure_ascii=False
|
||||||
|
) # 使用 ensure_ascii=False 以支持非 ASCII 字符
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
db_value = str(value).lower()
|
db_value = str(value).lower()
|
||||||
elif value is None: # 处理 None 值
|
elif value is None: # 处理 None 值
|
||||||
db_value = "" # 或者根据需要设为 NULL 或其他标记
|
db_value = "" # 或者根据需要设为 NULL 或其他标记
|
||||||
else:
|
else:
|
||||||
db_value = str(value)
|
db_value = str(value)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'key': key,
|
"key": key,
|
||||||
'value': db_value,
|
"value": db_value,
|
||||||
'description': f"{key} configuration setting", # 默认描述
|
"description": f"{key} configuration setting", # 默认描述
|
||||||
'updated_at': now
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if key in existing_db_keys:
|
if key in existing_db_keys:
|
||||||
@@ -252,7 +384,7 @@ async def sync_initial_settings():
|
|||||||
settings_to_update.append(data)
|
settings_to_update.append(data)
|
||||||
else:
|
else:
|
||||||
# 如果键不在数据库中,则插入
|
# 如果键不在数据库中,则插入
|
||||||
data['created_at'] = now
|
data["created_at"] = now
|
||||||
settings_to_insert.append(data)
|
settings_to_insert.append(data)
|
||||||
|
|
||||||
# 在事务中执行批量插入和更新
|
# 在事务中执行批量插入和更新
|
||||||
@@ -261,48 +393,78 @@ async def sync_initial_settings():
|
|||||||
async with database.transaction():
|
async with database.transaction():
|
||||||
if settings_to_insert:
|
if settings_to_insert:
|
||||||
# 获取现有描述以避免覆盖
|
# 获取现有描述以避免覆盖
|
||||||
query_existing = select(SettingsModel.key, SettingsModel.description).where(SettingsModel.key.in_([s['key'] for s in settings_to_insert]))
|
query_existing = select(
|
||||||
existing_desc = {row['key']: row['description'] for row in await database.fetch_all(query_existing)}
|
SettingsModel.key, SettingsModel.description
|
||||||
|
).where(
|
||||||
|
SettingsModel.key.in_(
|
||||||
|
[s["key"] for s in settings_to_insert]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_desc = {
|
||||||
|
row["key"]: row["description"]
|
||||||
|
for row in await database.fetch_all(query_existing)
|
||||||
|
}
|
||||||
for item in settings_to_insert:
|
for item in settings_to_insert:
|
||||||
item['description'] = existing_desc.get(item['key'], item['description'])
|
item["description"] = existing_desc.get(
|
||||||
|
item["key"], item["description"]
|
||||||
|
)
|
||||||
|
|
||||||
query_insert = insert(SettingsModel).values(settings_to_insert)
|
query_insert = insert(SettingsModel).values(settings_to_insert)
|
||||||
await database.execute(query=query_insert)
|
await database.execute(query=query_insert)
|
||||||
logger.info(f"Synced (inserted) {len(settings_to_insert)} settings to database.")
|
logger.info(
|
||||||
|
f"Synced (inserted) {len(settings_to_insert)} settings to database."
|
||||||
|
)
|
||||||
|
|
||||||
if settings_to_update:
|
if settings_to_update:
|
||||||
# 获取现有描述以避免覆盖
|
# 获取现有描述以避免覆盖
|
||||||
query_existing = select(SettingsModel.key, SettingsModel.description).where(SettingsModel.key.in_([s['key'] for s in settings_to_update]))
|
query_existing = select(
|
||||||
existing_desc = {row['key']: row['description'] for row in await database.fetch_all(query_existing)}
|
SettingsModel.key, SettingsModel.description
|
||||||
|
).where(
|
||||||
|
SettingsModel.key.in_(
|
||||||
|
[s["key"] for s in settings_to_update]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_desc = {
|
||||||
|
row["key"]: row["description"]
|
||||||
|
for row in await database.fetch_all(query_existing)
|
||||||
|
}
|
||||||
|
|
||||||
for setting_data in settings_to_update:
|
for setting_data in settings_to_update:
|
||||||
setting_data['description'] = existing_desc.get(setting_data['key'], setting_data['description'])
|
setting_data["description"] = existing_desc.get(
|
||||||
|
setting_data["key"], setting_data["description"]
|
||||||
|
)
|
||||||
query_update = (
|
query_update = (
|
||||||
update(SettingsModel)
|
update(SettingsModel)
|
||||||
.where(SettingsModel.key == setting_data['key'])
|
.where(SettingsModel.key == setting_data["key"])
|
||||||
.values(
|
.values(
|
||||||
value=setting_data['value'],
|
value=setting_data["value"],
|
||||||
description=setting_data['description'],
|
description=setting_data["description"],
|
||||||
updated_at=setting_data['updated_at']
|
updated_at=setting_data["updated_at"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await database.execute(query=query_update)
|
await database.execute(query=query_update)
|
||||||
logger.info(f"Synced (updated) {len(settings_to_update)} settings to database.")
|
logger.info(
|
||||||
|
f"Synced (updated) {len(settings_to_update)} settings to database."
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to sync settings to database during startup: {str(e)}")
|
logger.error(
|
||||||
|
f"Failed to sync settings to database during startup: {str(e)}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("No setting changes detected between memory and database during initial sync.")
|
logger.info(
|
||||||
|
"No setting changes detected between memory and database during initial sync."
|
||||||
|
)
|
||||||
|
|
||||||
# 刷新日志等级
|
# 刷新日志等级
|
||||||
Logger.update_log_levels(final_memory_settings.get("LOG_LEVEL"))
|
Logger.update_log_levels(final_memory_settings.get("LOG_LEVEL"))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
|
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
|
||||||
finally:
|
finally:
|
||||||
if database.is_connected:
|
if database.is_connected:
|
||||||
try:
|
try:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error disconnecting database after initial sync: {e}")
|
logger.error(f"Error disconnecting database after initial sync: {e}")
|
||||||
|
|
||||||
logger.info("Initial settings synchronization finished.")
|
logger.info("Initial settings synchronization finished.")
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ from app.exception.exceptions import setup_exception_handlers
|
|||||||
from app.router.routes import setup_routers
|
from app.router.routes import setup_routers
|
||||||
from app.service.key.key_manager import get_key_manager_instance
|
from app.service.key.key_manager import get_key_manager_instance
|
||||||
from app.database.connection import connect_to_db, disconnect_from_db
|
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.database.initialization import initialize_database
|
||||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler
|
||||||
from app.service.update.update_service import check_for_updates
|
from app.service.update.update_service import check_for_updates
|
||||||
|
|
||||||
logger = get_application_logger()
|
logger = get_application_logger()
|
||||||
@@ -20,28 +21,11 @@ logger = get_application_logger()
|
|||||||
# Define project paths using pathlib
|
# Define project paths using pathlib
|
||||||
# Assuming this file is at app/core/application.py
|
# Assuming this file is at app/core/application.py
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
|
# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py
|
||||||
STATIC_DIR = PROJECT_ROOT / "app" / "static"
|
STATIC_DIR = PROJECT_ROOT / "app" / "static"
|
||||||
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
|
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
|
||||||
|
|
||||||
|
# Removed _get_current_version function definition, moved to helpers.py
|
||||||
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
|
|
||||||
try:
|
|
||||||
# Use Path object's open method
|
|
||||||
with version_file.open('r', encoding='utf-8') as f:
|
|
||||||
version = f.read().strip()
|
|
||||||
if not version:
|
|
||||||
logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
|
|
||||||
return default_version
|
|
||||||
return version
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
|
|
||||||
return default_version
|
|
||||||
except IOError as e:
|
|
||||||
logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
|
|
||||||
return default_version
|
|
||||||
|
|
||||||
# 初始化模板引擎,并添加全局变量
|
# 初始化模板引擎,并添加全局变量
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
@@ -70,7 +54,6 @@ async def _setup_database_and_config(app_settings):
|
|||||||
async def _shutdown_database():
|
async def _shutdown_database():
|
||||||
"""Disconnects from the database."""
|
"""Disconnects from the database."""
|
||||||
await disconnect_from_db()
|
await disconnect_from_db()
|
||||||
logger.info("Disconnected from database.")
|
|
||||||
|
|
||||||
def _start_scheduler():
|
def _start_scheduler():
|
||||||
"""Starts the background scheduler."""
|
"""Starts the background scheduler."""
|
||||||
@@ -83,12 +66,11 @@ def _start_scheduler():
|
|||||||
def _stop_scheduler():
|
def _stop_scheduler():
|
||||||
"""Stops the background scheduler."""
|
"""Stops the background scheduler."""
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
logger.info("Scheduler stopped.")
|
|
||||||
|
|
||||||
async def _perform_update_check(app: FastAPI):
|
async def _perform_update_check(app: FastAPI):
|
||||||
"""Checks for updates and stores the info in app.state."""
|
"""Checks for updates and stores the info in app.state."""
|
||||||
update_available, latest_version, error_message = await check_for_updates()
|
update_available, latest_version, error_message = await check_for_updates()
|
||||||
current_version = _get_current_version() # Read from VERSION file
|
current_version = get_current_version() # Use imported function
|
||||||
update_info = {
|
update_info = {
|
||||||
"update_available": update_available,
|
"update_available": update_available,
|
||||||
"latest_version": latest_version,
|
"latest_version": latest_version,
|
||||||
@@ -119,7 +101,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await _setup_database_and_config(settings) # Pass settings object
|
await _setup_database_and_config(settings) # Pass settings object
|
||||||
|
|
||||||
# Perform update check after core components are ready
|
# Perform update check after core components are ready
|
||||||
await _perform_update_check(app)
|
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
|
||||||
|
|
||||||
# Start the scheduler
|
# Start the scheduler
|
||||||
_start_scheduler()
|
_start_scheduler()
|
||||||
@@ -148,7 +130,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# 创建FastAPI应用
|
# 创建FastAPI应用
|
||||||
# Read version from file for consistency
|
# Read version from file for consistency
|
||||||
current_version = _get_current_version()
|
current_version = get_current_version() # Use imported function
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Gemini Balance API",
|
title="Gemini Balance API",
|
||||||
description="Gemini API代理服务,支持负载均衡和密钥管理",
|
description="Gemini API代理服务,支持负载均衡和密钥管理",
|
||||||
|
|||||||
@@ -40,3 +40,40 @@ DEFAULT_STREAM_CHUNK_SIZE = 5
|
|||||||
# 正则表达式模式
|
# 正则表达式模式
|
||||||
IMAGE_URL_PATTERN = r'!\[(.*?)\]\((.*?)\)'
|
IMAGE_URL_PATTERN = r'!\[(.*?)\]\((.*?)\)'
|
||||||
DATA_URL_PATTERN = r'data:([^;]+);base64,(.+)'
|
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,8 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
数据库连接池模块
|
数据库连接池模块
|
||||||
"""
|
"""
|
||||||
|
from pathlib import Path
|
||||||
from databases import Database
|
from databases import Database
|
||||||
from sqlalchemy import create_engine, MetaData
|
from sqlalchemy import create_engine, MetaData
|
||||||
|
# from sqlalchemy.orm import sessionmaker # 不再需要
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
@@ -11,7 +13,19 @@ from app.log.logger import get_database_logger
|
|||||||
logger = get_database_logger()
|
logger = get_database_logger()
|
||||||
|
|
||||||
# 数据库URL
|
# 数据库URL
|
||||||
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
|
if settings.DATABASE_TYPE == "sqlite":
|
||||||
|
# 确保 data 目录存在
|
||||||
|
data_dir = Path("data")
|
||||||
|
data_dir.mkdir(exist_ok=True)
|
||||||
|
db_path = data_dir / settings.SQLITE_DATABASE
|
||||||
|
DATABASE_URL = f"sqlite:///{db_path}"
|
||||||
|
elif settings.DATABASE_TYPE == "mysql":
|
||||||
|
if settings.MYSQL_SOCKET:
|
||||||
|
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@/{settings.MYSQL_DATABASE}?unix_socket={settings.MYSQL_SOCKET}"
|
||||||
|
else:
|
||||||
|
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
|
||||||
|
else:
|
||||||
|
raise ValueError("Unsupported database type. Please set DATABASE_TYPE to 'sqlite' or 'mysql'.")
|
||||||
|
|
||||||
# 创建数据库引擎
|
# 创建数据库引擎
|
||||||
# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效
|
# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效
|
||||||
@@ -23,22 +37,27 @@ metadata = MetaData()
|
|||||||
# 创建基类
|
# 创建基类
|
||||||
Base = declarative_base(metadata=metadata)
|
Base = declarative_base(metadata=metadata)
|
||||||
|
|
||||||
# 创建数据库连接池,并配置连接池参数
|
# 创建数据库连接池,并配置连接池参数,在sqlite中不使用连接池
|
||||||
# min_size/max_size: 连接池的最小/最大连接数
|
# min_size/max_size: 连接池的最小/最大连接数
|
||||||
# pool_recycle=3600: 连接在池中允许存在的最大秒数(生命周期)。
|
# pool_recycle=3600: 连接在池中允许存在的最大秒数(生命周期)。
|
||||||
# 设置为 3600 秒(1小时),确保在 MySQL 默认的 wait_timeout (通常8小时) 或其他网络超时之前回收连接。
|
# 设置为 3600 秒(1小时),确保在 MySQL 默认的 wait_timeout (通常8小时) 或其他网络超时之前回收连接。
|
||||||
# 如果遇到连接失效问题,可以尝试调低此值,使其小于实际的 wait_timeout 或网络超时时间。
|
# 如果遇到连接失效问题,可以尝试调低此值,使其小于实际的 wait_timeout 或网络超时时间。
|
||||||
# databases 库会自动处理连接失效后的重连尝试。
|
# databases 库会自动处理连接失效后的重连尝试。
|
||||||
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins
|
if settings.DATABASE_TYPE == "sqlite":
|
||||||
|
database = Database(DATABASE_URL)
|
||||||
|
else:
|
||||||
|
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():
|
async def connect_to_db():
|
||||||
"""
|
"""
|
||||||
连接到数据库
|
连接到数据库
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await database.connect()
|
await database.connect()
|
||||||
logger.info("Connected to database")
|
logger.info(f"Connected to {settings.DATABASE_TYPE}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to database: {str(e)}")
|
logger.error(f"Failed to connect to database: {str(e)}")
|
||||||
raise
|
raise
|
||||||
@@ -50,6 +69,6 @@ async def disconnect_from_db():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await database.disconnect()
|
await database.disconnect()
|
||||||
logger.info("Disconnected from database")
|
logger.info(f"Disconnected from {settings.DATABASE_TYPE}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to disconnect from database: {str(e)}")
|
logger.error(f"Failed to disconnect from database: {str(e)}")
|
||||||
|
|||||||
@@ -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
|
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.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
|
from app.log.logger import get_database_logger
|
||||||
|
|
||||||
logger = get_database_logger()
|
logger = get_database_logger()
|
||||||
@@ -157,19 +155,25 @@ async def get_error_logs(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
key_search: Optional[str] = None,
|
key_search: Optional[str] = None,
|
||||||
error_search: Optional[str] = None,
|
error_search: Optional[str] = None,
|
||||||
|
error_code_search: Optional[str] = None,
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None
|
end_date: Optional[datetime] = None,
|
||||||
|
sort_by: str = 'id', # 新增排序字段
|
||||||
|
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取错误日志,支持搜索和日期过滤
|
获取错误日志,支持搜索、日期过滤和排序
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit (int): 限制数量
|
limit (int): 限制数量
|
||||||
offset (int): 偏移量
|
offset (int): 偏移量
|
||||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||||
|
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||||
start_date (Optional[datetime]): 开始日期时间
|
start_date (Optional[datetime]): 开始日期时间
|
||||||
end_date (Optional[datetime]): 结束日期时间
|
end_date (Optional[datetime]): 结束日期时间
|
||||||
|
sort_by (str): 排序字段 (例如 'id', 'request_time')
|
||||||
|
sort_order (str): 排序顺序 ('asc' or 'desc')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[Dict[str, Any]]: 错误日志列表
|
List[Dict[str, Any]]: 错误日志列表
|
||||||
@@ -198,10 +202,28 @@ async def get_error_logs(
|
|||||||
if end_date:
|
if end_date:
|
||||||
# Use the datetime object directly for comparison
|
# Use the datetime object directly for comparison
|
||||||
query = query.where(ErrorLog.request_time < end_date)
|
query = query.where(ErrorLog.request_time < end_date)
|
||||||
|
if error_code_search:
|
||||||
|
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.id.desc()).limit(limit).offset(offset)
|
|
||||||
|
|
||||||
result = await database.fetch_all(query)
|
result = await database.fetch_all(query)
|
||||||
return [dict(row) for row in result]
|
return [dict(row) for row in result]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -212,6 +234,7 @@ async def get_error_logs(
|
|||||||
async def get_error_logs_count(
|
async def get_error_logs_count(
|
||||||
key_search: Optional[str] = None,
|
key_search: Optional[str] = None,
|
||||||
error_search: Optional[str] = None,
|
error_search: Optional[str] = None,
|
||||||
|
error_code_search: Optional[str] = None, # Added error code search
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None
|
end_date: Optional[datetime] = None
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -221,6 +244,7 @@ async def get_error_logs_count(
|
|||||||
Args:
|
Args:
|
||||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||||
|
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||||
start_date (Optional[datetime]): 开始日期时间
|
start_date (Optional[datetime]): 开始日期时间
|
||||||
end_date (Optional[datetime]): 结束日期时间
|
end_date (Optional[datetime]): 结束日期时间
|
||||||
|
|
||||||
@@ -243,6 +267,16 @@ async def get_error_logs_count(
|
|||||||
if end_date:
|
if end_date:
|
||||||
# Use the datetime object directly for comparison
|
# Use the datetime object directly for comparison
|
||||||
query = query.where(ErrorLog.request_time < end_date)
|
query = query.where(ErrorLog.request_time < end_date)
|
||||||
|
if error_code_search:
|
||||||
|
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)
|
count_result = await database.fetch_one(query)
|
||||||
return count_result[0] if count_result else 0
|
return count_result[0] if count_result else 0
|
||||||
@@ -281,6 +315,68 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
|||||||
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# --- 异步删除函数 (使用 databases 库) ---
|
||||||
|
|
||||||
|
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||||
|
"""
|
||||||
|
根据提供的 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(
|
async def add_request_log(
|
||||||
model_name: Optional[str],
|
model_name: Optional[str],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
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
|
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
|
model: str = DEFAULT_MODEL
|
||||||
temperature: Optional[float] = DEFAULT_TEMPERATURE
|
temperature: Optional[float] = DEFAULT_TEMPERATURE
|
||||||
stream: Optional[bool] = False
|
stream: Optional[bool] = False
|
||||||
tools: Optional[List[dict]] = []
|
|
||||||
max_tokens: Optional[int] = None
|
max_tokens: Optional[int] = None
|
||||||
top_p: Optional[float] = DEFAULT_TOP_P
|
top_p: Optional[float] = DEFAULT_TOP_P
|
||||||
top_k: Optional[int] = DEFAULT_TOP_K
|
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):
|
class EmbeddingRequest(BaseModel):
|
||||||
@@ -23,10 +26,10 @@ class EmbeddingRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ImageGenerationRequest(BaseModel):
|
class ImageGenerationRequest(BaseModel):
|
||||||
model: str = "DALL-E-3"
|
model: str = "imagen-3.0-generate-002"
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
n: int = 1
|
n: int = 1
|
||||||
size: Optional[str] = "1024x1024"
|
size: Optional[str] = "1024x1024"
|
||||||
quality: Optional[str] = ""
|
quality: Optional[str] = None
|
||||||
style: Optional[str] = ""
|
style: Optional[str] = None
|
||||||
response_format: Optional[str] = "url"
|
response_format: Optional[str] = "url"
|
||||||
|
|||||||
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,61 +1,70 @@
|
|||||||
|
import base64
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Dict, List, Optional
|
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):
|
class MessageConverter(ABC):
|
||||||
"""消息转换器基类"""
|
"""消息转换器基类"""
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get_mime_type_and_data(base64_string):
|
def _get_mime_type_and_data(base64_string):
|
||||||
"""
|
"""
|
||||||
从 base64 字符串中提取 MIME 类型和数据。
|
从 base64 字符串中提取 MIME 类型和数据。
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
base64_string (str): 可能包含 MIME 类型信息的 base64 字符串
|
base64_string (str): 可能包含 MIME 类型信息的 base64 字符串
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
tuple: (mime_type, encoded_data)
|
tuple: (mime_type, encoded_data)
|
||||||
"""
|
"""
|
||||||
# 检查字符串是否以 "data:" 格式开始
|
# 检查字符串是否以 "data:" 格式开始
|
||||||
if base64_string.startswith('data:'):
|
if base64_string.startswith("data:"):
|
||||||
# 提取 MIME 类型和数据
|
# 提取 MIME 类型和数据
|
||||||
pattern = DATA_URL_PATTERN
|
pattern = DATA_URL_PATTERN
|
||||||
match = re.match(pattern, base64_string)
|
match = re.match(pattern, base64_string)
|
||||||
if match:
|
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)
|
encoded_data = match.group(2)
|
||||||
return mime_type, encoded_data
|
return mime_type, encoded_data
|
||||||
|
|
||||||
# 如果不是预期格式,假定它只是数据部分
|
# 如果不是预期格式,假定它只是数据部分
|
||||||
return None, base64_string
|
return None, base64_string
|
||||||
|
|
||||||
|
|
||||||
def _convert_image(image_url: str) -> Dict[str, Any]:
|
def _convert_image(image_url: str) -> Dict[str, Any]:
|
||||||
if image_url.startswith("data:image"):
|
if image_url.startswith("data:image"):
|
||||||
mime_type, encoded_data = _get_mime_type_and_data(image_url)
|
mime_type, encoded_data = _get_mime_type_and_data(image_url)
|
||||||
return {
|
return {"inline_data": {"mime_type": mime_type, "data": encoded_data}}
|
||||||
"inline_data": {
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"data": encoded_data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
encoded_data = _convert_image_to_base64(image_url)
|
encoded_data = _convert_image_to_base64(image_url)
|
||||||
return {
|
return {"inline_data": {"mime_type": "image/png", "data": encoded_data}}
|
||||||
"inline_data": {
|
|
||||||
"mime_type": "image/png",
|
|
||||||
"data": encoded_data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_image_to_base64(url: str) -> str:
|
def _convert_image_to_base64(url: str) -> str:
|
||||||
@@ -69,7 +78,7 @@ def _convert_image_to_base64(url: str) -> str:
|
|||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# 将图片内容转换为base64
|
# 将图片内容转换为base64
|
||||||
img_data = base64.b64encode(response.content).decode('utf-8')
|
img_data = base64.b64encode(response.content).decode("utf-8")
|
||||||
return img_data
|
return img_data
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Failed to fetch image: {response.status_code}")
|
raise Exception(f"Failed to fetch image: {response.status_code}")
|
||||||
@@ -93,12 +102,9 @@ def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
|||||||
# 将URL对应的图片转换为base64
|
# 将URL对应的图片转换为base64
|
||||||
try:
|
try:
|
||||||
base64_data = _convert_image_to_base64(img_url)
|
base64_data = _convert_image_to_base64(img_url)
|
||||||
parts.append({
|
parts.append(
|
||||||
"inlineData": {
|
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
|
||||||
"mimeType": "image/png",
|
)
|
||||||
"data": base64_data
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# 如果转换失败,回退到文本模式
|
# 如果转换失败,回退到文本模式
|
||||||
parts.append({"text": text})
|
parts.append({"text": text})
|
||||||
@@ -111,42 +117,215 @@ def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
|||||||
class OpenAIMessageConverter(MessageConverter):
|
class OpenAIMessageConverter(MessageConverter):
|
||||||
"""OpenAI消息格式转换器"""
|
"""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 = []
|
converted_messages = []
|
||||||
system_instruction_parts = []
|
system_instruction_parts = []
|
||||||
|
|
||||||
for idx, msg in enumerate(messages):
|
for idx, msg in enumerate(messages):
|
||||||
role = msg.get("role", "")
|
role = msg.get("role", "")
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
# 特别处理最后一个assistant的消息,按\n\n分割
|
|
||||||
if "content" in msg and isinstance(msg["content"], str) and msg["content"] and role == "assistant" and idx == len(messages) - 2:
|
if "content" in msg and isinstance(msg["content"], list):
|
||||||
# 按\n\n分割消息
|
for content_item in msg["content"]:
|
||||||
content_parts = msg["content"].split("\n\n")
|
if not isinstance(content_item, dict):
|
||||||
for part in content_parts:
|
# Skip non-dict items if any unexpected format appears
|
||||||
if not part.strip(): # 跳过空内容
|
logger.warning(
|
||||||
|
f"Skipping unexpected content item format: {type(content_item)}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
# 处理可能包含图片的文本
|
|
||||||
parts.extend(_process_text_with_image(part))
|
content_type = content_item.get("type")
|
||||||
elif "content" in msg and isinstance(msg["content"], str) and msg["content"]:
|
|
||||||
# 请求 gemini 接口时如果包含 content 字段但内容为空时会返回 400 错误,所以需要判断是否为空并移除
|
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"]))
|
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):
|
elif "tool_calls" in msg and isinstance(msg["tool_calls"], list):
|
||||||
|
# Keep existing tool call processing
|
||||||
for tool_call in msg["tool_calls"]:
|
for tool_call in msg["tool_calls"]:
|
||||||
function_call = tool_call.get("function",{})
|
function_call = tool_call.get("function", {})
|
||||||
function_call["args"] = json.loads(function_call.get("arguments","{}"))
|
# Sanitize arguments loading
|
||||||
del function_call["arguments"]
|
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})
|
parts.append({"functionCall": function_call})
|
||||||
|
|
||||||
if role not in SUPPORTED_ROLES:
|
if role not in SUPPORTED_ROLES:
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
role = "user"
|
role = "user"
|
||||||
@@ -158,7 +337,14 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
role = "model"
|
role = "model"
|
||||||
if parts:
|
if parts:
|
||||||
if role == "system":
|
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:
|
else:
|
||||||
converted_messages.append({"role": role, "parts": parts})
|
converted_messages.append({"role": role, "parts": parts})
|
||||||
|
|
||||||
@@ -170,4 +356,4 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
"parts": system_instruction_parts,
|
"parts": system_instruction_parts,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return converted_messages, system_instruction
|
return converted_messages, system_instruction
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Dict, Any, List, Optional
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.utils.uploader import ImageUploaderFactory
|
from app.utils.uploader import ImageUploaderFactory
|
||||||
|
|
||||||
@@ -15,7 +15,9 @@ class ResponseHandler(ABC):
|
|||||||
"""响应处理器基类"""
|
"""响应处理器基类"""
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -26,32 +28,44 @@ class GeminiResponseHandler(ResponseHandler):
|
|||||||
self.thinking_first = True
|
self.thinking_first = True
|
||||||
self.thinking_status = False
|
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, usage_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
if stream:
|
if stream:
|
||||||
return _handle_gemini_stream_response(response, model, stream)
|
return _handle_gemini_stream_response(response, model, stream)
|
||||||
return _handle_gemini_normal_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]:
|
def _handle_openai_stream_response(
|
||||||
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
|
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
text, tool_calls = _extract_result(
|
||||||
|
response, model, stream=True, gemini_format=False
|
||||||
|
)
|
||||||
if not text and not tool_calls:
|
if not text and not tool_calls:
|
||||||
delta = {}
|
delta = {}
|
||||||
else:
|
else:
|
||||||
delta = {"content": text, "role": "assistant"}
|
delta = {"content": text, "role": "assistant"}
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
delta["tool_calls"] = tool_calls
|
delta["tool_calls"] = tool_calls
|
||||||
|
template_chunk = {
|
||||||
return {
|
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
||||||
}
|
}
|
||||||
|
if usage_metadata:
|
||||||
|
template_chunk["usage"] = {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)}
|
||||||
|
return template_chunk
|
||||||
|
|
||||||
|
|
||||||
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
def _handle_openai_normal_response(
|
||||||
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
|
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
text, tool_calls = _extract_result(
|
||||||
|
response, model, stream=False, gemini_format=False
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
@@ -60,11 +74,15 @@ def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_
|
|||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": text,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
},
|
||||||
"finish_reason": finish_reason,
|
"finish_reason": finish_reason,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
"usage": {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -77,59 +95,68 @@ class OpenAIResponseHandler(ResponseHandler):
|
|||||||
self.thinking_status = False
|
self.thinking_status = False
|
||||||
|
|
||||||
def handle_response(
|
def handle_response(
|
||||||
self,
|
self,
|
||||||
response: Dict[str, Any],
|
response: Dict[str, Any],
|
||||||
model: str,
|
model: str,
|
||||||
stream: bool = False,
|
stream: bool = False,
|
||||||
finish_reason: str = None
|
finish_reason: str = None,
|
||||||
|
usage_metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if stream:
|
if stream:
|
||||||
return _handle_openai_stream_response(response, model, finish_reason)
|
return _handle_openai_stream_response(response, model, finish_reason, usage_metadata)
|
||||||
return _handle_openai_normal_response(response, model, finish_reason)
|
return _handle_openai_normal_response(response, model, finish_reason, usage_metadata)
|
||||||
|
|
||||||
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:
|
if stream:
|
||||||
return _handle_openai_stream_image_response(image_str,model,finish_reason)
|
return _handle_openai_stream_image_response(image_str, model, finish_reason)
|
||||||
return _handle_openai_normal_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]:
|
def _handle_openai_stream_image_response(
|
||||||
|
image_str: str, model: str, finish_reason: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [{
|
"choices": [
|
||||||
"index": 0,
|
{
|
||||||
"delta": {"content": image_str} if image_str else {},
|
"index": 0,
|
||||||
"finish_reason": finish_reason
|
"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 {
|
return {
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [{
|
"choices": [
|
||||||
"index": 0,
|
{
|
||||||
"message": {
|
"index": 0,
|
||||||
"role": "assistant",
|
"message": {"role": "assistant", "content": image_str},
|
||||||
"content": image_str
|
"finish_reason": finish_reason,
|
||||||
},
|
}
|
||||||
"finish_reason": finish_reason
|
],
|
||||||
}],
|
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||||
"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 = "", []
|
text, tool_calls = "", []
|
||||||
if stream:
|
if stream:
|
||||||
if response.get("candidates"):
|
if response.get("candidates"):
|
||||||
@@ -145,13 +172,9 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
|
|||||||
elif "codeExecution" in parts[0]:
|
elif "codeExecution" in parts[0]:
|
||||||
text = _format_code_block(parts[0]["codeExecution"])
|
text = _format_code_block(parts[0]["codeExecution"])
|
||||||
elif "executableCodeResult" in parts[0]:
|
elif "executableCodeResult" in parts[0]:
|
||||||
text = _format_execution_result(
|
text = _format_execution_result(parts[0]["executableCodeResult"])
|
||||||
parts[0]["executableCodeResult"]
|
|
||||||
)
|
|
||||||
elif "codeExecutionResult" in parts[0]:
|
elif "codeExecutionResult" in parts[0]:
|
||||||
text = _format_execution_result(
|
text = _format_execution_result(parts[0]["codeExecutionResult"])
|
||||||
parts[0]["codeExecutionResult"]
|
|
||||||
)
|
|
||||||
elif "inlineData" in parts[0]:
|
elif "inlineData" in parts[0]:
|
||||||
text = _extract_image_data(parts[0])
|
text = _extract_image_data(parts[0])
|
||||||
else:
|
else:
|
||||||
@@ -165,10 +188,10 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
|
|||||||
if settings.SHOW_THINKING_PROCESS:
|
if settings.SHOW_THINKING_PROCESS:
|
||||||
if len(candidate["content"]["parts"]) == 2:
|
if len(candidate["content"]["parts"]) == 2:
|
||||||
text = (
|
text = (
|
||||||
"> thinking\n\n"
|
"> thinking\n\n"
|
||||||
+ candidate["content"]["parts"][0]["text"]
|
+ candidate["content"]["parts"][0]["text"]
|
||||||
+ "\n\n---\n> output\n\n"
|
+ "\n\n---\n> output\n\n"
|
||||||
+ candidate["content"]["parts"][1]["text"]
|
+ candidate["content"]["parts"][1]["text"]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
text = candidate["content"]["parts"][0]["text"]
|
text = candidate["content"]["parts"][0]["text"]
|
||||||
@@ -186,34 +209,47 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
|
|||||||
elif "inlineData" in part:
|
elif "inlineData" in part:
|
||||||
text += _extract_image_data(part)
|
text += _extract_image_data(part)
|
||||||
|
|
||||||
|
|
||||||
text = _add_search_link_text(model, candidate, text)
|
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:
|
else:
|
||||||
text = "暂无返回"
|
text = "暂无返回"
|
||||||
return text, tool_calls
|
return text, tool_calls
|
||||||
|
|
||||||
|
|
||||||
def _extract_image_data(part: dict) -> str:
|
def _extract_image_data(part: dict) -> str:
|
||||||
image_uploader = None
|
image_uploader = None
|
||||||
if settings.UPLOAD_PROVIDER == "smms":
|
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":
|
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":
|
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")
|
current_date = time.strftime("%Y/%m/%d")
|
||||||
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||||
base64_data = part["inlineData"]["data"]
|
base64_data = part["inlineData"]["data"]
|
||||||
#将base64_data转成bytes数组
|
# 将base64_data转成bytes数组
|
||||||
bytes_data = base64.b64decode(base64_data)
|
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:
|
if upload_response.success:
|
||||||
text = f"\n\n\n\n"
|
text = f"\n\n\n\n"
|
||||||
else:
|
else:
|
||||||
text = ""
|
text = ""
|
||||||
return 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):
|
if not parts or not isinstance(parts, list):
|
||||||
return []
|
return []
|
||||||
@@ -249,8 +285,12 @@ def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> Lis
|
|||||||
return tool_calls
|
return tool_calls
|
||||||
|
|
||||||
|
|
||||||
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
def _handle_gemini_stream_response(
|
||||||
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
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:
|
if tool_calls:
|
||||||
content = {"parts": tool_calls, "role": "model"}
|
content = {"parts": tool_calls, "role": "model"}
|
||||||
else:
|
else:
|
||||||
@@ -259,8 +299,12 @@ def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
def _handle_gemini_normal_response(
|
||||||
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
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:
|
if tool_calls:
|
||||||
content = {"parts": tool_calls, "role": "model"}
|
content = {"parts": tool_calls, "role": "model"}
|
||||||
else:
|
else:
|
||||||
@@ -278,10 +322,10 @@ def _format_code_block(code_data: dict) -> str:
|
|||||||
|
|
||||||
def _add_search_link_text(model: str, candidate: dict, text: str) -> str:
|
def _add_search_link_text(model: str, candidate: dict, text: str) -> str:
|
||||||
if (
|
if (
|
||||||
settings.SHOW_SEARCH_LINK
|
settings.SHOW_SEARCH_LINK
|
||||||
and model.endswith("-search")
|
and model.endswith("-search")
|
||||||
and "groundingMetadata" in candidate
|
and "groundingMetadata" in candidate
|
||||||
and "groundingChunks" in candidate["groundingMetadata"]
|
and "groundingChunks" in candidate["groundingMetadata"]
|
||||||
):
|
):
|
||||||
grounding_chunks = candidate["groundingMetadata"]["groundingChunks"]
|
grounding_chunks = candidate["groundingMetadata"]["groundingChunks"]
|
||||||
text += "\n\n---\n\n"
|
text += "\n\n---\n\n"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, TypeVar
|
from typing import Callable, TypeVar
|
||||||
|
|
||||||
from app.core.constants import MAX_RETRIES
|
from app.config.config import settings
|
||||||
from app.log.logger import get_retry_logger
|
from app.log.logger import get_retry_logger
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
@@ -12,8 +12,7 @@ logger = get_retry_logger()
|
|||||||
class RetryHandler:
|
class RetryHandler:
|
||||||
"""重试处理装饰器"""
|
"""重试处理装饰器"""
|
||||||
|
|
||||||
def __init__(self, max_retries: int = MAX_RETRIES, key_arg: str = "api_key"):
|
def __init__(self, key_arg: str = "api_key"):
|
||||||
self.max_retries = max_retries
|
|
||||||
self.key_arg = key_arg
|
self.key_arg = key_arg
|
||||||
|
|
||||||
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
|
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
|
||||||
@@ -21,14 +20,14 @@ class RetryHandler:
|
|||||||
async def wrapper(*args, **kwargs) -> T:
|
async def wrapper(*args, **kwargs) -> T:
|
||||||
last_exception = None
|
last_exception = None
|
||||||
|
|
||||||
for attempt in range(self.max_retries):
|
for attempt in range(settings.MAX_RETRIES):
|
||||||
retries = attempt + 1
|
retries = attempt + 1
|
||||||
try:
|
try:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_exception = e
|
last_exception = e
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"API call failed with error: {str(e)}. Attempt {retries} of {self.max_retries}"
|
f"API call failed with error: {str(e)}. Attempt {retries} of {settings.MAX_RETRIES}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 从函数参数中获取 key_manager
|
# 从函数参数中获取 key_manager
|
||||||
|
|||||||
@@ -206,3 +206,20 @@ def get_update_logger():
|
|||||||
|
|
||||||
def get_scheduler_routes():
|
def get_scheduler_routes():
|
||||||
return Logger.setup_logger("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")
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_log_logger():
|
||||||
|
return Logger.setup_logger("error_log")
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
and not request.url.path.startswith(f"/{API_VERSION}")
|
and not request.url.path.startswith(f"/{API_VERSION}")
|
||||||
and not request.url.path.startswith("/health")
|
and not request.url.path.startswith("/health")
|
||||||
and not request.url.path.startswith("/hf")
|
and not request.url.path.startswith("/hf")
|
||||||
|
and not request.url.path.startswith("/openai")
|
||||||
|
and not request.url.path.startswith("/api/version/check")
|
||||||
):
|
):
|
||||||
|
|
||||||
auth_token = request.cookies.get("auth_token")
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
配置路由模块
|
配置路由模块
|
||||||
"""
|
"""
|
||||||
from typing import Any, Dict
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.core.security import verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.log.logger import get_config_routes_logger, Logger # 导入 Logger 类
|
from app.log.logger import Logger, get_config_routes_logger
|
||||||
from app.service.config.config_service import ConfigService
|
from app.service.config.config_service import ConfigService
|
||||||
|
|
||||||
# 创建路由
|
|
||||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||||
|
|
||||||
logger = get_config_routes_logger()
|
logger = get_config_routes_logger()
|
||||||
@@ -34,10 +36,10 @@ async def update_config(config_data: Dict[str, Any], request: Request):
|
|||||||
result = await ConfigService.update_config(config_data)
|
result = await ConfigService.update_config(config_data)
|
||||||
# 配置更新成功后,立即更新所有 logger 的级别
|
# 配置更新成功后,立即更新所有 logger 的级别
|
||||||
Logger.update_log_levels(config_data["LOG_LEVEL"])
|
Logger.update_log_levels(config_data["LOG_LEVEL"])
|
||||||
logger.info("Log levels updated after configuration change.") # 添加日志记录
|
logger.info("Log levels updated after configuration change.")
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating config or log levels: {e}", exc_info=True) # 记录详细错误
|
logger.error(f"Error updating config or log levels: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -51,3 +53,90 @@ async def reset_config(request: Request):
|
|||||||
return await ConfigService.reset_config()
|
return await ConfigService.reset_config()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic model for bulk delete request
|
||||||
|
class DeleteKeysRequest(BaseModel):
|
||||||
|
keys: List[str] = Field(..., description="List of API keys to delete")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/keys/{key_to_delete}", response_model=Dict[str, Any])
|
||||||
|
async def delete_single_key(key_to_delete: str, request: Request):
|
||||||
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
|
logger.warning(f"Unauthorized attempt to delete key: {key_to_delete}")
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
try:
|
||||||
|
logger.info(f"Attempting to delete key: {key_to_delete}")
|
||||||
|
result = await ConfigService.delete_key(key_to_delete)
|
||||||
|
if not result.get("success"):
|
||||||
|
# Optionally, translate specific errors to HTTP status codes
|
||||||
|
# For now, let's assume 400 for any failure from service if not found,
|
||||||
|
# or 500 if it was an unexpected error (though service should handle that)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=(
|
||||||
|
404 if "not found" in result.get("message", "").lower() else 400
|
||||||
|
),
|
||||||
|
detail=result.get("message"),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except HTTPException as e:
|
||||||
|
# Re-raise HTTPExceptions directly
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error deleting key: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keys/delete-selected", response_model=Dict[str, Any])
|
||||||
|
async def delete_selected_keys_route(
|
||||||
|
delete_request: DeleteKeysRequest, request: Request
|
||||||
|
):
|
||||||
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
|
logger.warning("Unauthorized attempt to bulk delete keys")
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
if not delete_request.keys:
|
||||||
|
logger.warning("Attempt to bulk delete keys with an empty list.")
|
||||||
|
raise HTTPException(status_code=400, detail="No keys provided for deletion.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
|
||||||
|
result = await ConfigService.delete_selected_keys(delete_request.keys)
|
||||||
|
# Similar to single delete, we can check result["success"]
|
||||||
|
if not result.get("success") and result.get("deleted_count", 0) == 0:
|
||||||
|
# If no keys were actually deleted, it might be a client error (e.g., all keys not found)
|
||||||
|
# or an empty list was somehow passed despite the check above.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=result.get("message", "Failed to delete keys.")
|
||||||
|
)
|
||||||
|
# If some keys were deleted but others not found, it's still a partial success, return 200 with details.
|
||||||
|
return result
|
||||||
|
except HTTPException as e:
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error bulk deleting keys: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error bulk deleting keys: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ui/models")
|
||||||
|
async def get_ui_models(request: Request):
|
||||||
|
auth_token_cookie = request.cookies.get("auth_token")
|
||||||
|
if not auth_token_cookie or not verify_auth_token(auth_token_cookie):
|
||||||
|
logger.warning("Unauthorized access attempt to /api/config/ui/models")
|
||||||
|
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
models = await ConfigService.fetch_ui_models()
|
||||||
|
return models
|
||||||
|
except HTTPException as e:
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in /ui/models endpoint: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"An unexpected error occurred while fetching UI models: {str(e)}",
|
||||||
|
)
|
||||||
|
|||||||
211
app/router/error_log_routes.py
Normal file
211
app/router/error_log_routes.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
日志路由模块
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Body,
|
||||||
|
HTTPException,
|
||||||
|
Path,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.security import verify_auth_token
|
||||||
|
from app.log.logger import get_log_routes_logger
|
||||||
|
from app.service.error_log import error_log_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
|
||||||
|
logger = get_log_routes_logger()
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
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")
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await error_log_service.process_get_error_logs(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
key_search=key_search,
|
||||||
|
error_search=error_search,
|
||||||
|
error_code_search=error_code_search,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
logs_data = result["logs"]
|
||||||
|
total_count = result["total"]
|
||||||
|
|
||||||
|
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
|
||||||
|
request_msg: Optional[str] = None
|
||||||
|
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:
|
||||||
|
log_details = await error_log_service.process_get_error_log_details(
|
||||||
|
log_id=log_id
|
||||||
|
)
|
||||||
|
if not log_details:
|
||||||
|
raise HTTPException(status_code=404, detail="Error log not found")
|
||||||
|
|
||||||
|
return ErrorLogDetailResponse(**log_details)
|
||||||
|
except HTTPException as http_exc:
|
||||||
|
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(...)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量删除错误日志 (异步)
|
||||||
|
"""
|
||||||
|
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 error_log_service.process_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)):
|
||||||
|
"""
|
||||||
|
删除单个错误日志 (异步)
|
||||||
|
"""
|
||||||
|
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 error_log_service.process_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
|
||||||
|
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,23 +1,22 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
import asyncio
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.log.logger import get_gemini_logger
|
from app.log.logger import get_gemini_logger
|
||||||
from app.core.security import SecurityService
|
from app.core.security import SecurityService
|
||||||
import asyncio # 导入 asyncio
|
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest
|
||||||
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest # 添加导入
|
|
||||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||||
from app.service.model.model_service import ModelService
|
from app.service.model.model_service import ModelService
|
||||||
from app.handler.retry_handler import RetryHandler
|
from app.handler.retry_handler import RetryHandler
|
||||||
|
from app.handler.error_handler import handle_route_errors
|
||||||
from app.core.constants import API_VERSION
|
from app.core.constants import API_VERSION
|
||||||
|
|
||||||
# 路由设置
|
|
||||||
router = APIRouter(prefix=f"/gemini/{API_VERSION}")
|
router = APIRouter(prefix=f"/gemini/{API_VERSION}")
|
||||||
router_v1beta = APIRouter(prefix=f"/{API_VERSION}")
|
router_v1beta = APIRouter(prefix=f"/{API_VERSION}")
|
||||||
logger = get_gemini_logger()
|
logger = get_gemini_logger()
|
||||||
|
|
||||||
# 初始化服务
|
|
||||||
security_service = SecurityService()
|
security_service = SecurityService()
|
||||||
model_service = ModelService()
|
model_service = ModelService()
|
||||||
|
|
||||||
@@ -43,67 +42,60 @@ async def list_models(
|
|||||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||||
key_manager: KeyManager = Depends(get_key_manager)
|
key_manager: KeyManager = Depends(get_key_manager)
|
||||||
):
|
):
|
||||||
"""获取可用的Gemini模型列表"""
|
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
|
||||||
logger.info("-" * 50 + "list_gemini_models" + "-" * 50)
|
operation_name = "list_gemini_models"
|
||||||
|
logger.info("-" * 50 + operation_name + "-" * 50)
|
||||||
logger.info("Handling Gemini models list request")
|
logger.info("Handling Gemini models list request")
|
||||||
|
|
||||||
api_key = await key_manager.get_first_valid_key()
|
try:
|
||||||
logger.info(f"Using API key: {api_key}")
|
api_key = await key_manager.get_first_valid_key()
|
||||||
|
if not api_key:
|
||||||
models_json = model_service.get_gemini_models(api_key)
|
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
|
||||||
model_mapping = {x.get("name", "").split("/", maxsplit=1)[1]: x for x in models_json["models"]}
|
logger.info(f"Using API key: {api_key}")
|
||||||
|
|
||||||
# 添加搜索模型
|
models_data = await model_service.get_gemini_models(api_key)
|
||||||
if settings.SEARCH_MODELS:
|
if not models_data or "models" not in models_data:
|
||||||
for name in settings.SEARCH_MODELS:
|
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
|
||||||
model = model_mapping.get(name)
|
|
||||||
|
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:
|
if not model:
|
||||||
continue
|
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
|
||||||
|
return
|
||||||
item = deepcopy(model)
|
item = deepcopy(model)
|
||||||
item["name"] = f"models/{name}-search"
|
item["name"] = f"models/{base_name}{suffix}"
|
||||||
display_name = f'{item.get("displayName")} For Search'
|
display_name = f'{item.get("displayName", base_name)}{display_suffix}'
|
||||||
item["displayName"] = display_name
|
item["displayName"] = display_name
|
||||||
item["description"] = display_name
|
item["description"] = display_name
|
||||||
|
|
||||||
models_json["models"].append(item)
|
models_json["models"].append(item)
|
||||||
|
|
||||||
# 添加图像生成模型
|
if settings.SEARCH_MODELS:
|
||||||
if settings.IMAGE_MODELS:
|
for name in settings.SEARCH_MODELS:
|
||||||
for name in settings.IMAGE_MODELS:
|
add_derived_model(name, "-search", " For Search")
|
||||||
model = model_mapping.get(name)
|
if settings.IMAGE_MODELS:
|
||||||
if not model:
|
for name in settings.IMAGE_MODELS:
|
||||||
continue
|
add_derived_model(name, "-image", " For Image")
|
||||||
|
if settings.THINKING_MODELS:
|
||||||
item = deepcopy(model)
|
for name in settings.THINKING_MODELS:
|
||||||
item["name"] = f"models/{name}-image"
|
add_derived_model(name, "-non-thinking", " Non Thinking")
|
||||||
display_name = f'{item.get("displayName")} For Image'
|
|
||||||
item["displayName"] = display_name
|
logger.info("Gemini models list request successful")
|
||||||
item["description"] = display_name
|
return models_json
|
||||||
|
except HTTPException as http_exc:
|
||||||
models_json["models"].append(item)
|
raise http_exc
|
||||||
|
except Exception as e:
|
||||||
# 添加思考模型的非思考版本
|
logger.error(f"Error getting Gemini models list: {str(e)}")
|
||||||
if settings.THINKING_MODELS:
|
raise HTTPException(
|
||||||
for name in settings.THINKING_MODELS:
|
status_code=500, detail="Internal server error while fetching Gemini models list"
|
||||||
model = model_mapping.get(name)
|
) from e
|
||||||
if not model:
|
|
||||||
continue
|
|
||||||
|
|
||||||
item = deepcopy(model)
|
|
||||||
item["name"] = f"models/{name}-non-thinking"
|
|
||||||
display_name = f'{item.get("displayName")} Non Thinking'
|
|
||||||
item["displayName"] = display_name
|
|
||||||
item["description"] = display_name
|
|
||||||
|
|
||||||
models_json["models"].append(item)
|
|
||||||
|
|
||||||
return models_json
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/models/{model_name}:generateContent")
|
@router.post("/models/{model_name}:generateContent")
|
||||||
@router_v1beta.post("/models/{model_name}:generateContent")
|
@router_v1beta.post("/models/{model_name}:generateContent")
|
||||||
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
|
@RetryHandler(key_arg="api_key")
|
||||||
async def generate_content(
|
async def generate_content(
|
||||||
model_name: str,
|
model_name: str,
|
||||||
request: GeminiRequest,
|
request: GeminiRequest,
|
||||||
@@ -112,30 +104,27 @@ async def generate_content(
|
|||||||
key_manager: KeyManager = Depends(get_key_manager),
|
key_manager: KeyManager = Depends(get_key_manager),
|
||||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||||
):
|
):
|
||||||
"""非流式生成内容"""
|
"""处理 Gemini 非流式内容生成请求。"""
|
||||||
logger.info("-" * 50 + "gemini_generate_content" + "-" * 50)
|
operation_name = "gemini_generate_content"
|
||||||
logger.info(f"Handling Gemini content generation request for model: {model_name}")
|
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
|
||||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
logger.info(f"Handling Gemini content generation request for model: {model_name}")
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.debug(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")
|
if not await model_service.check_model_support(model_name):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||||
try:
|
|
||||||
response = await chat_service.generate_content(
|
response = await chat_service.generate_content(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
request=request,
|
request=request,
|
||||||
api_key=api_key
|
api_key=api_key
|
||||||
)
|
)
|
||||||
return response
|
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")
|
@router.post("/models/{model_name}:streamGenerateContent")
|
||||||
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
|
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
|
||||||
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
|
@RetryHandler(key_arg="api_key")
|
||||||
async def stream_generate_content(
|
async def stream_generate_content(
|
||||||
model_name: str,
|
model_name: str,
|
||||||
request: GeminiRequest,
|
request: GeminiRequest,
|
||||||
@@ -144,25 +133,23 @@ async def stream_generate_content(
|
|||||||
key_manager: KeyManager = Depends(get_key_manager),
|
key_manager: KeyManager = Depends(get_key_manager),
|
||||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||||
):
|
):
|
||||||
"""流式生成内容"""
|
"""处理 Gemini 流式内容生成请求。"""
|
||||||
logger.info("-" * 50 + "gemini_stream_generate_content" + "-" * 50)
|
operation_name = "gemini_stream_generate_content"
|
||||||
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
|
||||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.debug(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")
|
if not await model_service.check_model_support(model_name):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||||
try:
|
|
||||||
response_stream = chat_service.stream_generate_content(
|
response_stream = chat_service.stream_generate_content(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
request=request,
|
request=request,
|
||||||
api_key=api_key
|
api_key=api_key
|
||||||
)
|
)
|
||||||
return StreamingResponse(response_stream, media_type="text/event-stream")
|
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")
|
@router.post("/reset-all-fail-counts")
|
||||||
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
|
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
|
||||||
@@ -211,7 +198,7 @@ async def reset_selected_key_fail_counts(
|
|||||||
"""批量重置选定Gemini API密钥的失败计数"""
|
"""批量重置选定Gemini API密钥的失败计数"""
|
||||||
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
|
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
|
||||||
keys_to_reset = request.keys
|
keys_to_reset = request.keys
|
||||||
key_type = request.key_type # 获取类型用于日志记录和响应消息
|
key_type = request.key_type
|
||||||
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
|
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
|
||||||
|
|
||||||
if not keys_to_reset:
|
if not keys_to_reset:
|
||||||
@@ -227,38 +214,31 @@ async def reset_selected_key_fail_counts(
|
|||||||
if result:
|
if result:
|
||||||
reset_count += 1
|
reset_count += 1
|
||||||
else:
|
else:
|
||||||
# 记录未找到的密钥,但不视为致命错误
|
|
||||||
logger.warning(f"Key not found during selective reset: {key}")
|
logger.warning(f"Key not found during selective reset: {key}")
|
||||||
except Exception as key_error:
|
except Exception as key_error:
|
||||||
# 记录单个密钥重置时的错误
|
|
||||||
logger.error(f"Error resetting key {key}: {str(key_error)}")
|
logger.error(f"Error resetting key {key}: {str(key_error)}")
|
||||||
errors.append(f"Key {key}: {str(key_error)}")
|
errors.append(f"Key {key}: {str(key_error)}")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
# 如果有错误,报告部分成功或完全失败
|
|
||||||
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
|
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
|
||||||
# 确定最终状态码和成功标志
|
|
||||||
final_success = reset_count > 0
|
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
|
status_code = 207 if final_success and errors else 500
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": final_success,
|
"success": final_success,
|
||||||
"message": error_message,
|
"message": error_message,
|
||||||
"reset_count": reset_count
|
"reset_count": reset_count
|
||||||
}, status_code=status_code)
|
}, status_code=status_code)
|
||||||
|
|
||||||
# 完全成功的情况
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
|
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
|
||||||
"reset_count": reset_count
|
"reset_count": reset_count
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 捕获循环外的意外错误
|
|
||||||
logger.error(f"Failed to process reset selected key failure counts request: {str(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)
|
return JSONResponse({"success": False, "message": f"批量重置处理失败: {str(e)}"}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset-fail-count/{api_key}")
|
@router.post("/reset-fail-count/{api_key}")
|
||||||
async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(get_key_manager)):
|
async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(get_key_manager)):
|
||||||
"""重置指定Gemini API密钥的失败计数"""
|
"""重置指定Gemini API密钥的失败计数"""
|
||||||
@@ -274,6 +254,7 @@ async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(g
|
|||||||
logger.error(f"Failed to reset key failure count: {str(e)}")
|
logger.error(f"Failed to reset key failure count: {str(e)}")
|
||||||
return JSONResponse({"success": False, "message": f"重置失败: {str(e)}"}, status_code=500)
|
return JSONResponse({"success": False, "message": f"重置失败: {str(e)}"}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-key/{api_key}")
|
@router.post("/verify-key/{api_key}")
|
||||||
async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get_chat_service), key_manager: KeyManager = Depends(get_key_manager)):
|
async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get_chat_service), key_manager: KeyManager = Depends(get_key_manager)):
|
||||||
"""验证Gemini API密钥的有效性"""
|
"""验证Gemini API密钥的有效性"""
|
||||||
@@ -281,14 +262,14 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
|
|||||||
logger.info("Verifying API key validity")
|
logger.info("Verifying API key validity")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 使用generate_content接口测试key的有效性
|
|
||||||
gemini_request = GeminiRequest(
|
gemini_request = GeminiRequest(
|
||||||
contents=[
|
contents=[
|
||||||
GeminiContent(
|
GeminiContent(
|
||||||
role="user",
|
role="user",
|
||||||
parts=[{"text": "hi"}]
|
parts=[{"text": "hi"}],
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
|
generation_config={"temperature": 0.7, "top_p": 1.0, "max_output_tokens": 10}
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await chat_service.generate_content(
|
response = await chat_service.generate_content(
|
||||||
@@ -302,7 +283,6 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Key verification failed: {str(e)}")
|
logger.error(f"Key verification failed: {str(e)}")
|
||||||
|
|
||||||
# 验证出现异常时增加失败计数
|
|
||||||
async with key_manager.failure_count_lock:
|
async with key_manager.failure_count_lock:
|
||||||
if api_key in key_manager.key_failure_counts:
|
if api_key in key_manager.key_failure_counts:
|
||||||
key_manager.key_failure_counts[api_key] += 1
|
key_manager.key_failure_counts[api_key] += 1
|
||||||
@@ -325,76 +305,70 @@ async def verify_selected_keys(
|
|||||||
if not keys_to_verify:
|
if not keys_to_verify:
|
||||||
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
|
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
|
||||||
|
|
||||||
valid_count = 0
|
successful_keys = []
|
||||||
invalid_count = 0
|
failed_keys = {}
|
||||||
verification_errors = {} # 存储验证过程中的错误
|
|
||||||
|
|
||||||
async def _verify_single_key(api_key: str):
|
async def _verify_single_key(api_key: str):
|
||||||
"""内部函数,用于验证单个密钥并处理异常"""
|
"""内部函数,用于验证单个密钥并处理异常"""
|
||||||
nonlocal valid_count, invalid_count # 允许修改外部计数器
|
nonlocal successful_keys, failed_keys
|
||||||
try:
|
try:
|
||||||
# 重用单密钥验证逻辑的核心部分
|
|
||||||
gemini_request = GeminiRequest(
|
gemini_request = GeminiRequest(
|
||||||
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])]
|
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])],
|
||||||
|
generation_config={"temperature": 0.7, "top_p": 1.0, "max_output_tokens": 10}
|
||||||
)
|
)
|
||||||
# 注意:这里直接调用 chat_service.generate_content,不依赖于 key_manager 获取密钥
|
|
||||||
await chat_service.generate_content(
|
await chat_service.generate_content(
|
||||||
settings.TEST_MODEL,
|
settings.TEST_MODEL,
|
||||||
gemini_request,
|
gemini_request,
|
||||||
api_key
|
api_key
|
||||||
)
|
)
|
||||||
# 如果上面没有抛出异常,则认为密钥有效
|
successful_keys.append(api_key)
|
||||||
valid_count += 1
|
|
||||||
return api_key, "valid", None
|
return api_key, "valid", None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
logger.warning(f"Key verification failed for {api_key}: {error_message}")
|
logger.warning(f"Key verification failed for {api_key}: {error_message}")
|
||||||
# 验证失败时增加失败计数 (使用与 /verify-key 一致的逻辑)
|
|
||||||
async with key_manager.failure_count_lock:
|
async with key_manager.failure_count_lock:
|
||||||
if api_key in key_manager.key_failure_counts:
|
if api_key in key_manager.key_failure_counts:
|
||||||
key_manager.key_failure_counts[api_key] += 1
|
key_manager.key_failure_counts[api_key] += 1
|
||||||
logger.warning(f"Bulk verification exception for key: {api_key}, incrementing failure count")
|
logger.warning(f"Bulk verification exception for key: {api_key}, incrementing failure count")
|
||||||
else:
|
else:
|
||||||
# 如果密钥不在计数中(可能刚添加或从未失败),初始化为1
|
|
||||||
key_manager.key_failure_counts[api_key] = 1
|
key_manager.key_failure_counts[api_key] = 1
|
||||||
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
|
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
|
||||||
invalid_count += 1
|
failed_keys[api_key] = error_message
|
||||||
return api_key, "invalid", error_message
|
return api_key, "invalid", error_message
|
||||||
|
|
||||||
# 并发执行所有密钥的验证
|
|
||||||
tasks = [_verify_single_key(key) for key in keys_to_verify]
|
tasks = [_verify_single_key(key) for key in keys_to_verify]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True) # return_exceptions=True 捕获任务本身的异常
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# 处理并发执行的结果
|
|
||||||
for result in results:
|
for result in results:
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
# 捕获 asyncio.gather 可能遇到的异常(例如任务被取消)
|
|
||||||
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
|
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
|
||||||
# 可以选择如何处理这种任务级别的错误,这里我们简单记录
|
|
||||||
# 也可以将其计入 invalid_count 或单独记录
|
|
||||||
elif result:
|
elif result:
|
||||||
key, status, error = result
|
if not isinstance(result, Exception) and result:
|
||||||
if status == "invalid" and error:
|
key, status, error = result
|
||||||
verification_errors[key] = error # 记录具体的验证错误信息
|
elif isinstance(result, Exception):
|
||||||
|
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}")
|
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
|
||||||
|
|
||||||
# 根据是否有错误决定最终消息和状态
|
if failed_keys:
|
||||||
if verification_errors or valid_count + invalid_count != len(keys_to_verify): # 检查是否有错误或任务异常
|
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}。"
|
||||||
error_summary = "; ".join([f"{k}: {v}" for k, v in verification_errors.items()])
|
|
||||||
message = f"批量验证完成,但出现问题。有效: {valid_count}, 无效: {invalid_count}。错误详情: {error_summary or '任务执行异常'}"
|
|
||||||
return JSONResponse({
|
|
||||||
"success": False, # 标记为失败,因为有错误
|
|
||||||
"message": message,
|
|
||||||
"valid_count": valid_count,
|
|
||||||
"invalid_count": invalid_count,
|
|
||||||
"errors": verification_errors
|
|
||||||
}, status_code=207) # 207 Multi-Status 表示部分成功/失败
|
|
||||||
else:
|
|
||||||
# 完全成功
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"批量验证成功完成。有效: {valid_count}, 无效: {invalid_count}",
|
"message": message,
|
||||||
|
"successful_keys": successful_keys,
|
||||||
|
"failed_keys": failed_keys,
|
||||||
"valid_count": valid_count,
|
"valid_count": valid_count,
|
||||||
"invalid_count": invalid_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,124 +0,0 @@
|
|||||||
"""
|
|
||||||
日志路由模块
|
|
||||||
"""
|
|
||||||
from typing import List, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Query, Path
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# 创建路由
|
|
||||||
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"), # 数据库查询需处理
|
|
||||||
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
|
|
||||||
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
获取错误日志列表 (返回错误码)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: 请求对象
|
|
||||||
limit: 限制数量
|
|
||||||
offset: 偏移量
|
|
||||||
key_search: 密钥搜索
|
|
||||||
error_search: 错误搜索 (可能搜索类型或日志内容,由DB层决定)
|
|
||||||
start_date: 开始日期
|
|
||||||
end_date: 结束日期
|
|
||||||
|
|
||||||
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, # 数据库查询需要处理这个
|
|
||||||
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
|
|
||||||
)
|
|
||||||
# 验证并转换数据以匹配 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)}")
|
|
||||||
113
app/router/openai_compatiable_routes.py
Normal file
113
app/router/openai_compatiable_routes.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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(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
|
||||||
|
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}")
|
||||||
|
|
||||||
|
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:
|
||||||
|
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,
|
ImageGenerationRequest,
|
||||||
)
|
)
|
||||||
from app.handler.retry_handler import RetryHandler
|
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.log.logger import get_openai_logger
|
||||||
from app.service.chat.openai_chat_service import OpenAIChatService
|
from app.service.chat.openai_chat_service import OpenAIChatService
|
||||||
from app.service.embedding.embedding_service import EmbeddingService
|
from app.service.embedding.embedding_service import EmbeddingService
|
||||||
@@ -19,7 +20,6 @@ from app.service.model.model_service import ModelService
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = get_openai_logger()
|
logger = get_openai_logger()
|
||||||
|
|
||||||
# 初始化服务
|
|
||||||
security_service = SecurityService()
|
security_service = SecurityService()
|
||||||
model_service = ModelService()
|
model_service = ModelService()
|
||||||
embedding_service = EmbeddingService()
|
embedding_service = EmbeddingService()
|
||||||
@@ -47,56 +47,52 @@ async def list_models(
|
|||||||
_=Depends(security_service.verify_authorization),
|
_=Depends(security_service.verify_authorization),
|
||||||
key_manager: KeyManager = Depends(get_key_manager),
|
key_manager: KeyManager = Depends(get_key_manager),
|
||||||
):
|
):
|
||||||
logger.info("-" * 50 + "list_models" + "-" * 50)
|
"""获取可用的 OpenAI 模型列表 (兼容 Gemini 和 OpenAI)。"""
|
||||||
logger.info("Handling models list request")
|
operation_name = "list_models"
|
||||||
api_key = await key_manager.get_first_valid_key()
|
async with handle_route_errors(logger, operation_name):
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.info("Handling models list request")
|
||||||
try:
|
api_key = await key_manager.get_first_valid_key()
|
||||||
return model_service.get_gemini_openai_models(api_key)
|
logger.info(f"Using API key: {api_key}")
|
||||||
except Exception as e:
|
return await model_service.get_gemini_openai_models(api_key)
|
||||||
logger.error(f"Error getting models list: {str(e)}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500, detail="Internal server error while fetching models list"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/chat/completions")
|
@router.post("/v1/chat/completions")
|
||||||
@router.post("/hf/v1/chat/completions")
|
@router.post("/hf/v1/chat/completions")
|
||||||
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
|
@RetryHandler(key_arg="api_key")
|
||||||
async def chat_completion(
|
async def chat_completion(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
_=Depends(security_service.verify_authorization),
|
_=Depends(security_service.verify_authorization),
|
||||||
api_key: str = Depends(get_next_working_key_wrapper),
|
api_key: str = Depends(get_next_working_key_wrapper),
|
||||||
key_manager: KeyManager = Depends(get_key_manager), # 保留 key_manager 用于获取 paid_key
|
key_manager: KeyManager = Depends(get_key_manager),
|
||||||
chat_service: OpenAIChatService = Depends(get_openai_chat_service),
|
chat_service: OpenAIChatService = Depends(get_openai_chat_service),
|
||||||
):
|
):
|
||||||
# 如果model是imagen3,使用paid_key
|
"""处理 OpenAI 聊天补全请求,支持流式响应和特定模型切换。"""
|
||||||
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
operation_name = "chat_completion"
|
||||||
api_key = await key_manager.get_paid_key()
|
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
|
||||||
logger.info("-" * 50 + "chat_completion" + "-" * 50)
|
current_api_key = api_key
|
||||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
if is_image_chat:
|
||||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
current_api_key = await key_manager.get_paid_key()
|
||||||
logger.info(f"Using API key: {api_key}")
|
|
||||||
|
|
||||||
if not model_service.check_model_support(request.model):
|
async with handle_route_errors(logger, operation_name):
|
||||||
raise HTTPException(
|
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||||
status_code=400, detail=f"Model {request.model} is not supported"
|
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||||
)
|
logger.info(f"Using API key: {current_api_key}")
|
||||||
|
|
||||||
try:
|
if not await model_service.check_model_support(request.model):
|
||||||
# 如果model是imagen3,使用paid_key
|
raise HTTPException(
|
||||||
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
status_code=400, detail=f"Model {request.model} is not supported"
|
||||||
response = await chat_service.create_image_chat_completion(request, api_key)
|
)
|
||||||
|
|
||||||
|
if is_image_chat:
|
||||||
|
response = await chat_service.create_image_chat_completion(request, current_api_key)
|
||||||
|
if request.stream:
|
||||||
|
return StreamingResponse(response, media_type="text/event-stream")
|
||||||
|
return response
|
||||||
else:
|
else:
|
||||||
response = await chat_service.create_chat_completion(request, api_key)
|
response = await chat_service.create_chat_completion(request, current_api_key)
|
||||||
# 处理流式响应
|
if request.stream:
|
||||||
if request.stream:
|
return StreamingResponse(response, media_type="text/event-stream")
|
||||||
return StreamingResponse(response, media_type="text/event-stream")
|
return response
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/images/generations")
|
@router.post("/v1/images/generations")
|
||||||
@@ -105,18 +101,12 @@ async def generate_image(
|
|||||||
request: ImageGenerationRequest,
|
request: ImageGenerationRequest,
|
||||||
_=Depends(security_service.verify_authorization),
|
_=Depends(security_service.verify_authorization),
|
||||||
):
|
):
|
||||||
logger.info("-" * 50 + "generate_image" + "-" * 50)
|
"""处理 OpenAI 图像生成请求。"""
|
||||||
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
operation_name = "generate_image"
|
||||||
|
async with handle_route_errors(logger, operation_name):
|
||||||
try:
|
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
||||||
response = image_create_service.generate_images(request)
|
response = image_create_service.generate_images(request)
|
||||||
logger.info("Image generation request successful")
|
|
||||||
return response
|
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")
|
@router.post("/v1/embeddings")
|
||||||
@@ -126,19 +116,16 @@ async def embedding(
|
|||||||
_=Depends(security_service.verify_authorization),
|
_=Depends(security_service.verify_authorization),
|
||||||
key_manager: KeyManager = Depends(get_key_manager),
|
key_manager: KeyManager = Depends(get_key_manager),
|
||||||
):
|
):
|
||||||
logger.info("-" * 50 + "embedding" + "-" * 50)
|
"""处理 OpenAI 文本嵌入请求。"""
|
||||||
logger.info(f"Handling embedding request for model: {request.model}")
|
operation_name = "embedding"
|
||||||
api_key = await key_manager.get_next_working_key()
|
async with handle_route_errors(logger, operation_name):
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.info(f"Handling embedding request for model: {request.model}")
|
||||||
try:
|
api_key = await key_manager.get_next_working_key()
|
||||||
|
logger.info(f"Using API key: {api_key}")
|
||||||
response = await embedding_service.create_embedding(
|
response = await embedding_service.create_embedding(
|
||||||
input_text=request.input, model=request.model, api_key=api_key
|
input_text=request.input, model=request.model, api_key=api_key
|
||||||
)
|
)
|
||||||
logger.info("Embedding request successful")
|
|
||||||
return response
|
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")
|
@router.get("/v1/keys/list")
|
||||||
@@ -147,10 +134,10 @@ async def get_keys_list(
|
|||||||
_=Depends(security_service.verify_auth_token),
|
_=Depends(security_service.verify_auth_token),
|
||||||
key_manager: KeyManager = Depends(get_key_manager),
|
key_manager: KeyManager = Depends(get_key_manager),
|
||||||
):
|
):
|
||||||
"""获取有效和无效的API key列表"""
|
"""获取有效和无效的API key列表 (需要管理 Token 认证)。"""
|
||||||
logger.info("-" * 50 + "get_keys_list" + "-" * 50)
|
operation_name = "get_keys_list"
|
||||||
logger.info("Handling keys list request")
|
async with handle_route_errors(logger, operation_name):
|
||||||
try:
|
logger.info("Handling keys list request")
|
||||||
keys_status = await key_manager.get_keys_by_status()
|
keys_status = await key_manager.get_keys_by_status()
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -160,8 +147,3 @@ async def get_keys_list(
|
|||||||
},
|
},
|
||||||
"total": len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]),
|
"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,13 +8,12 @@ from fastapi.templating import Jinja2Templates
|
|||||||
|
|
||||||
from app.core.security import verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.log.logger import get_routes_logger
|
from app.log.logger import get_routes_logger
|
||||||
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes, stats_routes # 新增导入 stats_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.key.key_manager import get_key_manager_instance
|
||||||
from app.service.stats_service import StatsService
|
from app.service.stats.stats_service import StatsService
|
||||||
|
|
||||||
logger = get_routes_logger()
|
logger = get_routes_logger()
|
||||||
|
|
||||||
# 配置Jinja2模板
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
@@ -25,21 +24,20 @@ def setup_routers(app: FastAPI) -> None:
|
|||||||
Args:
|
Args:
|
||||||
app: FastAPI应用程序实例
|
app: FastAPI应用程序实例
|
||||||
"""
|
"""
|
||||||
# 包含API路由
|
|
||||||
app.include_router(openai_routes.router)
|
app.include_router(openai_routes.router)
|
||||||
app.include_router(gemini_routes.router)
|
app.include_router(gemini_routes.router)
|
||||||
app.include_router(gemini_routes.router_v1beta)
|
app.include_router(gemini_routes.router_v1beta)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(log_routes.router)
|
app.include_router(error_log_routes.router)
|
||||||
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
app.include_router(scheduler_routes.router)
|
||||||
app.include_router(stats_routes.router) # 包含 stats API 路由
|
app.include_router(stats_routes.router)
|
||||||
|
app.include_router(version_routes.router)
|
||||||
|
app.include_router(openai_compatiable_routes.router)
|
||||||
|
|
||||||
# 添加页面路由
|
|
||||||
setup_page_routes(app)
|
setup_page_routes(app)
|
||||||
|
|
||||||
# 添加健康检查路由
|
|
||||||
setup_health_routes(app)
|
setup_health_routes(app)
|
||||||
setup_api_stats_routes(app) # Add API stats routes
|
setup_api_stats_routes(app)
|
||||||
|
|
||||||
|
|
||||||
def setup_page_routes(app: FastAPI) -> None:
|
def setup_page_routes(app: FastAPI) -> None:
|
||||||
@@ -104,16 +102,14 @@ def setup_page_routes(app: FastAPI) -> None:
|
|||||||
"request": request,
|
"request": request,
|
||||||
"valid_keys": keys_status["valid_keys"],
|
"valid_keys": keys_status["valid_keys"],
|
||||||
"invalid_keys": keys_status["invalid_keys"],
|
"invalid_keys": keys_status["invalid_keys"],
|
||||||
"total_keys": total_keys, # Renamed for clarity
|
"total_keys": total_keys,
|
||||||
"valid_key_count": valid_key_count, # Added count
|
"valid_key_count": valid_key_count,
|
||||||
"invalid_key_count": invalid_key_count, # Added count
|
"invalid_key_count": invalid_key_count,
|
||||||
"api_stats": api_stats, # <-- Pass stats to template
|
"api_stats": api_stats,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving keys status or API stats: {str(e)}")
|
logger.error(f"Error retrieving keys status or API stats: {str(e)}")
|
||||||
# Optionally, render template with error or default stats
|
|
||||||
# For now, re-raise to show error page
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@app.get("/config", response_class=HTMLResponse)
|
@app.get("/config", response_class=HTMLResponse)
|
||||||
@@ -173,16 +169,13 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
|||||||
async def api_stats_details(request: Request, period: str):
|
async def api_stats_details(request: Request, period: str):
|
||||||
"""获取指定时间段内的 API 调用详情"""
|
"""获取指定时间段内的 API 调用详情"""
|
||||||
try:
|
try:
|
||||||
# 验证认证
|
|
||||||
auth_token = request.cookies.get("auth_token")
|
auth_token = request.cookies.get("auth_token")
|
||||||
if not auth_token or not verify_auth_token(auth_token):
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
logger.warning("Unauthorized access attempt to API stats details")
|
logger.warning("Unauthorized access attempt to API stats details")
|
||||||
# Returning JSON error instead of redirect for API endpoint
|
|
||||||
return {"error": "Unauthorized"}, 401
|
return {"error": "Unauthorized"}, 401
|
||||||
|
|
||||||
logger.info(f"Fetching API call details for period: {period}")
|
logger.info(f"Fetching API call details for period: {period}")
|
||||||
# Use the service instance here as well
|
stats_service = StatsService()
|
||||||
stats_service = StatsService() # Create an instance
|
|
||||||
details = await stats_service.get_api_call_details(period)
|
details = await stats_service.get_api_call_details(period)
|
||||||
return details
|
return details
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -2,22 +2,20 @@
|
|||||||
定时任务控制路由模块
|
定时任务控制路由模块
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, status # 移除 Depends, 添加 Request
|
from fastapi import APIRouter, Request, HTTPException, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.core.security import verify_auth_token # 导入 verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler
|
||||||
from app.log.logger import get_scheduler_routes # 使用路由日志记录器
|
from app.log.logger import get_scheduler_routes
|
||||||
|
|
||||||
logger = get_scheduler_routes()
|
logger = get_scheduler_routes()
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/scheduler",
|
prefix="/api/scheduler",
|
||||||
tags=["Scheduler"]
|
tags=["Scheduler"]
|
||||||
# 移除全局依赖
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 认证检查的辅助函数
|
|
||||||
async def verify_token(request: Request):
|
async def verify_token(request: Request):
|
||||||
auth_token = request.cookies.get("auth_token")
|
auth_token = request.cookies.get("auth_token")
|
||||||
if not auth_token or not verify_auth_token(auth_token):
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
@@ -29,14 +27,12 @@ async def verify_token(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/start", summary="启动定时任务")
|
@router.post("/start", summary="启动定时任务")
|
||||||
async def start_scheduler_endpoint(request: Request): # 添加 request 参数
|
async def start_scheduler_endpoint(request: Request):
|
||||||
"""Start the background scheduler task"""
|
"""Start the background scheduler task"""
|
||||||
"""
|
await verify_token(request)
|
||||||
await verify_token(request) # 在函数开始处进行认证检查
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
logger.info("Received request to start scheduler.")
|
logger.info("Received request to start scheduler.")
|
||||||
start_scheduler() # 调用 key_checker 中的函数
|
start_scheduler()
|
||||||
return JSONResponse(content={"message": "Scheduler started successfully."}, status_code=status.HTTP_200_OK)
|
return JSONResponse(content={"message": "Scheduler started successfully."}, status_code=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting scheduler: {str(e)}", exc_info=True)
|
logger.error(f"Error starting scheduler: {str(e)}", exc_info=True)
|
||||||
@@ -46,14 +42,12 @@ async def start_scheduler_endpoint(request: Request): # 添加 request 参数
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/stop", summary="停止定时任务")
|
@router.post("/stop", summary="停止定时任务")
|
||||||
async def stop_scheduler_endpoint(request: Request): # 添加 request 参数
|
async def stop_scheduler_endpoint(request: Request):
|
||||||
"""Stop the background scheduler task"""
|
"""Stop the background scheduler task"""
|
||||||
"""
|
await verify_token(request)
|
||||||
await verify_token(request) # 在函数开始处进行认证检查
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
logger.info("Received request to stop scheduler.")
|
logger.info("Received request to stop scheduler.")
|
||||||
stop_scheduler() # 调用 key_checker 中的函数
|
stop_scheduler()
|
||||||
return JSONResponse(content={"message": "Scheduler stopped successfully."}, status_code=status.HTTP_200_OK)
|
return JSONResponse(content={"message": "Scheduler stopped successfully."}, status_code=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping scheduler: {str(e)}", exc_info=True)
|
logger.error(f"Error stopping scheduler: {str(e)}", exc_info=True)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from starlette import status
|
from starlette import status
|
||||||
from app.core.security import verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.service.stats_service import StatsService
|
from app.service.stats.stats_service import StatsService
|
||||||
from app.log.logger import get_stats_logger
|
from app.log.logger import get_stats_logger
|
||||||
|
|
||||||
logger = get_stats_logger()
|
logger = get_stats_logger()
|
||||||
@@ -45,9 +45,6 @@ async def get_key_usage_details(key: str):
|
|||||||
try:
|
try:
|
||||||
usage_details = await stats_service.get_key_usage_details_last_24h(key)
|
usage_details = await stats_service.get_key_usage_details_last_24h(key)
|
||||||
if usage_details is None:
|
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 {}
|
||||||
return usage_details
|
return usage_details
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
37
app/router/version_routes.py
Normal file
37
app/router/version_routes.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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()
|
||||||
|
update_available, latest_version, error_message = await check_for_updates()
|
||||||
|
|
||||||
|
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="检查版本信息时发生内部错误")
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
||||||
from app.service.key.key_manager import get_key_manager_instance
|
|
||||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
|
||||||
from app.domain.gemini_models import GeminiRequest, GeminiContent
|
|
||||||
from app.config.config import settings
|
|
||||||
from app.log.logger import Logger # 导入 Logger 类
|
|
||||||
|
|
||||||
logger = Logger.setup_logger("scheduler") # 使用 Logger.setup_logger
|
|
||||||
|
|
||||||
async def check_failed_keys():
|
|
||||||
"""
|
|
||||||
定时检查失败次数大于0的API密钥,并尝试验证它们。
|
|
||||||
如果验证成功,重置失败计数;如果失败,增加失败计数。
|
|
||||||
"""
|
|
||||||
logger.info("Starting scheduled check for failed API keys...")
|
|
||||||
try:
|
|
||||||
key_manager = await get_key_manager_instance()
|
|
||||||
# 确保 KeyManager 已经初始化
|
|
||||||
if not key_manager or not hasattr(key_manager, 'key_failure_counts'):
|
|
||||||
logger.warning("KeyManager instance not available or not initialized. Skipping check.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 创建 GeminiChatService 实例用于验证
|
|
||||||
# 注意:这里直接创建实例,而不是通过依赖注入,因为这是后台任务
|
|
||||||
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
|
||||||
|
|
||||||
# 获取需要检查的 key 列表 (失败次数 > 0)
|
|
||||||
keys_to_check = []
|
|
||||||
async with key_manager.failure_count_lock: # 访问共享数据需要加锁
|
|
||||||
# 复制一份以避免在迭代时修改字典
|
|
||||||
failure_counts_copy = key_manager.key_failure_counts.copy()
|
|
||||||
keys_to_check = [key for key, count in failure_counts_copy.items() if count > 0] # 检查所有失败次数大于0的key
|
|
||||||
|
|
||||||
if not keys_to_check:
|
|
||||||
logger.info("No keys with failure count > 0 found. Skipping verification.")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Found {len(keys_to_check)} keys with failure count > 0 to verify.")
|
|
||||||
|
|
||||||
for key in keys_to_check:
|
|
||||||
# 隐藏部分 key 用于日志记录
|
|
||||||
log_key = f"{key[:4]}...{key[-4:]}" if len(key) > 8 else key
|
|
||||||
logger.info(f"Verifying key: {log_key}...")
|
|
||||||
try:
|
|
||||||
# 构造测试请求
|
|
||||||
gemini_request = GeminiRequest(
|
|
||||||
contents=[
|
|
||||||
GeminiContent(
|
|
||||||
role="user",
|
|
||||||
parts=[{"text": "hi"}] # 使用简单的文本进行验证
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
# 调用 generate_content 进行验证
|
|
||||||
await chat_service.generate_content(
|
|
||||||
settings.TEST_MODEL, # 使用配置中定义的测试模型
|
|
||||||
gemini_request,
|
|
||||||
key
|
|
||||||
)
|
|
||||||
# 如果没有抛出异常,说明 key 有效
|
|
||||||
logger.info(f"Key {log_key} verification successful. Resetting failure count.")
|
|
||||||
await key_manager.reset_key_failure_count(key)
|
|
||||||
except Exception as e:
|
|
||||||
# 验证失败,增加失败计数
|
|
||||||
logger.warning(f"Key {log_key} verification failed: {str(e)}. Incrementing failure count.")
|
|
||||||
# 直接操作计数器,需要加锁
|
|
||||||
async with key_manager.failure_count_lock:
|
|
||||||
# 再次检查 key 是否存在且失败次数未达上限
|
|
||||||
if key in key_manager.key_failure_counts and key_manager.key_failure_counts[key] < key_manager.MAX_FAILURES:
|
|
||||||
key_manager.key_failure_counts[key] += 1
|
|
||||||
logger.info(f"Failure count for key {log_key} incremented to {key_manager.key_failure_counts[key]}.")
|
|
||||||
elif key in key_manager.key_failure_counts:
|
|
||||||
logger.warning(f"Key {log_key} reached MAX_FAILURES ({key_manager.MAX_FAILURES}). Not incrementing further.")
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"An error occurred during the scheduled key check: {str(e)}", exc_info=True)
|
|
||||||
|
|
||||||
def setup_scheduler():
|
|
||||||
"""设置并启动 APScheduler"""
|
|
||||||
scheduler = AsyncIOScheduler(timezone=str(settings.TIMEZONE)) # 从配置读取时区
|
|
||||||
# 添加定时任务,例如每小时执行一次 (可以调整)
|
|
||||||
scheduler.add_job(check_failed_keys, 'interval', hours=settings.CHECK_INTERVAL_HOURS)
|
|
||||||
scheduler.start()
|
|
||||||
logger.info(f"Scheduler started. Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s).")
|
|
||||||
return scheduler
|
|
||||||
|
|
||||||
# 可以在这里添加一个全局的 scheduler 实例,以便在应用关闭时优雅地停止
|
|
||||||
scheduler_instance = None
|
|
||||||
|
|
||||||
def start_scheduler():
|
|
||||||
global scheduler_instance
|
|
||||||
if scheduler_instance is None:
|
|
||||||
scheduler_instance = setup_scheduler()
|
|
||||||
|
|
||||||
def stop_scheduler():
|
|
||||||
global scheduler_instance
|
|
||||||
if scheduler_instance and scheduler_instance.running:
|
|
||||||
scheduler_instance.shutdown()
|
|
||||||
logger.info("Scheduler stopped.")
|
|
||||||
162
app/scheduler/scheduled_tasks.py
Normal file
162
app/scheduler/scheduled_tasks.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
from app.config.config import settings
|
||||||
|
from app.domain.gemini_models import GeminiContent, GeminiRequest
|
||||||
|
from app.log.logger import Logger
|
||||||
|
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||||
|
from app.service.error_log.error_log_service import delete_old_error_logs
|
||||||
|
from app.service.key.key_manager import get_key_manager_instance
|
||||||
|
from app.service.request_log.request_log_service import delete_old_request_logs_task
|
||||||
|
|
||||||
|
logger = Logger.setup_logger("scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_failed_keys():
|
||||||
|
"""
|
||||||
|
定时检查失败次数大于0的API密钥,并尝试验证它们。
|
||||||
|
如果验证成功,重置失败计数;如果失败,增加失败计数。
|
||||||
|
"""
|
||||||
|
logger.info("Starting scheduled check for failed API keys...")
|
||||||
|
try:
|
||||||
|
key_manager = await get_key_manager_instance()
|
||||||
|
# 确保 KeyManager 已经初始化
|
||||||
|
if not key_manager or not hasattr(key_manager, "key_failure_counts"):
|
||||||
|
logger.warning(
|
||||||
|
"KeyManager instance not available or not initialized. Skipping check."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 创建 GeminiChatService 实例用于验证
|
||||||
|
# 注意:这里直接创建实例,而不是通过依赖注入,因为这是后台任务
|
||||||
|
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||||
|
|
||||||
|
# 获取需要检查的 key 列表 (失败次数 > 0)
|
||||||
|
keys_to_check = []
|
||||||
|
async with key_manager.failure_count_lock: # 访问共享数据需要加锁
|
||||||
|
# 复制一份以避免在迭代时修改字典
|
||||||
|
failure_counts_copy = key_manager.key_failure_counts.copy()
|
||||||
|
keys_to_check = [
|
||||||
|
key for key, count in failure_counts_copy.items() if count > 0
|
||||||
|
] # 检查所有失败次数大于0的key
|
||||||
|
|
||||||
|
if not keys_to_check:
|
||||||
|
logger.info("No keys with failure count > 0 found. Skipping verification.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Found {len(keys_to_check)} keys with failure count > 0 to verify."
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in keys_to_check:
|
||||||
|
# 隐藏部分 key 用于日志记录
|
||||||
|
log_key = f"{key[:4]}...{key[-4:]}" if len(key) > 8 else key
|
||||||
|
logger.info(f"Verifying key: {log_key}...")
|
||||||
|
try:
|
||||||
|
# 构造测试请求
|
||||||
|
gemini_request = GeminiRequest(
|
||||||
|
contents=[
|
||||||
|
GeminiContent(
|
||||||
|
role="user",
|
||||||
|
parts=[{"text": "hi"}], # 使用简单的文本进行验证
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# 调用 generate_content 进行验证
|
||||||
|
await chat_service.generate_content(
|
||||||
|
settings.TEST_MODEL, gemini_request, key # 使用配置中定义的测试模型
|
||||||
|
)
|
||||||
|
# 如果没有抛出异常,说明 key 有效
|
||||||
|
logger.info(
|
||||||
|
f"Key {log_key} verification successful. Resetting failure count."
|
||||||
|
)
|
||||||
|
await key_manager.reset_key_failure_count(key)
|
||||||
|
except Exception as e:
|
||||||
|
# 验证失败,增加失败计数
|
||||||
|
logger.warning(
|
||||||
|
f"Key {log_key} verification failed: {str(e)}. Incrementing failure count."
|
||||||
|
)
|
||||||
|
# 直接操作计数器,需要加锁
|
||||||
|
async with key_manager.failure_count_lock:
|
||||||
|
# 再次检查 key 是否存在且失败次数未达上限
|
||||||
|
if (
|
||||||
|
key in key_manager.key_failure_counts
|
||||||
|
and key_manager.key_failure_counts[key]
|
||||||
|
< key_manager.MAX_FAILURES
|
||||||
|
):
|
||||||
|
key_manager.key_failure_counts[key] += 1
|
||||||
|
logger.info(
|
||||||
|
f"Failure count for key {log_key} incremented to {key_manager.key_failure_counts[key]}."
|
||||||
|
)
|
||||||
|
elif key in key_manager.key_failure_counts:
|
||||||
|
logger.warning(
|
||||||
|
f"Key {log_key} reached MAX_FAILURES ({key_manager.MAX_FAILURES}). Not incrementing further."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"An error occurred during the scheduled key check: {str(e)}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_scheduler():
|
||||||
|
"""设置并启动 APScheduler"""
|
||||||
|
scheduler = AsyncIOScheduler(timezone=str(settings.TIMEZONE)) # 从配置读取时区
|
||||||
|
# 添加检查失败密钥的定时任务
|
||||||
|
scheduler.add_job(
|
||||||
|
check_failed_keys,
|
||||||
|
"interval",
|
||||||
|
hours=settings.CHECK_INTERVAL_HOURS,
|
||||||
|
id="check_failed_keys_job",
|
||||||
|
name="Check Failed API Keys",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 新增:添加自动删除错误日志的定时任务,每天凌晨3点执行
|
||||||
|
scheduler.add_job(
|
||||||
|
delete_old_error_logs,
|
||||||
|
"cron",
|
||||||
|
hour=3,
|
||||||
|
minute=0,
|
||||||
|
id="delete_old_error_logs_job",
|
||||||
|
name="Delete Old Error Logs",
|
||||||
|
)
|
||||||
|
logger.info("Auto-delete error logs job scheduled to run daily at 3:00 AM.")
|
||||||
|
|
||||||
|
# 新增:添加自动删除请求日志的定时任务,每天凌晨3点05分执行
|
||||||
|
scheduler.add_job(
|
||||||
|
delete_old_request_logs_task,
|
||||||
|
"cron",
|
||||||
|
hour=3,
|
||||||
|
minute=5,
|
||||||
|
id="delete_old_request_logs_job",
|
||||||
|
name="Delete Old Request Logs",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Auto-delete request logs job scheduled to run daily at 3:05 AM, if enabled and AUTO_DELETE_REQUEST_LOGS_DAYS is set to {settings.AUTO_DELETE_REQUEST_LOGS_DAYS} days."
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("Scheduler started with all jobs.")
|
||||||
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
# 可以在这里添加一个全局的 scheduler 实例,以便在应用关闭时优雅地停止
|
||||||
|
scheduler_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
global scheduler_instance
|
||||||
|
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
|
||||||
|
if scheduler_instance and scheduler_instance.running:
|
||||||
|
scheduler_instance.shutdown()
|
||||||
|
logger.info("Scheduler stopped.")
|
||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import datetime # Add datetime import
|
import datetime
|
||||||
import time # Add time import
|
import time
|
||||||
from typing import Any, AsyncGenerator, Dict, List
|
from typing import Any, AsyncGenerator, Dict, List
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
|
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
|
||||||
from app.domain.gemini_models import GeminiRequest
|
from app.domain.gemini_models import GeminiRequest
|
||||||
from app.handler.response_handler import GeminiResponseHandler
|
from app.handler.response_handler import GeminiResponseHandler
|
||||||
from app.handler.stream_optimizer import gemini_optimizer
|
from app.handler.stream_optimizer import gemini_optimizer
|
||||||
from app.log.logger import get_gemini_logger
|
from app.log.logger import get_gemini_logger
|
||||||
from app.service.client.api_client import GeminiApiClient
|
from app.service.client.api_client import GeminiApiClient
|
||||||
from app.service.key.key_manager import KeyManager
|
from app.service.key.key_manager import KeyManager
|
||||||
from app.database.services import add_error_log, add_request_log # Import add_request_log
|
from app.database.services import add_error_log, add_request_log
|
||||||
|
|
||||||
logger = get_gemini_logger()
|
logger = get_gemini_logger()
|
||||||
|
|
||||||
@@ -73,20 +74,8 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
||||||
"""获取安全设置"""
|
"""获取安全设置"""
|
||||||
if model == "gemini-2.0-flash-exp":
|
if model == "gemini-2.0-flash-exp":
|
||||||
return [
|
return GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
|
||||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
return settings.SAFETY_SETTINGS
|
||||||
{"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"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
||||||
@@ -101,8 +90,8 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
|||||||
"contents": request_dict.get("contents", []),
|
"contents": request_dict.get("contents", []),
|
||||||
"tools": _build_tools(model, request_dict),
|
"tools": _build_tools(model, request_dict),
|
||||||
"safetySettings": _get_safety_settings(model),
|
"safetySettings": _get_safety_settings(model),
|
||||||
"generationConfig": request_dict.get("generationConfig", {}),
|
"generationConfig": request_dict.get("generationConfig"),
|
||||||
"systemInstruction": request_dict.get("systemInstruction", ""),
|
"systemInstruction": request_dict.get("systemInstruction"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.endswith("-image") or model.endswith("-image-generation"):
|
if model.endswith("-image") or model.endswith("-image-generation"):
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
# app/services/chat_service.py
|
# app/services/chat_service.py
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import datetime # Add datetime import
|
import time
|
||||||
import time # Add time import
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
|
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
|
||||||
|
from app.database.services import (
|
||||||
|
add_error_log,
|
||||||
|
add_request_log,
|
||||||
|
)
|
||||||
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
|
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
|
||||||
from app.handler.message_converter import OpenAIMessageConverter
|
from app.handler.message_converter import OpenAIMessageConverter
|
||||||
from app.handler.response_handler import OpenAIResponseHandler
|
from app.handler.response_handler import OpenAIResponseHandler
|
||||||
@@ -16,17 +22,16 @@ from app.log.logger import get_openai_logger
|
|||||||
from app.service.client.api_client import GeminiApiClient
|
from app.service.client.api_client import GeminiApiClient
|
||||||
from app.service.image.image_create_service import ImageCreateService
|
from app.service.image.image_create_service import ImageCreateService
|
||||||
from app.service.key.key_manager import KeyManager
|
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()
|
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:
|
for content in contents:
|
||||||
if "parts" in content:
|
if content and "parts" in content and isinstance(content["parts"], list):
|
||||||
for part in content["parts"]:
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -46,9 +51,13 @@ def _build_tools(
|
|||||||
or model.endswith("-image")
|
or model.endswith("-image")
|
||||||
or model.endswith("-image-generation")
|
or model.endswith("-image-generation")
|
||||||
)
|
)
|
||||||
and not _has_image_parts(messages)
|
and not _has_media_parts(messages) # Use the updated check
|
||||||
):
|
):
|
||||||
tool["codeExecution"] = {}
|
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"):
|
if model.endswith("-search"):
|
||||||
tool["googleSearch"] = {}
|
tool["googleSearch"] = {}
|
||||||
|
|
||||||
@@ -62,7 +71,9 @@ def _build_tools(
|
|||||||
if item.get("type", "") == "function" and item.get("function"):
|
if item.get("type", "") == "function" and item.get("function"):
|
||||||
function = deepcopy(item.get("function"))
|
function = deepcopy(item.get("function"))
|
||||||
parameters = function.get("parameters", {})
|
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.pop("parameters", None)
|
||||||
|
|
||||||
function_declarations.append(function)
|
function_declarations.append(function)
|
||||||
@@ -93,20 +104,8 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
|||||||
# and "gemini-2.0-pro-exp" not in model
|
# and "gemini-2.0-pro-exp" not in model
|
||||||
# ):
|
# ):
|
||||||
if model == "gemini-2.0-flash-exp":
|
if model == "gemini-2.0-flash-exp":
|
||||||
return [
|
return GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
|
||||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
return settings.SAFETY_SETTINGS
|
||||||
{"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"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(
|
def _build_payload(
|
||||||
@@ -131,9 +130,11 @@ def _build_payload(
|
|||||||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||||
if request.model.endswith("-non-thinking"):
|
if request.model.endswith("-non-thinking"):
|
||||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||||
if request.model in settings.THINKING_BUDGET_MAP:
|
if request.model in settings.THINKING_BUDGET_MAP:
|
||||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model,1000)}
|
payload["generationConfig"]["thinkingConfig"] = {
|
||||||
|
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
instruction
|
instruction
|
||||||
@@ -204,10 +205,15 @@ class OpenAIChatService:
|
|||||||
response = None
|
response = None
|
||||||
try:
|
try:
|
||||||
response = await self.api_client.generate_content(payload, model, api_key)
|
response = await self.api_client.generate_content(payload, model, api_key)
|
||||||
|
usage_metadata = response.get("usageMetadata", {})
|
||||||
is_success = True
|
is_success = True
|
||||||
status_code = 200 # Assume 200 on success
|
status_code = 200
|
||||||
return self.response_handler.handle_response(
|
return self.response_handler.handle_response(
|
||||||
response, model, stream=False, finish_reason="stop"
|
response,
|
||||||
|
model,
|
||||||
|
stream=False,
|
||||||
|
finish_reason="stop",
|
||||||
|
usage_metadata=usage_metadata,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
is_success = False
|
is_success = False
|
||||||
@@ -218,17 +224,17 @@ class OpenAIChatService:
|
|||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
else:
|
else:
|
||||||
status_code = 500 # Default if parsing fails
|
status_code = 500
|
||||||
|
|
||||||
await add_error_log(
|
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,
|
model_name=model,
|
||||||
error_type="openai-chat-non-stream",
|
error_type="openai-chat-non-stream",
|
||||||
error_log=error_log_msg,
|
error_log=error_log_msg,
|
||||||
error_code=status_code,
|
error_code=status_code,
|
||||||
request_msg=payload
|
request_msg=payload,
|
||||||
)
|
)
|
||||||
raise e # Re-throw exception
|
raise e
|
||||||
finally:
|
finally:
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
@@ -238,13 +244,122 @@ class OpenAIChatService:
|
|||||||
is_success=is_success,
|
is_success=is_success,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
request_time=request_datetime
|
request_time=request_datetime,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _fake_stream_logic_impl(
|
||||||
|
self, model: str, payload: Dict[str, Any], api_key: str
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""处理伪流式 (fake stream) 的核心逻辑"""
|
||||||
|
logger.info(
|
||||||
|
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
|
||||||
|
)
|
||||||
|
keep_sending_empty_data = True
|
||||||
|
|
||||||
|
async def send_empty_data_locally() -> AsyncGenerator[str, None]:
|
||||||
|
"""定期发送空数据以保持连接"""
|
||||||
|
while keep_sending_empty_data:
|
||||||
|
await asyncio.sleep(settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS)
|
||||||
|
if keep_sending_empty_data:
|
||||||
|
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
||||||
|
yield f"data: {json.dumps(empty_chunk)}\n\n"
|
||||||
|
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
||||||
|
|
||||||
|
empty_data_generator = send_empty_data_locally()
|
||||||
|
api_response_task = asyncio.create_task(
|
||||||
|
self.api_client.generate_content(payload, model, api_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not api_response_task.done():
|
||||||
|
try:
|
||||||
|
next_empty_chunk = await asyncio.wait_for(
|
||||||
|
empty_data_generator.__anext__(), timeout=0.1
|
||||||
|
)
|
||||||
|
yield next_empty_chunk
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
except (
|
||||||
|
StopAsyncIteration
|
||||||
|
):
|
||||||
|
break
|
||||||
|
|
||||||
|
response = await api_response_task
|
||||||
|
finally:
|
||||||
|
keep_sending_empty_data = False
|
||||||
|
|
||||||
|
if response and response.get("candidates"):
|
||||||
|
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {}))
|
||||||
|
yield f"data: {json.dumps(response)}\n\n"
|
||||||
|
logger.info(f"Sent full response content for fake stream: {model}")
|
||||||
|
else:
|
||||||
|
error_message = "Failed to get response from model"
|
||||||
|
if (
|
||||||
|
response and isinstance(response, dict) and response.get("error")
|
||||||
|
):
|
||||||
|
error_details = response.get("error")
|
||||||
|
if isinstance(error_details, dict):
|
||||||
|
error_message = error_details.get("message", error_message)
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"No candidates or error in response for fake stream model {model}: {response}"
|
||||||
|
)
|
||||||
|
error_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
||||||
|
yield f"data: {json.dumps(error_chunk)}\n\n"
|
||||||
|
|
||||||
|
async def _real_stream_logic_impl(
|
||||||
|
self, model: str, payload: Dict[str, Any], api_key: str
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""处理真实流式 (real stream) 的核心逻辑"""
|
||||||
|
tool_call_flag = False
|
||||||
|
usage_metadata = None
|
||||||
|
async for line in self.api_client.stream_generate_content(
|
||||||
|
payload, model, api_key
|
||||||
|
):
|
||||||
|
if line.startswith("data:"):
|
||||||
|
chunk_str = line[6:]
|
||||||
|
if not chunk_str or chunk_str.isspace():
|
||||||
|
logger.debug(
|
||||||
|
f"Received empty data line for model {model}, skipping."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = json.loads(chunk_str)
|
||||||
|
usage_metadata = chunk.get("usageMetadata", {})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to decode JSON from stream for model {model}: {chunk_str}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
openai_chunk = self.response_handler.handle_response(
|
||||||
|
chunk, model, stream=True, finish_reason=None, usage_metadata=usage_metadata
|
||||||
|
)
|
||||||
|
if openai_chunk:
|
||||||
|
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||||
|
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||||||
|
async for (
|
||||||
|
optimized_chunk_data
|
||||||
|
) 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_data
|
||||||
|
else:
|
||||||
|
if openai_chunk.get("choices") and openai_chunk["choices"][0].get("delta", {}).get("tool_calls"):
|
||||||
|
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', usage_metadata=usage_metadata))}\n\n"
|
||||||
|
else:
|
||||||
|
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=usage_metadata))}\n\n"
|
||||||
|
|
||||||
async def _handle_stream_completion(
|
async def _handle_stream_completion(
|
||||||
self, model: str, payload: Dict[str, Any], api_key: str
|
self, model: str, payload: Dict[str, Any], api_key: str
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""处理流式聊天完成,添加重试逻辑"""
|
"""处理流式聊天完成,添加重试逻辑和假流式支持"""
|
||||||
retries = 0
|
retries = 0
|
||||||
max_retries = settings.MAX_RETRIES
|
max_retries = settings.MAX_RETRIES
|
||||||
is_success = False
|
is_success = False
|
||||||
@@ -254,110 +369,107 @@ class OpenAIChatService:
|
|||||||
while retries < max_retries:
|
while retries < max_retries:
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
request_datetime = datetime.datetime.now()
|
request_datetime = datetime.datetime.now()
|
||||||
current_attempt_key = api_key
|
current_attempt_key = final_api_key
|
||||||
final_api_key = current_attempt_key
|
|
||||||
try:
|
try:
|
||||||
tool_call_flag = False
|
stream_generator = None
|
||||||
async for line in self.api_client.stream_generate_content(
|
if settings.FAKE_STREAM_ENABLED:
|
||||||
payload, model, current_attempt_key
|
logger.info(
|
||||||
):
|
f"Using fake stream logic for model: {model}, Attempt: {retries + 1}"
|
||||||
if line.startswith("data:"):
|
)
|
||||||
chunk = json.loads(line[6:])
|
stream_generator = self._fake_stream_logic_impl(
|
||||||
openai_chunk = self.response_handler.handle_response(
|
model, payload, current_attempt_key
|
||||||
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:
|
else:
|
||||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
logger.info(
|
||||||
|
f"Using real stream logic for model: {model}, Attempt: {retries + 1}"
|
||||||
|
)
|
||||||
|
stream_generator = self._real_stream_logic_impl(
|
||||||
|
model, payload, current_attempt_key
|
||||||
|
)
|
||||||
|
|
||||||
|
async for chunk_data in stream_generator:
|
||||||
|
yield chunk_data
|
||||||
|
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
logger.info("Streaming completed successfully")
|
logger.info(
|
||||||
|
f"Streaming completed successfully for model: {model}, FakeStream: {settings.FAKE_STREAM_ENABLED}, Attempt: {retries + 1}"
|
||||||
|
)
|
||||||
is_success = True
|
is_success = True
|
||||||
status_code = 200 # Assume 200 on success
|
status_code = 200
|
||||||
break # 成功后退出循环
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
retries += 1
|
retries += 1
|
||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = str(e)
|
error_log_msg = str(e)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries} with key {current_attempt_key}"
|
||||||
)
|
)
|
||||||
# Parse error code for logging
|
|
||||||
match = re.search(r"status code (\d+)", error_log_msg)
|
match = re.search(r"status code (\\d+)", error_log_msg)
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
status_code = int(match.group(1))
|
||||||
else:
|
else:
|
||||||
status_code = 500 # Default if parsing fails
|
if isinstance(e, asyncio.TimeoutError):
|
||||||
|
status_code = 408
|
||||||
|
else:
|
||||||
|
status_code = 500
|
||||||
|
|
||||||
# Log error to error log table
|
|
||||||
await add_error_log(
|
await add_error_log(
|
||||||
gemini_key=current_attempt_key,
|
gemini_key=current_attempt_key,
|
||||||
model_name=model,
|
model_name=model,
|
||||||
error_type="openai-chat-stream",
|
error_type="openai-chat-stream",
|
||||||
error_log=error_log_msg,
|
error_log=error_log_msg,
|
||||||
error_code=status_code,
|
error_code=status_code,
|
||||||
request_msg=payload
|
request_msg=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attempt to switch API Key
|
|
||||||
# Ensure key_manager is available (might need adjustment if not always passed)
|
|
||||||
if self.key_manager:
|
if self.key_manager:
|
||||||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
new_api_key = await self.key_manager.handle_api_failure(
|
||||||
if api_key:
|
current_attempt_key, retries
|
||||||
logger.info(f"Switched to new API key: {api_key}")
|
)
|
||||||
else:
|
if new_api_key and new_api_key != current_attempt_key:
|
||||||
logger.error(f"No valid API key available after {retries} retries.")
|
final_api_key = new_api_key
|
||||||
break # Exit loop if no key available
|
logger.info(
|
||||||
|
f"Switched to new API key for next attempt: {final_api_key}"
|
||||||
|
)
|
||||||
|
elif not new_api_key:
|
||||||
|
logger.error(
|
||||||
|
f"No valid API key available after {retries} retries, ceasing attempts for this request."
|
||||||
|
)
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
logger.error("KeyManager not available for retry logic.")
|
logger.error(
|
||||||
break # Exit loop if key manager is missing
|
"KeyManager not available, cannot switch API key. Ceasing attempts for this request."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
if retries >= max_retries:
|
if retries >= max_retries:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Max retries ({max_retries}) reached for streaming."
|
f"Max retries ({max_retries}) reached for streaming model {model}."
|
||||||
)
|
)
|
||||||
break # Exit loop after max retries
|
|
||||||
finally:
|
finally:
|
||||||
# Log the final outcome of the streaming request
|
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
api_key=final_api_key, # Log the last key used
|
api_key=current_attempt_key,
|
||||||
is_success=is_success, # Log the final success status
|
is_success=is_success,
|
||||||
status_code=status_code, # Log the last known status code
|
status_code=status_code,
|
||||||
latency_ms=latency_ms, # Log total time including retries
|
latency_ms=latency_ms,
|
||||||
request_time=request_datetime
|
request_time=request_datetime,
|
||||||
)
|
)
|
||||||
# If the loop finished due to failure, yield error and DONE
|
|
||||||
if not is_success and retries >= max_retries:
|
if not is_success:
|
||||||
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
|
logger.error(
|
||||||
yield "data: [DONE]\n\n"
|
f"Streaming failed permanently for model {model} after {retries} attempts."
|
||||||
|
)
|
||||||
|
yield f"data: {json.dumps({'error': f'Streaming failed after {retries} retries.'})}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
async def create_image_chat_completion(
|
async def create_image_chat_completion(
|
||||||
self,
|
self, request: ChatRequest, api_key: str
|
||||||
request: ChatRequest,
|
|
||||||
api_key: str
|
|
||||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||||
|
|
||||||
image_generate_request = ImageGenerationRequest()
|
image_generate_request = ImageGenerationRequest()
|
||||||
@@ -367,18 +479,22 @@ class OpenAIChatService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
return self._handle_stream_image_completion(request.model, image_res, api_key)
|
return self._handle_stream_image_completion(
|
||||||
|
request.model, image_res, api_key
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return await self._handle_normal_image_completion(request.model, image_res, api_key)
|
return await self._handle_normal_image_completion(
|
||||||
|
request.model, image_res, api_key
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_stream_image_completion(
|
async def _handle_stream_image_completion(
|
||||||
self, model: str, image_data: str, api_key:str
|
self, model: str, image_data: str, api_key: str
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
logger.info(f"Starting stream image completion for model: {model}")
|
logger.info(f"Starting stream image completion for model: {model}")
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
request_datetime = datetime.datetime.now() # Although not used for DB log here
|
request_datetime = datetime.datetime.now()
|
||||||
is_success = False
|
is_success = False
|
||||||
status_code = None # Although not used for DB log here
|
status_code = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if image_data:
|
if image_data:
|
||||||
@@ -402,7 +518,9 @@ class OpenAIChatService:
|
|||||||
# 如果没有文本内容(如图片URL等),整块输出
|
# 如果没有文本内容(如图片URL等),整块输出
|
||||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
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 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}")
|
logger.info(
|
||||||
|
f"Stream image completion finished successfully for model: {model}"
|
||||||
|
)
|
||||||
is_success = True
|
is_success = True
|
||||||
status_code = 200
|
status_code = 200
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
@@ -410,46 +528,49 @@ class OpenAIChatService:
|
|||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = f"Stream image completion failed for model {model}: {e}"
|
error_log_msg = f"Stream image completion failed for model {model}: {e}"
|
||||||
logger.error(error_log_msg)
|
logger.error(error_log_msg)
|
||||||
status_code = 500 # Default error code
|
status_code = 500
|
||||||
await add_error_log(
|
await add_error_log(
|
||||||
gemini_key=api_key,
|
gemini_key=api_key,
|
||||||
model_name=model,
|
model_name=model,
|
||||||
error_type="openai-image-stream", # Specific error type
|
error_type="openai-image-stream",
|
||||||
error_log=error_log_msg,
|
error_log=error_log_msg,
|
||||||
error_code=status_code,
|
error_code=status_code,
|
||||||
request_msg={"image_data_truncated": image_data[:1000]} # Log truncated data
|
request_msg={"image_data_truncated": image_data[:1000]},
|
||||||
)
|
)
|
||||||
yield f"data: {json.dumps({'error': error_log_msg})}\n\n" # Send error to client
|
yield f"data: {json.dumps({'error': error_log_msg})}\n\n"
|
||||||
yield "data: [DONE]\n\n" # Still need DONE message
|
yield "data: [DONE]\n\n"
|
||||||
# Re-raising might break the stream, decide if needed
|
|
||||||
finally:
|
finally:
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
logger.info(f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}")
|
logger.info(
|
||||||
|
f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}"
|
||||||
|
)
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
is_success=is_success,
|
is_success=is_success,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
request_time=request_datetime
|
request_time=request_datetime,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_normal_image_completion(
|
async def _handle_normal_image_completion(
|
||||||
self, model: str, image_data: str, api_key: str # Add api_key parameter
|
self, model: str, image_data: str, api_key: str
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
logger.info(f"Starting normal image completion for model: {model}")
|
logger.info(f"Starting normal image completion for model: {model}")
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
request_datetime = datetime.datetime.now() # Although not used for DB log here
|
request_datetime = datetime.datetime.now()
|
||||||
is_success = False
|
is_success = False
|
||||||
status_code = None # Although not used for DB log here
|
status_code = None
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.response_handler.handle_image_chat_response(
|
result = self.response_handler.handle_image_chat_response(
|
||||||
image_data, model, stream=False, finish_reason="stop"
|
image_data, model, stream=False, finish_reason="stop"
|
||||||
)
|
)
|
||||||
logger.info(f"Normal image completion finished successfully for model: {model}")
|
logger.info(
|
||||||
|
f"Normal image completion finished successfully for model: {model}"
|
||||||
|
)
|
||||||
is_success = True
|
is_success = True
|
||||||
status_code = 200
|
status_code = 200
|
||||||
return result
|
return result
|
||||||
@@ -457,26 +578,28 @@ class OpenAIChatService:
|
|||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = f"Normal image completion failed for model {model}: {e}"
|
error_log_msg = f"Normal image completion failed for model {model}: {e}"
|
||||||
logger.error(error_log_msg)
|
logger.error(error_log_msg)
|
||||||
status_code = 500 # Default error code
|
status_code = 500
|
||||||
await add_error_log(
|
await add_error_log(
|
||||||
gemini_key=api_key,
|
gemini_key=api_key,
|
||||||
model_name=model,
|
model_name=model,
|
||||||
error_type="openai-image-non-stream", # Specific error type
|
error_type="openai-image-non-stream",
|
||||||
error_log=error_log_msg,
|
error_log=error_log_msg,
|
||||||
error_code=status_code,
|
error_code=status_code,
|
||||||
request_msg={"image_data_truncated": image_data[:1000]} # Log truncated data
|
request_msg={"image_data_truncated": image_data[:1000]},
|
||||||
)
|
)
|
||||||
# Re-raise the exception so the caller knows about the failure
|
# Re-raise the exception so the caller knows about the failure
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
logger.info(f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}")
|
logger.info(
|
||||||
|
f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}"
|
||||||
|
)
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
is_success=is_success,
|
is_success=is_success,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
request_time=request_datetime
|
request_time=request_datetime,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# app/services/chat/api_client.py
|
# app/services/chat/api_client.py
|
||||||
|
|
||||||
from typing import Dict, Any, AsyncGenerator
|
from typing import Dict, Any, AsyncGenerator, Optional
|
||||||
import httpx
|
import httpx
|
||||||
|
import random
|
||||||
from abc import ABC, abstractmethod
|
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
|
from app.core.constants import DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
logger = get_api_client_logger()
|
||||||
|
|
||||||
class ApiClient(ABC):
|
class ApiClient(ABC):
|
||||||
"""API客户端基类"""
|
"""API客户端基类"""
|
||||||
@@ -37,11 +40,41 @@ class GeminiApiClient(ApiClient):
|
|||||||
model = model[:-20]
|
model = model[:-20]
|
||||||
return model
|
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]:
|
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)
|
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||||
model = self._get_real_model(model)
|
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}"
|
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
|
||||||
response = await client.post(url, json=payload)
|
response = await client.post(url, json=payload)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
@@ -53,7 +86,12 @@ class GeminiApiClient(ApiClient):
|
|||||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||||
model = self._get_real_model(model)
|
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}"
|
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:
|
async with client.stream(method="POST", url=url, json=payload) as response:
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
@@ -62,3 +100,96 @@ class GeminiApiClient(ApiClient):
|
|||||||
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
|
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
yield line
|
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()
|
||||||
@@ -1,41 +1,49 @@
|
|||||||
"""
|
"""
|
||||||
配置服务模块
|
配置服务模块
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from dotenv import find_dotenv, load_dotenv
|
from dotenv import find_dotenv, load_dotenv
|
||||||
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import insert, update
|
from sqlalchemy import insert, update
|
||||||
|
|
||||||
|
from app.config.config import Settings as ConfigSettings
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.database.connection import database
|
from app.database.connection import database
|
||||||
from app.database.models import Settings
|
from app.database.models import Settings
|
||||||
from app.config.config import Settings as ConfigSettings
|
|
||||||
from app.database.services import get_all_settings
|
from app.database.services import get_all_settings
|
||||||
from app.service.key.key_manager import get_key_manager_instance, reset_key_manager_instance
|
|
||||||
from app.log.logger import get_config_routes_logger
|
from app.log.logger import get_config_routes_logger
|
||||||
|
from app.service.key.key_manager import (
|
||||||
|
get_key_manager_instance,
|
||||||
|
reset_key_manager_instance,
|
||||||
|
)
|
||||||
|
from app.service.model.model_service import ModelService
|
||||||
|
|
||||||
logger = get_config_routes_logger()
|
logger = get_config_routes_logger()
|
||||||
|
|
||||||
|
|
||||||
class ConfigService:
|
class ConfigService:
|
||||||
"""配置服务类,用于管理应用程序配置"""
|
"""配置服务类,用于管理应用程序配置"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_config() -> Dict[str, Any]:
|
async def get_config() -> Dict[str, Any]:
|
||||||
return settings.model_dump()
|
return settings.model_dump()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
|
async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
for key, value in config_data.items():
|
for key, value in config_data.items():
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
logger.debug(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()
|
existing_settings_raw: List[Dict[str, Any]] = await get_all_settings()
|
||||||
existing_settings_map: Dict[str, Dict[str, Any]] = {s['key']: s for s in existing_settings_raw}
|
existing_settings_map: Dict[str, Dict[str, Any]] = {
|
||||||
|
s["key"]: s for s in existing_settings_raw
|
||||||
|
}
|
||||||
existing_keys = set(existing_settings_map.keys())
|
existing_keys = set(existing_settings_map.keys())
|
||||||
|
|
||||||
settings_to_update: List[Dict[str, Any]] = []
|
settings_to_update: List[Dict[str, Any]] = []
|
||||||
@@ -47,7 +55,7 @@ class ConfigService:
|
|||||||
# 处理不同类型的值
|
# 处理不同类型的值
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
db_value = json.dumps(value)
|
db_value = json.dumps(value)
|
||||||
elif isinstance(value, dict): # 新增对 dict 类型的处理
|
elif isinstance(value, dict): # 新增对 dict 类型的处理
|
||||||
db_value = json.dumps(value)
|
db_value = json.dumps(value)
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
db_value = str(value).lower()
|
db_value = str(value).lower()
|
||||||
@@ -55,24 +63,26 @@ class ConfigService:
|
|||||||
db_value = str(value)
|
db_value = str(value)
|
||||||
|
|
||||||
# 仅当值发生变化时才更新
|
# 仅当值发生变化时才更新
|
||||||
if key in existing_keys and existing_settings_map[key]['value'] == db_value:
|
if key in existing_keys and existing_settings_map[key]["value"] == db_value:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
description = f"{key}配置项"
|
description = f"{key}配置项"
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'key': key,
|
"key": key,
|
||||||
'value': db_value,
|
"value": db_value,
|
||||||
'description': description,
|
"description": description,
|
||||||
'updated_at': now
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if key in existing_keys:
|
if key in existing_keys:
|
||||||
# Preserve original description if not explicitly provided
|
# Preserve original description if not explicitly provided
|
||||||
data['description'] = existing_settings_map[key].get('description', description)
|
data["description"] = existing_settings_map[key].get(
|
||||||
|
"description", description
|
||||||
|
)
|
||||||
settings_to_update.append(data)
|
settings_to_update.append(data)
|
||||||
else:
|
else:
|
||||||
data['created_at'] = now
|
data["created_at"] = now
|
||||||
settings_to_insert.append(data)
|
settings_to_insert.append(data)
|
||||||
|
|
||||||
# 在事务中执行批量插入和更新
|
# 在事务中执行批量插入和更新
|
||||||
@@ -82,17 +92,19 @@ class ConfigService:
|
|||||||
if settings_to_insert:
|
if settings_to_insert:
|
||||||
query_insert = insert(Settings).values(settings_to_insert)
|
query_insert = insert(Settings).values(settings_to_insert)
|
||||||
await database.execute(query=query_insert)
|
await database.execute(query=query_insert)
|
||||||
logger.info(f"Bulk inserted {len(settings_to_insert)} settings.")
|
logger.info(
|
||||||
|
f"Bulk inserted {len(settings_to_insert)} settings."
|
||||||
|
)
|
||||||
|
|
||||||
if settings_to_update:
|
if settings_to_update:
|
||||||
for setting_data in settings_to_update:
|
for setting_data in settings_to_update:
|
||||||
query_update = (
|
query_update = (
|
||||||
update(Settings)
|
update(Settings)
|
||||||
.where(Settings.key == setting_data['key'])
|
.where(Settings.key == setting_data["key"])
|
||||||
.values(
|
.values(
|
||||||
value=setting_data['value'],
|
value=setting_data["value"],
|
||||||
description=setting_data['description'],
|
description=setting_data["description"],
|
||||||
updated_at=setting_data['updated_at']
|
updated_at=setting_data["updated_at"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await database.execute(query=query_update)
|
await database.execute(query=query_update)
|
||||||
@@ -112,7 +124,79 @@ class ConfigService:
|
|||||||
# For now, we log the error and continue
|
# For now, we log the error and continue
|
||||||
|
|
||||||
return await ConfigService.get_config()
|
return await ConfigService.get_config()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_key(key_to_delete: str) -> Dict[str, Any]:
|
||||||
|
"""删除单个API密钥"""
|
||||||
|
# 确保 settings.API_KEYS 是一个列表
|
||||||
|
if not isinstance(settings.API_KEYS, list):
|
||||||
|
settings.API_KEYS = []
|
||||||
|
|
||||||
|
original_keys_count = len(settings.API_KEYS)
|
||||||
|
# 创建一个不包含待删除密钥的新列表
|
||||||
|
updated_api_keys = [k for k in settings.API_KEYS if k != key_to_delete]
|
||||||
|
|
||||||
|
if len(updated_api_keys) < original_keys_count:
|
||||||
|
# 密钥已找到并从列表中移除
|
||||||
|
settings.API_KEYS = updated_api_keys # 首先更新内存中的 settings
|
||||||
|
# 使用 update_config 持久化更改,它同时处理数据库和 KeyManager
|
||||||
|
await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
|
||||||
|
logger.info(f"密钥 '{key_to_delete}' 已成功删除。")
|
||||||
|
return {"success": True, "message": f"密钥 '{key_to_delete}' 已成功删除。"}
|
||||||
|
else:
|
||||||
|
# 未找到密钥
|
||||||
|
logger.warning(f"尝试删除密钥 '{key_to_delete}',但未找到该密钥。")
|
||||||
|
return {"success": False, "message": f"未找到密钥 '{key_to_delete}'。"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_selected_keys(keys_to_delete: List[str]) -> Dict[str, Any]:
|
||||||
|
"""批量删除选定的API密钥"""
|
||||||
|
if not isinstance(settings.API_KEYS, list):
|
||||||
|
settings.API_KEYS = []
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
not_found_keys: List[str] = []
|
||||||
|
|
||||||
|
current_api_keys = list(settings.API_KEYS) # 创建副本以进行修改
|
||||||
|
keys_actually_removed: List[str] = []
|
||||||
|
|
||||||
|
for key_to_del in keys_to_delete:
|
||||||
|
if key_to_del in current_api_keys:
|
||||||
|
current_api_keys.remove(key_to_del)
|
||||||
|
keys_actually_removed.append(key_to_del)
|
||||||
|
deleted_count += 1
|
||||||
|
else:
|
||||||
|
not_found_keys.append(key_to_del)
|
||||||
|
|
||||||
|
if deleted_count > 0:
|
||||||
|
settings.API_KEYS = current_api_keys # 更新内存中的 settings
|
||||||
|
await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
|
||||||
|
logger.info(
|
||||||
|
f"成功删除 {deleted_count} 个密钥。密钥: {keys_actually_removed}"
|
||||||
|
)
|
||||||
|
message = f"成功删除 {deleted_count} 个密钥。"
|
||||||
|
if not_found_keys:
|
||||||
|
message += f" {len(not_found_keys)} 个密钥未找到: {not_found_keys}。"
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": message,
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"not_found_keys": not_found_keys,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
message = "没有密钥被删除。"
|
||||||
|
if not_found_keys: # 如果提供了密钥但都未找到
|
||||||
|
message = f"所有 {len(not_found_keys)} 个指定的密钥均未找到: {not_found_keys}。"
|
||||||
|
elif not keys_to_delete: # 如果 keys_to_delete 列表为空
|
||||||
|
message = "未指定要删除的密钥。"
|
||||||
|
logger.warning(message)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": message,
|
||||||
|
"deleted_count": 0,
|
||||||
|
"not_found_keys": not_found_keys,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def reset_config() -> Dict[str, Any]:
|
async def reset_config() -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -124,7 +208,9 @@ class ConfigService:
|
|||||||
"""
|
"""
|
||||||
# 1. 重新加载配置对象,它应该处理环境变量和 .env 的优先级
|
# 1. 重新加载配置对象,它应该处理环境变量和 .env 的优先级
|
||||||
_reload_settings()
|
_reload_settings()
|
||||||
logger.info("Settings object reloaded, prioritizing system environment variables then .env file.")
|
logger.info(
|
||||||
|
"Settings object reloaded, prioritizing system environment variables then .env file."
|
||||||
|
)
|
||||||
|
|
||||||
# 2. 重置并重新初始化 KeyManager
|
# 2. 重置并重新初始化 KeyManager
|
||||||
try:
|
try:
|
||||||
@@ -140,6 +226,36 @@ class ConfigService:
|
|||||||
# 3. 返回更新后的配置
|
# 3. 返回更新后的配置
|
||||||
return await ConfigService.get_config()
|
return await ConfigService.get_config()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def fetch_ui_models() -> List[Dict[str, Any]]:
|
||||||
|
"""获取用于UI显示的模型列表"""
|
||||||
|
try:
|
||||||
|
key_manager = await get_key_manager_instance()
|
||||||
|
model_service = ModelService()
|
||||||
|
|
||||||
|
api_key = await key_manager.get_first_valid_key()
|
||||||
|
if not api_key:
|
||||||
|
logger.error("No valid API keys available to fetch model list for UI.")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="No valid API keys available to fetch model list.",
|
||||||
|
)
|
||||||
|
|
||||||
|
models = await model_service.get_gemini_openai_models(api_key)
|
||||||
|
return models
|
||||||
|
except HTTPException as e:
|
||||||
|
# Re-raise HTTPExceptions directly if they are already specific
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
# Raise a generic HTTPException for other errors
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# 重新加载配置的函数
|
# 重新加载配置的函数
|
||||||
def _reload_settings():
|
def _reload_settings():
|
||||||
"""重新加载环境变量并更新配置"""
|
"""重新加载环境变量并更新配置"""
|
||||||
@@ -147,4 +263,4 @@ def _reload_settings():
|
|||||||
load_dotenv(find_dotenv(), override=True)
|
load_dotenv(find_dotenv(), override=True)
|
||||||
# 更新现有 settings 对象的属性,而不是新建实例
|
# 更新现有 settings 对象的属性,而不是新建实例
|
||||||
for key, value in ConfigSettings().model_dump().items():
|
for key, value in ConfigSettings().model_dump().items():
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class EmbeddingService:
|
|||||||
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
|
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
|
||||||
response = client.embeddings.create(input=input_text, model=model)
|
response = client.embeddings.create(input=input_text, model=model)
|
||||||
is_success = True
|
is_success = True
|
||||||
status_code = 200 # Assume 200 OK on success
|
status_code = 200
|
||||||
return response
|
return response
|
||||||
except APIStatusError as e:
|
except APIStatusError as e:
|
||||||
is_success = False
|
is_success = False
|
||||||
|
|||||||
155
app/service/error_log/error_log_service.py
Normal file
155
app/service/error_log/error_log_service.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
|
from app.config.config import settings
|
||||||
|
from app.database import services as db_services
|
||||||
|
from app.database.connection import database
|
||||||
|
from app.database.models import ErrorLog
|
||||||
|
from app.log.logger import get_error_log_logger
|
||||||
|
|
||||||
|
logger = get_error_log_logger()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_old_error_logs():
|
||||||
|
"""
|
||||||
|
Deletes error logs older than a specified number of days,
|
||||||
|
based on the AUTO_DELETE_ERROR_LOGS_ENABLED and AUTO_DELETE_ERROR_LOGS_DAYS settings.
|
||||||
|
"""
|
||||||
|
if not settings.AUTO_DELETE_ERROR_LOGS_ENABLED:
|
||||||
|
logger.info("Auto-deletion of error logs is disabled. Skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
days_to_keep = settings.AUTO_DELETE_ERROR_LOGS_DAYS
|
||||||
|
if not isinstance(days_to_keep, int) or days_to_keep <= 0:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid AUTO_DELETE_ERROR_LOGS_DAYS value: {days_to_keep}. Must be a positive integer. Skipping deletion."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Attempting to delete error logs older than {days_to_keep} days (before {cutoff_date.strftime('%Y-%m-%d %H:%M:%S %Z')})."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not database.is_connected:
|
||||||
|
await database.connect()
|
||||||
|
logger.info("Database connection established for deleting error logs.")
|
||||||
|
|
||||||
|
# First, count how many logs will be deleted (optional, for logging)
|
||||||
|
count_query = select(func.count(ErrorLog.id)).where(
|
||||||
|
ErrorLog.request_time < cutoff_date
|
||||||
|
)
|
||||||
|
num_logs_to_delete = await database.fetch_val(count_query)
|
||||||
|
|
||||||
|
if num_logs_to_delete == 0:
|
||||||
|
logger.info(
|
||||||
|
"No error logs found older than the specified period. No deletion needed."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Found {num_logs_to_delete} error logs to delete.")
|
||||||
|
|
||||||
|
# Perform the deletion
|
||||||
|
query = delete(ErrorLog).where(ErrorLog.request_time < cutoff_date)
|
||||||
|
await database.execute(query)
|
||||||
|
logger.info(
|
||||||
|
f"Successfully deleted {num_logs_to_delete} error logs older than {days_to_keep} days."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error during automatic deletion of error logs: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_get_error_logs(
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
key_search: Optional[str],
|
||||||
|
error_search: Optional[str],
|
||||||
|
error_code_search: Optional[str],
|
||||||
|
start_date: Optional[datetime],
|
||||||
|
end_date: Optional[datetime],
|
||||||
|
sort_by: str,
|
||||||
|
sort_order: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
处理错误日志的检索,支持分页和过滤。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logs_data = await db_services.get_error_logs(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
key_search=key_search,
|
||||||
|
error_search=error_search,
|
||||||
|
error_code_search=error_code_search,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
total_count = await db_services.get_error_logs_count(
|
||||||
|
key_search=key_search,
|
||||||
|
error_search=error_search,
|
||||||
|
error_code_search=error_code_search,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
return {"logs": logs_data, "total": total_count}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Service error in process_get_error_logs: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def process_get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
处理特定错误日志详细信息的检索。
|
||||||
|
如果未找到,则返回 None。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
log_details = await db_services.get_error_log_details(log_id=log_id)
|
||||||
|
return log_details
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Service error in process_get_error_log_details for ID {log_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||||
|
"""
|
||||||
|
按 ID 批量删除错误日志。
|
||||||
|
返回尝试删除的日志数量。
|
||||||
|
"""
|
||||||
|
if not log_ids:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
deleted_count = await db_services.delete_error_logs_by_ids(log_ids)
|
||||||
|
return deleted_count
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Service error in process_delete_error_logs_by_ids for IDs {log_ids}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def process_delete_error_log_by_id(log_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
按 ID 删除单个错误日志。
|
||||||
|
如果删除成功(或找到日志并尝试删除),则返回 True,否则返回 False。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await db_services.delete_error_log_by_id(log_id)
|
||||||
|
return success
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Service error in process_delete_error_log_by_id for ID {log_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.log.logger import get_key_manager_logger
|
from app.log.logger import get_key_manager_logger
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ class KeyManager:
|
|||||||
async with self.failure_count_lock:
|
async with self.failure_count_lock:
|
||||||
for key in self.key_failure_counts:
|
for key in self.key_failure_counts:
|
||||||
self.key_failure_counts[key] = 0
|
self.key_failure_counts[key] = 0
|
||||||
|
|
||||||
async def reset_key_failure_count(self, key: str) -> bool:
|
async def reset_key_failure_count(self, key: str) -> bool:
|
||||||
"""重置指定key的失败计数"""
|
"""重置指定key的失败计数"""
|
||||||
async with self.failure_count_lock:
|
async with self.failure_count_lock:
|
||||||
@@ -45,7 +44,9 @@ class KeyManager:
|
|||||||
self.key_failure_counts[key] = 0
|
self.key_failure_counts[key] = 0
|
||||||
logger.info(f"Reset failure count for key: {key}")
|
logger.info(f"Reset failure count for key: {key}")
|
||||||
return True
|
return True
|
||||||
logger.warning(f"Attempt to reset failure count for non-existent key: {key}")
|
logger.warning(
|
||||||
|
f"Attempt to reset failure count for non-existent key: {key}"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_next_working_key(self) -> str:
|
async def get_next_working_key(self) -> str:
|
||||||
@@ -62,7 +63,7 @@ class KeyManager:
|
|||||||
# await self.reset_failure_counts() 取消重置
|
# await self.reset_failure_counts() 取消重置
|
||||||
return current_key
|
return current_key
|
||||||
|
|
||||||
async def handle_api_failure(self, api_key: str,retries: int) -> str:
|
async def handle_api_failure(self, api_key: str, retries: int) -> str:
|
||||||
"""处理API调用失败"""
|
"""处理API调用失败"""
|
||||||
async with self.failure_count_lock:
|
async with self.failure_count_lock:
|
||||||
self.key_failure_counts[api_key] += 1
|
self.key_failure_counts[api_key] += 1
|
||||||
@@ -72,7 +73,7 @@ class KeyManager:
|
|||||||
)
|
)
|
||||||
if retries < settings.MAX_RETRIES:
|
if retries < settings.MAX_RETRIES:
|
||||||
return await self.get_next_working_key()
|
return await self.get_next_working_key()
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def get_fail_count(self, key: str) -> int:
|
def get_fail_count(self, key: str) -> int:
|
||||||
@@ -100,10 +101,32 @@ class KeyManager:
|
|||||||
for key in self.key_failure_counts:
|
for key in self.key_failure_counts:
|
||||||
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
||||||
return key
|
return key
|
||||||
return self.api_keys[0]
|
# 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空)
|
||||||
|
# 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整
|
||||||
|
if self.api_keys:
|
||||||
|
return self.api_keys[0]
|
||||||
|
# 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。
|
||||||
|
# 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。
|
||||||
|
# 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。
|
||||||
|
# 根据现有代码,如果api_keys为空,self.api_keys[0]会报错。
|
||||||
|
# 如果没有有效key且列表不空,返回第一个。若列表为空,这里会出IndexError。
|
||||||
|
# 更安全的做法是:
|
||||||
|
if not self.api_keys:
|
||||||
|
logger.warning("API key list is empty, cannot get first valid key.")
|
||||||
|
# Depending on desired behavior, either raise error or return an indicator like "" or None
|
||||||
|
# For now, let's allow it to potentially fail if a key is expected by caller
|
||||||
|
# but it's better to be explicit. Let's return empty string for consistency with handle_api_failure
|
||||||
|
return ""
|
||||||
|
return self.api_keys[
|
||||||
|
0
|
||||||
|
] # Fallback to the first key if no key is "valid" but list is not empty
|
||||||
|
|
||||||
|
|
||||||
_singleton_instance = None
|
_singleton_instance = None
|
||||||
_singleton_lock = asyncio.Lock()
|
_singleton_lock = asyncio.Lock()
|
||||||
|
_preserved_failure_counts: Dict[str, int] | None = None
|
||||||
|
_preserved_old_api_keys_for_reset: list | None = None
|
||||||
|
_preserved_next_key_in_cycle: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
||||||
@@ -112,22 +135,174 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
|||||||
|
|
||||||
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。
|
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。
|
||||||
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
|
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
|
||||||
|
如果在重置后调用,会尝试恢复之前的状态(失败计数、循环位置)。
|
||||||
"""
|
"""
|
||||||
global _singleton_instance
|
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
|
||||||
|
|
||||||
async with _singleton_lock:
|
async with _singleton_lock:
|
||||||
if _singleton_instance is None:
|
if _singleton_instance is None:
|
||||||
if api_keys is None:
|
if api_keys is None:
|
||||||
raise ValueError("API keys are required to initialize the KeyManager")
|
# This case needs careful handling. If it's the very first call, api_keys are required.
|
||||||
|
# If it's after a reset and no api_keys are provided, what should happen?
|
||||||
|
# The original ValueError was "API keys are required to initialize the KeyManager".
|
||||||
|
# Let's assume if api_keys is None here, it's an error unless we are restoring from non-None _preserved_old_api_keys_for_reset.
|
||||||
|
# However, the user's request implies new api_keys will be part of the reset flow.
|
||||||
|
# For now, stick to a strict requirement for api_keys if _singleton_instance is None.
|
||||||
|
raise ValueError(
|
||||||
|
"API keys are required to initialize or re-initialize the KeyManager instance."
|
||||||
|
)
|
||||||
|
if not api_keys: # Handle case where api_keys is an empty list
|
||||||
|
logger.warning(
|
||||||
|
"Initializing KeyManager with an empty list of API keys."
|
||||||
|
)
|
||||||
|
# Consider if this should be an error or allowed. Current KeyManager supports it.
|
||||||
|
|
||||||
_singleton_instance = KeyManager(api_keys)
|
_singleton_instance = KeyManager(api_keys)
|
||||||
logger.info("KeyManager instance created.")
|
logger.info(
|
||||||
|
f"KeyManager instance created/re-created with {len(api_keys)} API keys."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 恢复失败计数
|
||||||
|
if _preserved_failure_counts:
|
||||||
|
# Initialize new instance's failure_counts for all new keys to 0
|
||||||
|
current_failure_counts = {
|
||||||
|
key: 0 for key in _singleton_instance.api_keys
|
||||||
|
}
|
||||||
|
# Inherit counts for keys that exist in both old and new lists
|
||||||
|
for key, count in _preserved_failure_counts.items():
|
||||||
|
if key in current_failure_counts:
|
||||||
|
current_failure_counts[key] = count
|
||||||
|
_singleton_instance.key_failure_counts = current_failure_counts
|
||||||
|
logger.info("Inherited failure counts for applicable keys.")
|
||||||
|
_preserved_failure_counts = None # Clear after use
|
||||||
|
|
||||||
|
# 2. 调整 key_cycle 的起始点
|
||||||
|
start_key_for_new_cycle = None
|
||||||
|
if (
|
||||||
|
_preserved_old_api_keys_for_reset
|
||||||
|
and _preserved_next_key_in_cycle
|
||||||
|
and _singleton_instance.api_keys # Ensure new api_keys list is not empty
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
# Find the index of the preserved next key in the *old* list
|
||||||
|
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
|
||||||
|
_preserved_next_key_in_cycle
|
||||||
|
)
|
||||||
|
|
||||||
|
# Iterate through the old key list (circularly) starting from _preserved_next_key_in_cycle
|
||||||
|
# Find the first key that also exists in the new api_keys list
|
||||||
|
for i in range(len(_preserved_old_api_keys_for_reset)):
|
||||||
|
current_old_key_idx = (start_idx_in_old + i) % len(
|
||||||
|
_preserved_old_api_keys_for_reset
|
||||||
|
)
|
||||||
|
key_candidate = _preserved_old_api_keys_for_reset[
|
||||||
|
current_old_key_idx
|
||||||
|
]
|
||||||
|
if key_candidate in _singleton_instance.api_keys:
|
||||||
|
start_key_for_new_cycle = key_candidate
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
f"Preserved next key '{_preserved_next_key_in_cycle}' not found in preserved old API keys. "
|
||||||
|
"New cycle will start from the beginning of the new list."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error determining start key for new cycle from preserved state: {e}. "
|
||||||
|
"New cycle will start from the beginning."
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_key_for_new_cycle and _singleton_instance.api_keys:
|
||||||
|
try:
|
||||||
|
# Find the index of the determined start_key in the new api_keys list
|
||||||
|
target_idx = _singleton_instance.api_keys.index(
|
||||||
|
start_key_for_new_cycle
|
||||||
|
)
|
||||||
|
# Advance the new cycle by calling next() target_idx times
|
||||||
|
# This positions the cycle so that the *next* call to next() will yield start_key_for_new_cycle
|
||||||
|
for _ in range(target_idx):
|
||||||
|
next(_singleton_instance.key_cycle)
|
||||||
|
logger.info(
|
||||||
|
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# This should not happen if start_key_for_new_cycle was correctly found in api_keys
|
||||||
|
logger.warning(
|
||||||
|
f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. "
|
||||||
|
"New cycle will start from the beginning."
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
StopIteration
|
||||||
|
): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check
|
||||||
|
logger.error(
|
||||||
|
"StopIteration while advancing key cycle, implies empty new API key list previously missed."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error advancing new key cycle: {e}. Cycle will start from beginning."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if _singleton_instance.api_keys:
|
||||||
|
logger.info(
|
||||||
|
"New key cycle will start from the beginning of the new API key list (no specific start key determined or needed)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"New key cycle not applicable as the new API key list is empty."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 清理所有保存的状态
|
||||||
|
_preserved_old_api_keys_for_reset = None
|
||||||
|
_preserved_next_key_in_cycle = None
|
||||||
|
# _preserved_failure_counts already cleared
|
||||||
|
|
||||||
return _singleton_instance
|
return _singleton_instance
|
||||||
|
|
||||||
|
|
||||||
async def reset_key_manager_instance():
|
async def reset_key_manager_instance():
|
||||||
"""重置 KeyManager 单例实例"""
|
"""
|
||||||
global _singleton_instance
|
重置 KeyManager 单例实例。
|
||||||
|
将保存当前实例的状态(失败计数、旧 API keys、下一个 key 提示)
|
||||||
|
以供下一次 get_key_manager_instance 调用时恢复。
|
||||||
|
"""
|
||||||
|
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
|
||||||
async with _singleton_lock:
|
async with _singleton_lock:
|
||||||
if _singleton_instance:
|
if _singleton_instance:
|
||||||
|
# 1. 保存失败计数
|
||||||
|
_preserved_failure_counts = _singleton_instance.key_failure_counts.copy()
|
||||||
|
|
||||||
|
# 2. 保存旧的 API keys 列表
|
||||||
|
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
|
||||||
|
|
||||||
|
# 3. 保存 key_cycle 的下一个 key 提示
|
||||||
|
# This should be the key that get_next_key() would return next.
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
_singleton_instance.api_keys
|
||||||
|
): # Only if there are keys to cycle through
|
||||||
|
# Calling get_next_key() consumes one key and returns it. This is the key
|
||||||
|
# we want the new cycle to effectively start with.
|
||||||
|
_preserved_next_key_in_cycle = (
|
||||||
|
await _singleton_instance.get_next_key()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_preserved_next_key_in_cycle = None # No keys, so no next key
|
||||||
|
except (
|
||||||
|
StopIteration
|
||||||
|
): # Should be caught by "if _singleton_instance.api_keys"
|
||||||
|
logger.warning(
|
||||||
|
"Could not preserve next key hint: key cycle was empty or exhausted in old instance."
|
||||||
|
)
|
||||||
|
_preserved_next_key_in_cycle = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error preserving next key hint during reset: {e}")
|
||||||
|
_preserved_next_key_in_cycle = None
|
||||||
|
|
||||||
_singleton_instance = None
|
_singleton_instance = None
|
||||||
logger.info("KeyManager instance reset.")
|
logger.info(
|
||||||
|
"KeyManager instance has been reset. State (failure counts, old keys, next key hint) preserved for next instantiation."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"KeyManager instance was not set (or already reset), no reset action performed."
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,50 +1,47 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.log.logger import get_model_logger
|
from app.log.logger import get_model_logger
|
||||||
|
from app.service.client.api_client import GeminiApiClient
|
||||||
|
|
||||||
logger = get_model_logger()
|
logger = get_model_logger()
|
||||||
|
|
||||||
|
|
||||||
class ModelService:
|
class ModelService:
|
||||||
def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||||
url = f"{settings.BASE_URL}/models?key={api_key}"
|
"""使用 GeminiApiClient 获取并过滤模型列表"""
|
||||||
|
api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端
|
||||||
|
gemini_models = await api_client.get_models(api_key)
|
||||||
|
|
||||||
try:
|
if gemini_models is None:
|
||||||
response = requests.get(url)
|
logger.error("从 API 客户端获取模型列表失败。")
|
||||||
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.debug(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}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_gemini_openai_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
|
||||||
try:
|
try:
|
||||||
gemini_models = self.get_gemini_models(api_key)
|
filtered_models_list = []
|
||||||
return self.convert_to_openai_models_format(gemini_models)
|
for model in gemini_models.get("models", []):
|
||||||
except requests.RequestException as e:
|
model_id = model["name"].split("/")[-1]
|
||||||
logger.error(f"Request failed: {e}")
|
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
|
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]
|
self, gemini_models: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
openai_format = {"object": "list", "data": [], "success": True}
|
openai_format = {"object": "list", "data": [], "success": True}
|
||||||
@@ -81,7 +78,7 @@ class ModelService:
|
|||||||
openai_format["data"].append(image_model)
|
openai_format["data"].append(image_model)
|
||||||
return openai_format
|
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):
|
if not model or not isinstance(model, str):
|
||||||
return False
|
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"
|
||||||
|
|
||||||
|
|
||||||
50
app/service/request_log/request_log_service.py
Normal file
50
app/service/request_log/request_log_service.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Service for request log operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from app import database
|
||||||
|
from app.config.config import settings
|
||||||
|
from app.database.models import RequestLog
|
||||||
|
from app.log.logger import Logger
|
||||||
|
|
||||||
|
logger = Logger.setup_logger("request_log_service")
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_old_request_logs_task():
|
||||||
|
"""
|
||||||
|
定时删除旧的请求日志。
|
||||||
|
"""
|
||||||
|
if not settings.AUTO_DELETE_REQUEST_LOGS_ENABLED:
|
||||||
|
logger.info(
|
||||||
|
"Auto-delete for request logs is disabled by settings. Skipping task."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
days_to_keep = settings.AUTO_DELETE_REQUEST_LOGS_DAYS
|
||||||
|
logger.info(
|
||||||
|
f"Starting scheduled task to delete old request logs older than {days_to_keep} days."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cutoff_date = datetime.now(datetime.timezone.utc) - timedelta(days=days_to_keep)
|
||||||
|
|
||||||
|
query = delete(RequestLog).where(RequestLog.request_time < cutoff_date)
|
||||||
|
|
||||||
|
if not database.is_connected:
|
||||||
|
logger.info("Connecting to database for request log deletion.")
|
||||||
|
await database.connect()
|
||||||
|
|
||||||
|
result = await database.execute(query)
|
||||||
|
logger.info(
|
||||||
|
f"Request logs older than {cutoff_date} potentially deleted. Rows affected: {result.rowcount if result else 'N/A'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"An error occurred during the scheduled request log deletion: {str(e)}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
255
app/service/stats/stats_service.py
Normal file
255
app/service/stats/stats_service.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# app/service/stats_service.py
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import and_, case, func, or_, select
|
||||||
|
|
||||||
|
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) -> dict[str, int]:
|
||||||
|
"""获取过去 N 秒内的调用次数 (总数、成功、失败)"""
|
||||||
|
try:
|
||||||
|
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
|
||||||
|
query = select(
|
||||||
|
func.count(RequestLog.id).label("total"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(
|
||||||
|
RequestLog.status_code >= 200,
|
||||||
|
RequestLog.status_code < 300,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("success"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
or_(
|
||||||
|
RequestLog.status_code < 200,
|
||||||
|
RequestLog.status_code >= 300,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
(RequestLog.status_code is None, 1), # type: ignore
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("failure"),
|
||||||
|
).where(RequestLog.request_time >= cutoff_time)
|
||||||
|
result = await database.fetch_one(query)
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
"total": result["total"] or 0,
|
||||||
|
"success": result["success"] or 0,
|
||||||
|
"failure": result["failure"] or 0,
|
||||||
|
}
|
||||||
|
return {"total": 0, "success": 0, "failure": 0}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
|
||||||
|
return {"total": 0, "success": 0, "failure": 0}
|
||||||
|
|
||||||
|
async def get_calls_in_last_minutes(self, minutes: int) -> dict[str, int]:
|
||||||
|
"""获取过去 N 分钟内的调用次数 (总数、成功、失败)"""
|
||||||
|
return await self.get_calls_in_last_seconds(minutes * 60)
|
||||||
|
|
||||||
|
async def get_calls_in_last_hours(self, hours: int) -> dict[str, int]:
|
||||||
|
"""获取过去 N 小时内的调用次数 (总数、成功、失败)"""
|
||||||
|
return await self.get_calls_in_last_seconds(hours * 3600)
|
||||||
|
|
||||||
|
async def get_calls_in_current_month(self) -> dict[str, 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).label("total"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(
|
||||||
|
RequestLog.status_code >= 200,
|
||||||
|
RequestLog.status_code < 300,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("success"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
or_(
|
||||||
|
RequestLog.status_code < 200,
|
||||||
|
RequestLog.status_code >= 300,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
(RequestLog.status_code is None, 1), # type: ignore
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("failure"),
|
||||||
|
).where(RequestLog.request_time >= start_of_month)
|
||||||
|
result = await database.fetch_one(query)
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
"total": result["total"] or 0,
|
||||||
|
"success": result["success"] or 0,
|
||||||
|
"failure": result["failure"] or 0,
|
||||||
|
}
|
||||||
|
return {"total": 0, "success": 0, "failure": 0}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get calls in current month: {e}")
|
||||||
|
return {"total": 0, "success": 0, "failure": 0}
|
||||||
|
|
||||||
|
async def get_api_usage_stats(self) -> dict:
|
||||||
|
"""获取所有需要的 API 使用统计数据 (总数、成功、失败)"""
|
||||||
|
try:
|
||||||
|
stats_1m = await self.get_calls_in_last_minutes(1)
|
||||||
|
stats_1h = await self.get_calls_in_last_hours(1)
|
||||||
|
stats_24h = await self.get_calls_in_last_hours(24)
|
||||||
|
stats_month = await self.get_calls_in_current_month()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"calls_1m": stats_1m,
|
||||||
|
"calls_1h": stats_1h,
|
||||||
|
"calls_24h": stats_24h,
|
||||||
|
"calls_month": stats_month,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get API usage stats: {e}")
|
||||||
|
default_stat = {"total": 0, "success": 0, "failure": 0}
|
||||||
|
return {
|
||||||
|
"calls_1m": default_stat.copy(),
|
||||||
|
"calls_1h": default_stat.copy(),
|
||||||
|
"calls_24h": default_stat.copy(),
|
||||||
|
"calls_month": default_stat.copy(),
|
||||||
|
}
|
||||||
|
|
||||||
|
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,174 +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()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,19 +9,67 @@ function scrollToBottom() {
|
|||||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API 调用辅助函数
|
||||||
|
async function fetchAPI(url, options = {}) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
// Handle cases where response might be empty but still ok (e.g., 204 No Content for DELETE)
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null; // Indicate success with no content
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseData;
|
||||||
|
try {
|
||||||
|
responseData = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
// Handle non-JSON responses if necessary, or assume error if JSON expected
|
||||||
|
if (!response.ok) {
|
||||||
|
// If response is not ok and not JSON, use statusText
|
||||||
|
throw new Error(`HTTP error! status: ${response.status} - ${response.statusText}`);
|
||||||
|
}
|
||||||
|
// If response is ok but not JSON, maybe return raw text or handle differently
|
||||||
|
// For now, let's assume successful non-JSON is not expected or handled later
|
||||||
|
console.warn("Response was not JSON for URL:", url);
|
||||||
|
return await response.text(); // Or handle as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Prefer error message from API response body if available
|
||||||
|
const message = responseData?.detail || `HTTP error! status: ${response.status} - ${response.statusText}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseData; // Return parsed JSON data for successful responses
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Catch network errors or errors thrown from above
|
||||||
|
console.error('API Call Failed:', error.message, 'URL:', url, 'Options:', options);
|
||||||
|
// Re-throw the error so the calling function knows the operation failed
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh function removed as the buttons are gone.
|
// Refresh function removed as the buttons are gone.
|
||||||
// If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs().
|
// If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs().
|
||||||
|
|
||||||
// 全局变量
|
// 全局状态管理
|
||||||
let currentPage = 1;
|
let errorLogState = {
|
||||||
let pageSize = 10;
|
currentPage: 1,
|
||||||
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
|
pageSize: 10,
|
||||||
let errorLogs = []; // Store fetched logs for details view
|
logs: [], // 存储获取的日志
|
||||||
let currentSearch = { // Store current search parameters
|
sort: {
|
||||||
key: '',
|
field: 'id', // 默认按 ID 排序
|
||||||
error: '',
|
order: 'desc' // 默认降序
|
||||||
startDate: '',
|
},
|
||||||
endDate: ''
|
search: {
|
||||||
|
key: '',
|
||||||
|
error: '',
|
||||||
|
errorCode: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: ''
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// DOM Elements Cache
|
// DOM Elements Cache
|
||||||
@@ -36,64 +84,89 @@ let logDetailModal;
|
|||||||
let modalCloseBtns; // Collection of close buttons for the modal
|
let modalCloseBtns; // Collection of close buttons for the modal
|
||||||
let keySearchInput;
|
let keySearchInput;
|
||||||
let errorSearchInput;
|
let errorSearchInput;
|
||||||
|
let errorCodeSearchInput; // Added error code input
|
||||||
let startDateInput;
|
let startDateInput;
|
||||||
let endDateInput;
|
let endDateInput;
|
||||||
let searchBtn;
|
let searchBtn;
|
||||||
let pageInput; // 新增:页码输入框
|
let pageInput;
|
||||||
let goToPageBtn; // 新增:跳转按钮
|
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
|
||||||
|
|
||||||
// 页面加载完成后执行
|
// Helper functions for initialization
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
function cacheDOMElements() {
|
||||||
// Cache DOM elements
|
|
||||||
pageSizeSelector = document.getElementById('pageSize');
|
pageSizeSelector = document.getElementById('pageSize');
|
||||||
// refreshBtn = document.getElementById('refreshBtn'); // Removed
|
|
||||||
tableBody = document.getElementById('errorLogsTable');
|
tableBody = document.getElementById('errorLogsTable');
|
||||||
paginationElement = document.getElementById('pagination');
|
paginationElement = document.getElementById('pagination');
|
||||||
loadingIndicator = document.getElementById('loadingIndicator');
|
loadingIndicator = document.getElementById('loadingIndicator');
|
||||||
noDataMessage = document.getElementById('noDataMessage');
|
noDataMessage = document.getElementById('noDataMessage');
|
||||||
errorMessage = document.getElementById('errorMessage');
|
errorMessage = document.getElementById('errorMessage');
|
||||||
logDetailModal = document.getElementById('logDetailModal');
|
logDetailModal = document.getElementById('logDetailModal');
|
||||||
// Get all elements that should close the modal
|
|
||||||
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
|
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
|
||||||
keySearchInput = document.getElementById('keySearch');
|
keySearchInput = document.getElementById('keySearch');
|
||||||
errorSearchInput = document.getElementById('errorSearch');
|
errorSearchInput = document.getElementById('errorSearch');
|
||||||
|
errorCodeSearchInput = document.getElementById('errorCodeSearch');
|
||||||
startDateInput = document.getElementById('startDate');
|
startDateInput = document.getElementById('startDate');
|
||||||
endDateInput = document.getElementById('endDate');
|
endDateInput = document.getElementById('endDate');
|
||||||
searchBtn = document.getElementById('searchBtn');
|
searchBtn = document.getElementById('searchBtn');
|
||||||
pageInput = document.getElementById('pageInput'); // 新增
|
pageInput = document.getElementById('pageInput');
|
||||||
goToPageBtn = document.getElementById('goToPageBtn'); // 新增
|
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
|
function initializePageSizeControls() {
|
||||||
if (pageSizeSelector) {
|
if (pageSizeSelector) {
|
||||||
pageSizeSelector.value = pageSize;
|
pageSizeSelector.value = errorLogState.pageSize;
|
||||||
pageSizeSelector.addEventListener('change', function() {
|
pageSizeSelector.addEventListener('change', function() {
|
||||||
pageSize = parseInt(this.value);
|
errorLogState.pageSize = parseInt(this.value);
|
||||||
currentPage = 1; // Reset to first page
|
errorLogState.currentPage = 1; // Reset to first page
|
||||||
loadErrorLogs();
|
loadErrorLogs();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh button event listener removed
|
function initializeSearchControls() {
|
||||||
|
|
||||||
// Initialize search button
|
|
||||||
if (searchBtn) {
|
if (searchBtn) {
|
||||||
searchBtn.addEventListener('click', function() {
|
searchBtn.addEventListener('click', function() {
|
||||||
// Update search parameters from input fields
|
errorLogState.search.key = keySearchInput ? keySearchInput.value.trim() : '';
|
||||||
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
|
errorLogState.search.error = errorSearchInput ? errorSearchInput.value.trim() : '';
|
||||||
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
|
errorLogState.search.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : '';
|
||||||
currentSearch.startDate = startDateInput ? startDateInput.value : '';
|
errorLogState.search.startDate = startDateInput ? startDateInput.value : '';
|
||||||
currentSearch.endDate = endDateInput ? endDateInput.value : '';
|
errorLogState.search.endDate = endDateInput ? endDateInput.value : '';
|
||||||
currentPage = 1; // Reset to first page on new search
|
errorLogState.currentPage = 1; // Reset to first page on new search
|
||||||
loadErrorLogs();
|
loadErrorLogs();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize modal close buttons
|
function initializeModalControls() {
|
||||||
|
// Log Detail Modal
|
||||||
if (logDetailModal && modalCloseBtns) {
|
if (logDetailModal && modalCloseBtns) {
|
||||||
modalCloseBtns.forEach(btn => {
|
modalCloseBtns.forEach(btn => {
|
||||||
btn.addEventListener('click', closeLogDetailModal);
|
btn.addEventListener('click', closeLogDetailModal);
|
||||||
});
|
});
|
||||||
// Optional: Close modal if clicking outside the content
|
|
||||||
logDetailModal.addEventListener('click', function(event) {
|
logDetailModal.addEventListener('click', function(event) {
|
||||||
if (event.target === logDetailModal) {
|
if (event.target === logDetailModal) {
|
||||||
closeLogDetailModal();
|
closeLogDetailModal();
|
||||||
@@ -101,39 +174,100 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load of error logs
|
// Delete Confirm Modal
|
||||||
loadErrorLogs();
|
if (closeDeleteConfirmModalBtn) {
|
||||||
|
closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal);
|
||||||
// Add event listeners for copy buttons inside the modal
|
}
|
||||||
setupCopyButtons();
|
if (cancelDeleteBtn) {
|
||||||
|
cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal);
|
||||||
// 新增:为页码跳转按钮添加事件监听器
|
}
|
||||||
if (goToPageBtn && pageInput) {
|
if (confirmDeleteBtn) {
|
||||||
goToPageBtn.addEventListener('click', function() {
|
confirmDeleteBtn.addEventListener('click', handleConfirmDelete);
|
||||||
const targetPage = parseInt(pageInput.value);
|
}
|
||||||
// 需要获取总页数来验证输入
|
if (deleteConfirmModal) {
|
||||||
// 暂时无法直接获取 totalPages,需要在 updatePagination 中存储或重新计算
|
deleteConfirmModal.addEventListener('click', function(event) {
|
||||||
// 简单的验证:必须是正整数
|
if (event.target === deleteConfirmModal) {
|
||||||
if (!isNaN(targetPage) && targetPage >= 1) {
|
hideDeleteConfirmModal();
|
||||||
// 理想情况下,应检查 targetPage <= totalPages
|
|
||||||
// 但 totalPages 可能未知,所以暂时只跳转
|
|
||||||
currentPage = targetPage;
|
|
||||||
loadErrorLogs();
|
|
||||||
pageInput.value = ''; // 清空输入框
|
|
||||||
} else {
|
|
||||||
showNotification('请输入有效的页码', 'error', 2000);
|
|
||||||
pageInput.value = ''; // 清空无效输入
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 允许按 Enter 键跳转
|
|
||||||
pageInput.addEventListener('keypress', function(event) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
goToPageBtn.click(); // 触发按钮点击
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializePaginationJumpControls() {
|
||||||
|
if (goToPageBtn && pageInput) {
|
||||||
|
goToPageBtn.addEventListener('click', function() {
|
||||||
|
const targetPage = parseInt(pageInput.value);
|
||||||
|
if (!isNaN(targetPage) && targetPage >= 1) {
|
||||||
|
errorLogState.currentPage = targetPage;
|
||||||
|
loadErrorLogs();
|
||||||
|
pageInput.value = '';
|
||||||
|
} else {
|
||||||
|
showNotification('请输入有效的页码', 'error', 2000);
|
||||||
|
pageInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pageInput.addEventListener('keypress', function(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
goToPageBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeActionControls() {
|
||||||
|
if (deleteSelectedBtn) {
|
||||||
|
deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
|
||||||
|
}
|
||||||
|
if (sortByIdHeader) {
|
||||||
|
sortByIdHeader.addEventListener('click', handleSortById);
|
||||||
|
}
|
||||||
|
// Bulk selection listeners are closely related to actions
|
||||||
|
setupBulkSelectionListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载完成后执行
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
cacheDOMElements();
|
||||||
|
initializePageSizeControls();
|
||||||
|
initializeSearchControls();
|
||||||
|
initializeModalControls();
|
||||||
|
initializePaginationJumpControls();
|
||||||
|
initializeActionControls();
|
||||||
|
|
||||||
|
// Initial load of error logs
|
||||||
|
loadErrorLogs();
|
||||||
|
|
||||||
|
// Add event listeners for copy buttons inside the modal and table
|
||||||
|
// This needs to be called after initial render and potentially after each render if content is dynamic
|
||||||
|
setupCopyButtons();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 新增:显示删除确认模态框
|
||||||
|
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
|
// Fallback copy function using document.execCommand
|
||||||
function fallbackCopyTextToClipboard(text) {
|
function fallbackCopyTextToClipboard(text) {
|
||||||
const textArea = document.createElement("textarea");
|
const textArea = document.createElement("textarea");
|
||||||
@@ -174,91 +308,334 @@ function handleCopyResult(buttonElement, success) {
|
|||||||
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
|
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() {
|
function _performCopy(text, buttonElement) {
|
||||||
const copyButtons = document.querySelectorAll('.copy-btn');
|
let copySuccess = false;
|
||||||
copyButtons.forEach(button => {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
button.addEventListener('click', function() {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
const targetId = this.getAttribute('data-target');
|
if (buttonElement) {
|
||||||
const targetElement = document.getElementById(targetId);
|
handleCopyResult(buttonElement, true);
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
console.error('Target element not found:', targetId);
|
showNotification('已复制到剪贴板', 'success');
|
||||||
showNotification('复制出错:找不到目标元素', 'error');
|
}
|
||||||
|
}).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 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
_performCopy(textToCopy, button); // 使用新的辅助函数
|
||||||
|
} else {
|
||||||
|
console.warn('No text found to copy for target:', targetId || 'direct text');
|
||||||
|
showNotification('没有内容可复制', 'warning');
|
||||||
|
}
|
||||||
|
} // End of handleCopyButtonClick function
|
||||||
|
|
||||||
|
// 新增:设置批量选择相关的事件监听器
|
||||||
|
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'); // 每行一个密钥
|
||||||
|
_performCopy(textToCopy, copySelectedKeysBtn); // 使用新的辅助函数
|
||||||
|
} else {
|
||||||
|
showNotification('没有选中的密钥可复制', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改:处理批量删除按钮点击的函数 - 改为显示模态框
|
||||||
|
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' };
|
||||||
|
const options = {
|
||||||
|
method: method,
|
||||||
|
headers: headers,
|
||||||
|
body: body, // fetchAPI handles null body correctly
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use fetchAPI for the delete request
|
||||||
|
await fetchAPI(url, options); // fetchAPI returns null for 204 No Content
|
||||||
|
|
||||||
|
// If fetchAPI doesn't throw, the request was successful
|
||||||
|
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 (errorLogState.sort.field === 'id') {
|
||||||
|
// 如果当前是按 ID 排序,切换顺序
|
||||||
|
errorLogState.sort.order = errorLogState.sort.order === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
// 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序
|
||||||
|
errorLogState.sort.field = 'id';
|
||||||
|
errorLogState.sort.order = 'desc';
|
||||||
|
}
|
||||||
|
// 更新图标
|
||||||
|
updateSortIcon();
|
||||||
|
// 重新加载第一页数据
|
||||||
|
errorLogState.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 (errorLogState.sort.field === 'id') {
|
||||||
|
sortIcon.classList.add(errorLogState.sort.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() {
|
async function loadErrorLogs() {
|
||||||
|
// 重置选择状态
|
||||||
|
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||||
|
if (selectAllCheckbox) selectAllCheckbox.indeterminate = false;
|
||||||
|
updateSelectedState(); // 更新按钮状态和计数
|
||||||
|
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
showError(false);
|
showError(false);
|
||||||
showNoData(false);
|
showNoData(false);
|
||||||
|
|
||||||
const offset = (currentPage - 1) * pageSize;
|
const offset = (errorLogState.currentPage - 1) * errorLogState.pageSize;
|
||||||
|
|
||||||
try {
|
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}`;
|
let apiUrl = `/api/logs/errors?limit=${errorLogState.pageSize}&offset=${offset}`;
|
||||||
if (currentSearch.key) {
|
// 添加排序参数
|
||||||
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
|
apiUrl += `&sort_by=${errorLogState.sort.field}&sort_order=${errorLogState.sort.order}`;
|
||||||
|
|
||||||
|
// 添加搜索参数
|
||||||
|
if (errorLogState.search.key) {
|
||||||
|
apiUrl += `&key_search=${encodeURIComponent(errorLogState.search.key)}`;
|
||||||
}
|
}
|
||||||
if (currentSearch.error) {
|
if (errorLogState.search.error) {
|
||||||
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
|
apiUrl += `&error_search=${encodeURIComponent(errorLogState.search.error)}`;
|
||||||
}
|
}
|
||||||
if (currentSearch.startDate) {
|
if (errorLogState.search.errorCode) { // Add error code to API request
|
||||||
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
|
apiUrl += `&error_code_search=${encodeURIComponent(errorLogState.search.errorCode)}`;
|
||||||
}
|
}
|
||||||
if (currentSearch.endDate) {
|
if (errorLogState.search.startDate) {
|
||||||
apiUrl += `&end_date=${encodeURIComponent(currentSearch.endDate)}`;
|
apiUrl += `&start_date=${encodeURIComponent(errorLogState.search.startDate)}`;
|
||||||
|
}
|
||||||
|
if (errorLogState.search.endDate) {
|
||||||
|
apiUrl += `&end_date=${encodeURIComponent(errorLogState.search.endDate)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(apiUrl);
|
// Use fetchAPI to get logs
|
||||||
if (!response.ok) {
|
const data = await fetchAPI(apiUrl);
|
||||||
// Try to get error message from response body
|
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await response.json();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore if response is not JSON
|
|
||||||
}
|
|
||||||
throw new Error(errorData?.detail || `网络响应异常: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
// API 现在返回 { logs: [], total: count }
|
// API 现在返回 { logs: [], total: count }
|
||||||
|
// fetchAPI already parsed JSON
|
||||||
if (data && Array.isArray(data.logs)) {
|
if (data && Array.isArray(data.logs)) {
|
||||||
errorLogs = data.logs; // Store the list data (contains error_code)
|
errorLogState.logs = data.logs; // Store the list data (contains error_code)
|
||||||
renderErrorLogs(errorLogs);
|
renderErrorLogs(errorLogState.logs);
|
||||||
updatePagination(errorLogs.length, data.total || -1);
|
updatePagination(errorLogState.logs.length, data.total || -1); // Use total from response
|
||||||
} else {
|
} else {
|
||||||
throw new Error('无法识别的API响应格式');
|
// Handle unexpected data format even after successful fetch
|
||||||
|
console.error('Unexpected API response format:', data);
|
||||||
|
throw new Error('无法识别的API响应格式');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
|
|
||||||
if (errorLogs.length === 0) {
|
if (errorLogState.logs.length === 0) {
|
||||||
showNoData(true);
|
showNoData(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -268,59 +645,77 @@ async function loadErrorLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to create HTML for a single log row
|
||||||
|
function _createLogRowHtml(log, sequentialId) {
|
||||||
|
// 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); }
|
||||||
|
|
||||||
|
const errorCodeContent = log.error_code || '无';
|
||||||
|
|
||||||
|
const maskKey = (key) => {
|
||||||
|
if (!key || key.length < 8) return key || '无';
|
||||||
|
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||||
|
};
|
||||||
|
const maskedKey = maskKey(log.gemini_key);
|
||||||
|
const fullKey = log.gemini_key || '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<td class="text-center px-3 py-3">
|
||||||
|
<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}">
|
||||||
|
</td>
|
||||||
|
<td>${sequentialId}</td>
|
||||||
|
<td class="relative group" title="${fullKey}">
|
||||||
|
${maskedKey}
|
||||||
|
<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="${fullKey}" title="复制完整密钥">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>${log.error_type || '未知'}</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 mr-2" data-log-id="${log.id}">
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// 渲染错误日志表格
|
// 渲染错误日志表格
|
||||||
function renderErrorLogs(logs) {
|
function renderErrorLogs(logs) {
|
||||||
if (!tableBody) return;
|
if (!tableBody) return;
|
||||||
tableBody.innerHTML = ''; // Clear previous entries
|
tableBody.innerHTML = ''; // Clear previous entries
|
||||||
|
|
||||||
|
// 重置全选复选框状态(在清空表格后)
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!logs || logs.length === 0) {
|
if (!logs || logs.length === 0) {
|
||||||
// Handled by showNoData
|
// Handled by showNoData
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startIndex = (currentPage - 1) * pageSize; // Calculate starting index for the current page
|
const startIndex = (errorLogState.currentPage - 1) * errorLogState.pageSize;
|
||||||
|
|
||||||
logs.forEach((log, index) => { // Add index parameter to forEach
|
logs.forEach((log, index) => {
|
||||||
|
const sequentialId = startIndex + index + 1;
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
const sequentialId = startIndex + index + 1; // Calculate sequential ID for the current page
|
row.innerHTML = _createLogRowHtml(log, sequentialId);
|
||||||
// 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); }
|
|
||||||
|
|
||||||
|
|
||||||
// Display error code instead of truncated log
|
|
||||||
const errorCodeContent = log.error_code || '无';
|
|
||||||
|
|
||||||
// Mask the Gemini key for display in the table
|
|
||||||
const maskKey = (key) => {
|
|
||||||
if (!key || key.length < 8) return key || '无'; // Don't mask short keys or null
|
|
||||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
|
||||||
};
|
|
||||||
const maskedKey = maskKey(log.gemini_key);
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<td>${sequentialId}</td> <!-- Use sequential ID -->
|
|
||||||
<td title="${log.gemini_key || ''}">${maskedKey}</td>
|
|
||||||
<td>${log.error_type || '未知'}</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>
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
tableBody.appendChild(row);
|
tableBody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,6 +726,19 @@ function renderErrorLogs(logs) {
|
|||||||
showLogDetails(logId);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示错误日志详情 (从 API 获取)
|
// 显示错误日志详情 (从 API 获取)
|
||||||
@@ -350,15 +758,14 @@ async function showLogDetails(logId) {
|
|||||||
document.body.style.overflow = 'hidden'; // Prevent body scrolling
|
document.body.style.overflow = 'hidden'; // Prevent body scrolling
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/logs/errors/${logId}/details`);
|
// Use fetchAPI to get log details
|
||||||
if (!response.ok) {
|
const logDetails = await fetchAPI(`/api/logs/errors/${logId}/details`);
|
||||||
let errorData;
|
|
||||||
try {
|
// fetchAPI handles response.ok check and JSON parsing
|
||||||
errorData = await response.json();
|
if (!logDetails) {
|
||||||
} catch (e) { /* ignore */ }
|
// Handle case where API returns success but no data (if possible)
|
||||||
throw new Error(errorData?.detail || `获取日志详情失败: ${response.statusText}`);
|
throw new Error('未找到日志详情');
|
||||||
}
|
}
|
||||||
const logDetails = await response.json();
|
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
let formattedTime = 'N/A';
|
let formattedTime = 'N/A';
|
||||||
@@ -403,6 +810,9 @@ async function showLogDetails(logId) {
|
|||||||
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
|
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
|
||||||
document.getElementById('modalRequestTime').textContent = formattedTime;
|
document.getElementById('modalRequestTime').textContent = formattedTime;
|
||||||
|
|
||||||
|
// Re-initialize copy buttons specifically for the modal after content is loaded
|
||||||
|
setupCopyButtons('#logDetailModal');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取日志详情失败:', error);
|
console.error('获取日志详情失败:', error);
|
||||||
// Show error in modal
|
// Show error in modal
|
||||||
@@ -435,8 +845,8 @@ function updatePagination(currentItemCount, totalItems) {
|
|||||||
// Calculate total pages only if totalItems is known and valid
|
// Calculate total pages only if totalItems is known and valid
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
if (totalItems >= 0) {
|
if (totalItems >= 0) {
|
||||||
totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
totalPages = Math.max(1, Math.ceil(totalItems / errorLogState.pageSize));
|
||||||
} else if (currentItemCount < pageSize && currentPage === 1) {
|
} else if (currentItemCount < errorLogState.pageSize && errorLogState.currentPage === 1) {
|
||||||
// If less items than page size fetched on page 1, assume it's the only page
|
// If less items than page size fetched on page 1, assume it's the only page
|
||||||
totalPages = 1;
|
totalPages = 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -444,15 +854,15 @@ function updatePagination(currentItemCount, totalItems) {
|
|||||||
// We can show Prev/Next based on current page and if items were returned
|
// We can show Prev/Next based on current page and if items were returned
|
||||||
console.warn("Total item count unknown, pagination will be limited.");
|
console.warn("Total item count unknown, pagination will be limited.");
|
||||||
// Basic Prev/Next for unknown total
|
// Basic Prev/Next for unknown total
|
||||||
addPaginationLink(paginationElement, '«', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
|
addPaginationLink(paginationElement, '«', errorLogState.currentPage > 1, () => { errorLogState.currentPage--; loadErrorLogs(); });
|
||||||
addPaginationLink(paginationElement, currentPage.toString(), true, null, true); // Current page number (non-clickable)
|
addPaginationLink(paginationElement, errorLogState.currentPage.toString(), true, null, true); // Current page number (non-clickable)
|
||||||
addPaginationLink(paginationElement, '»', currentItemCount === pageSize, () => { currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned
|
addPaginationLink(paginationElement, '»', currentItemCount === errorLogState.pageSize, () => { errorLogState.currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned
|
||||||
return; // Exit here for limited pagination
|
return; // Exit here for limited pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const maxPagesToShow = 5; // Max number of page links to show
|
const maxPagesToShow = 5; // Max number of page links to show
|
||||||
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
|
let startPage = Math.max(1, errorLogState.currentPage - Math.floor(maxPagesToShow / 2));
|
||||||
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
||||||
|
|
||||||
// Adjust startPage if endPage reaches the limit first
|
// Adjust startPage if endPage reaches the limit first
|
||||||
@@ -462,11 +872,11 @@ function updatePagination(currentItemCount, totalItems) {
|
|||||||
|
|
||||||
|
|
||||||
// Previous Button
|
// Previous Button
|
||||||
addPaginationLink(paginationElement, '«', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
|
addPaginationLink(paginationElement, '«', errorLogState.currentPage > 1, () => { errorLogState.currentPage--; loadErrorLogs(); });
|
||||||
|
|
||||||
// First Page Button
|
// First Page Button
|
||||||
if (startPage > 1) {
|
if (startPage > 1) {
|
||||||
addPaginationLink(paginationElement, '1', true, () => { currentPage = 1; loadErrorLogs(); });
|
addPaginationLink(paginationElement, '1', true, () => { errorLogState.currentPage = 1; loadErrorLogs(); });
|
||||||
if (startPage > 2) {
|
if (startPage > 2) {
|
||||||
addPaginationLink(paginationElement, '...', false); // Ellipsis
|
addPaginationLink(paginationElement, '...', false); // Ellipsis
|
||||||
}
|
}
|
||||||
@@ -474,7 +884,7 @@ function updatePagination(currentItemCount, totalItems) {
|
|||||||
|
|
||||||
// Page Number Buttons
|
// Page Number Buttons
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
addPaginationLink(paginationElement, i.toString(), true, () => { currentPage = i; loadErrorLogs(); }, i === currentPage);
|
addPaginationLink(paginationElement, i.toString(), true, () => { errorLogState.currentPage = i; loadErrorLogs(); }, i === errorLogState.currentPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last Page Button
|
// Last Page Button
|
||||||
@@ -482,12 +892,12 @@ function updatePagination(currentItemCount, totalItems) {
|
|||||||
if (endPage < totalPages - 1) {
|
if (endPage < totalPages - 1) {
|
||||||
addPaginationLink(paginationElement, '...', false); // Ellipsis
|
addPaginationLink(paginationElement, '...', false); // Ellipsis
|
||||||
}
|
}
|
||||||
addPaginationLink(paginationElement, totalPages.toString(), true, () => { currentPage = totalPages; loadErrorLogs(); });
|
addPaginationLink(paginationElement, totalPages.toString(), true, () => { errorLogState.currentPage = totalPages; loadErrorLogs(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Next Button
|
// Next Button
|
||||||
addPaginationLink(paginationElement, '»', currentPage < totalPages, () => { currentPage++; loadErrorLogs(); });
|
addPaginationLink(paginationElement, '»', errorLogState.currentPage < totalPages, () => { errorLogState.currentPage++; loadErrorLogs(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to add pagination links
|
// Helper function to add pagination links
|
||||||
@@ -547,10 +957,17 @@ function showError(show, message = '加载错误日志失败,请稍后重试
|
|||||||
|
|
||||||
// Function to show temporary status notifications (like copy success)
|
// Function to show temporary status notifications (like copy success)
|
||||||
function showNotification(message, type = 'success', duration = 3000) {
|
function showNotification(message, type = 'success', duration = 3000) {
|
||||||
const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed
|
const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html
|
||||||
if (!notificationElement) return;
|
if (!notificationElement) {
|
||||||
|
console.error("Notification element with ID 'notification' not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set message and type class
|
||||||
notificationElement.textContent = message;
|
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
|
notificationElement.className = `notification ${type} show`; // Add 'show' class
|
||||||
|
|
||||||
// Hide after duration
|
// 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 => {
|
self.addEventListener('fetch', event => {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request)
|
caches.open(CACHE_NAME).then(cache => {
|
||||||
.then(response => {
|
// 1. 尝试从缓存获取
|
||||||
if (response) {
|
return cache.match(event.request).then(responseFromCache => {
|
||||||
return response;
|
// 2. 同时从网络获取 (后台进行)
|
||||||
}
|
const fetchPromise = fetch(event.request).then(responseFromNetwork => {
|
||||||
return fetch(event.request);
|
// 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;
|
||||||
|
});
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,285 +1,369 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}Gemini Balance{% endblock %}</title>
|
<title>{% block title %}Gemini Balance{% endblock %}</title>
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json" />
|
||||||
<meta name="theme-color" content="#4F46E5">
|
<meta name="theme-color" content="#4F46E5" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
<meta name="apple-mobile-web-app-title" content="GBalance">
|
<meta name="apple-mobile-web-app-title" content="GBalance" />
|
||||||
<link rel="icon" href="/static/icons/icon-192x192.png">
|
<link rel="icon" href="/static/icons/icon-192x192.png" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||||
|
/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eef2ff',
|
50: "#eef2ff",
|
||||||
100: '#e0e7ff',
|
100: "#e0e7ff",
|
||||||
200: '#c7d2fe',
|
200: "#c7d2fe",
|
||||||
300: '#a5b4fc',
|
300: "#a5b4fc",
|
||||||
400: '#818cf8',
|
400: "#818cf8",
|
||||||
500: '#6366f1',
|
500: "#6366f1",
|
||||||
600: '#4f46e5',
|
600: "#4f46e5",
|
||||||
700: '#4338ca',
|
700: "#4338ca",
|
||||||
800: '#3730a3',
|
800: "#3730a3",
|
||||||
900: '#312e81',
|
900: "#312e81",
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
50: '#ecfdf5',
|
50: "#ecfdf5",
|
||||||
500: '#10b981',
|
500: "#10b981",
|
||||||
600: '#059669'
|
600: "#059669",
|
||||||
},
|
},
|
||||||
danger: {
|
danger: {
|
||||||
50: '#fef2f2',
|
50: "#fef2f2",
|
||||||
500: '#ef4444',
|
500: "#ef4444",
|
||||||
600: '#dc2626'
|
600: "#dc2626",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'sans-serif'],
|
sans: ["Inter", "sans-serif"],
|
||||||
mono: ['JetBrains Mono', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
mono: [
|
||||||
},
|
"JetBrains Mono",
|
||||||
animation: {
|
"SFMono-Regular",
|
||||||
'fade-in': 'fadeIn 0.5s ease-out',
|
"Menlo",
|
||||||
'slide-up': 'slideUp 0.5s ease-out',
|
"Monaco",
|
||||||
'slide-down': 'slideDown 0.5s ease-out',
|
"Consolas",
|
||||||
'shake': 'shake 0.5s ease-in-out',
|
"monospace",
|
||||||
'spin': 'spin 1s linear infinite',
|
],
|
||||||
},
|
},
|
||||||
keyframes: {
|
animation: {
|
||||||
fadeIn: {
|
"fade-in": "fadeIn 0.5s ease-out",
|
||||||
'0%': { opacity: '0' },
|
"slide-up": "slideUp 0.5s ease-out",
|
||||||
'100%': { opacity: '1' },
|
"slide-down": "slideDown 0.5s ease-out",
|
||||||
},
|
shake: "shake 0.5s ease-in-out",
|
||||||
slideUp: {
|
spin: "spin 1s linear infinite",
|
||||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
},
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
keyframes: {
|
||||||
},
|
fadeIn: {
|
||||||
slideDown: {
|
"0%": { opacity: "0" },
|
||||||
'0%': { transform: 'translateY(-20px)', opacity: '0' },
|
"100%": { opacity: "1" },
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
},
|
||||||
},
|
slideUp: {
|
||||||
shake: {
|
"0%": { transform: "translateY(20px)", opacity: "0" },
|
||||||
'0%, 100%': { transform: 'translateX(0)' },
|
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||||
'25%': { transform: 'translateX(-5px)' },
|
},
|
||||||
'75%': { transform: 'translateX(5px)' },
|
slideDown: {
|
||||||
},
|
"0%": { transform: "translateY(-20px)", opacity: "0" },
|
||||||
spin: {
|
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||||
'0%': { transform: 'rotate(0deg)' },
|
},
|
||||||
'100%': { transform: 'rotate(360deg)' },
|
shake: {
|
||||||
},
|
"0%, 100%": { transform: "translateX(0)" },
|
||||||
},
|
"25%": { transform: "translateX(-5px)" },
|
||||||
}
|
"75%": { transform: "translateX(5px)" },
|
||||||
}
|
},
|
||||||
}
|
spin: {
|
||||||
|
"0%": { transform: "rotate(0deg)" },
|
||||||
|
"100%": { transform: "rotate(360deg)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.glass-card {
|
.glass-card {
|
||||||
background: rgba(255, 255, 255, 0.85); /* Slightly increased opacity for better readability */
|
background: rgba(255, 255, 255, 0.85); /* Slightly increased opacity for better readability */
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(16px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18); /* Subtle border */
|
border: 1px solid rgba(255, 255, 255, 0.18); /* Subtle border */
|
||||||
}
|
}
|
||||||
.bg-gradient {
|
.bg-gradient {
|
||||||
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
|
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
|
||||||
}
|
}
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: rgba(243, 244, 246, 0.8); /* bg-gray-100 with opacity */
|
background: rgba(243, 244, 246, 0.8); /* bg-gray-100 with opacity */
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(79, 70, 229, 0.4); /* primary-600 with opacity */
|
background: rgba(79, 70, 229, 0.4); /* primary-600 with opacity */
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(79, 70, 229, 0.6); /* primary-600 with more opacity */
|
background: rgba(79, 70, 229, 0.6); /* primary-600 with more opacity */
|
||||||
}
|
}
|
||||||
/* Basic modal styles */
|
/* Basic modal styles */
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(0,0,0,0.5);
|
background-color: rgba(0,0,0,0.5);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
.modal.show {
|
.modal.show {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
/* Loading spinner */
|
/* Loading spinner */
|
||||||
.loading-spin {
|
.loading-spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
/* Notification */
|
/* Notification */
|
||||||
.notification {
|
.notification {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 5rem; /* Adjusted from bottom-20 */
|
bottom: 5rem; /* Adjusted from bottom-20 */
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 0.75rem 1.25rem; /* px-5 py-3 */
|
padding: 0.75rem 1.25rem; /* px-5 py-3 */
|
||||||
border-radius: 0.5rem; /* rounded-lg */
|
border-radius: 0.5rem; /* rounded-lg */
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 500; /* font-medium */
|
font-weight: 500; /* font-medium */
|
||||||
z-index: 50;
|
z-index: 1000; /* Increased z-index */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
.notification.show {
|
.notification.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
.notification.error {
|
.notification.error {
|
||||||
background-color: rgba(220, 38, 38, 0.8); /* danger-600 with opacity */
|
background-color: rgba(220, 38, 38, 0.8); /* danger-600 with opacity */
|
||||||
}
|
}
|
||||||
/* Scroll buttons */
|
/* Scroll buttons */
|
||||||
.scroll-buttons {
|
.scroll-buttons {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 1.25rem; /* right-5 */
|
right: 1.25rem; /* right-5 */
|
||||||
bottom: 5rem; /* bottom-20 */
|
bottom: 5rem; /* bottom-20 */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem; /* gap-2 */
|
gap: 0.5rem; /* gap-2 */
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
.scroll-button {
|
.scroll-button {
|
||||||
width: 2.5rem; /* w-10 */
|
width: 2.5rem; /* w-10 */
|
||||||
height: 2.5rem; /* h-10 */
|
height: 2.5rem; /* h-10 */
|
||||||
background-color: #4f46e5; /* bg-primary-600 */
|
background-color: #4f46e5; /* bg-primary-600 */
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 9999px; /* rounded-full */
|
border-radius: 9999px; /* rounded-full */
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* shadow-md */
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* shadow-md */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
.scroll-button:hover {
|
.scroll-button:hover {
|
||||||
background-color: #4338ca; /* hover:bg-primary-700 */
|
background-color: #4338ca; /* hover:bg-primary-700 */
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* hover:shadow-lg */
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* hover:shadow-lg */
|
||||||
}
|
}
|
||||||
{% block head_extra_styles %}
|
{% block head_extra_styles %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</style>
|
</style>
|
||||||
{% block head_extra_scripts %}{% endblock %}
|
{% block head_extra_scripts %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
|
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<!-- 底部版权 -->
|
<!-- 底部版权 -->
|
||||||
<div class="fixed bottom-0 left-0 w-full py-3 bg-white bg-opacity-80 backdrop-blur-md text-center text-sm text-gray-600 border-t border-gray-200">
|
<div
|
||||||
© <span id="copyright-year"></span> by
|
class="fixed bottom-0 left-0 w-full py-3 bg-white bg-opacity-80 backdrop-blur-md text-sm text-gray-800 border-t border-gray-200 flex flex-col items-center space-y-1"
|
||||||
<a href="https://linux.do/u/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
>
|
||||||
<img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily" class="inline-block w-5 h-5 rounded-full align-middle mr-1">snaily
|
<!-- 第一行 -->
|
||||||
</a> |
|
<div class="flex items-center justify-center space-x-2">
|
||||||
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
<span>© <span id="copyright-year"></span> by</span>
|
||||||
<i class="fab fa-github"></i> GitHub
|
<a
|
||||||
</a> |
|
href="https://linux.do/u/snaily"
|
||||||
<a href="https://afdian.com/a/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
target="_blank"
|
||||||
<i class="fas fa-drumstick-bite text-yellow-600"></i> 给作者加鸡腿
|
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif"
|
||||||
|
alt="snaily"
|
||||||
|
class="inline-block w-5 h-5 rounded-full align-middle mr-1"
|
||||||
|
/>snaily
|
||||||
</a>
|
</a>
|
||||||
<span class="mx-1">|</span>
|
<span class="text-gray-400">|</span>
|
||||||
<span class="text-xs text-yellow-600 font-semibold">
|
<a
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
|
href="https://github.com/snailyp/gemini-balance"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
|
||||||
|
>
|
||||||
|
<i class="fab fa-github mr-1"></i> GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- 第二行 -->
|
||||||
|
<div class="flex items-center justify-center space-x-2 text-xs">
|
||||||
|
<a
|
||||||
|
href="https://gb-docs.snaily.top/guide/supportme.html"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-drumstick-bite text-yellow-600 mr-1"></i> 给作者加鸡腿
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">|</span>
|
||||||
|
<a
|
||||||
|
href="https://gb-docs.snaily.top"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-book mr-1"></i> 在线文档
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">|</span>
|
||||||
|
<span class="text-yellow-600 font-semibold flex items-center">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
|
||||||
</span>
|
</span>
|
||||||
{% if request and request.app.state.update_info %}
|
<span id="version-info-container" class="inline-flex items-center">
|
||||||
{% set update_info = request.app.state.update_info %}
|
<!-- Version info will be loaded here by JavaScript -->
|
||||||
<span class="mx-1">|</span>
|
</span>
|
||||||
<span class="text-xs text-gray-500">v{{ update_info.current_version }}</span>
|
</div>
|
||||||
{% if update_info.update_available %}
|
|
||||||
<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{{ update_info.latest_version }}
|
|
||||||
</a>
|
|
||||||
{% elif update_info.error_message and update_info.error_message != 'Checking...' %}
|
|
||||||
<span class="mx-1">|</span>
|
|
||||||
<span class="text-xs text-red-500" title="{{ update_info.error_message }}">更新检查失败</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 通用JS -->
|
<!-- 通用JS -->
|
||||||
<script>
|
<script>
|
||||||
// 设置版权年份
|
// 设置版权年份
|
||||||
document.getElementById('copyright-year').textContent = new Date().getFullYear();
|
document.getElementById("copyright-year").textContent =
|
||||||
|
new Date().getFullYear();
|
||||||
|
|
||||||
// 滚动到顶部/底部函数 (如果页面需要)
|
// 滚动到顶部/底部函数 (如果页面需要)
|
||||||
function scrollToTop() {
|
function scrollToTop() {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
window.scrollTo({
|
||||||
|
top: document.body.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示通知
|
||||||
|
function showNotification(message, type = "success", duration = 3000) {
|
||||||
|
const notification =
|
||||||
|
document.getElementById("notification") ||
|
||||||
|
createNotificationElement();
|
||||||
|
if (!notification) return;
|
||||||
|
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.className = "notification show"; // Reset classes
|
||||||
|
if (type === "error") {
|
||||||
|
notification.classList.add("error");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示通知
|
// Clear previous timeout if exists
|
||||||
function showNotification(message, type = 'success', duration = 3000) {
|
if (notification.timeoutId) {
|
||||||
const notification = document.getElementById('notification') || createNotificationElement();
|
clearTimeout(notification.timeoutId);
|
||||||
if (!notification) return;
|
|
||||||
|
|
||||||
notification.textContent = message;
|
|
||||||
notification.className = 'notification show'; // Reset classes
|
|
||||||
if (type === 'error') {
|
|
||||||
notification.classList.add('error');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous timeout if exists
|
|
||||||
if (notification.timeoutId) {
|
|
||||||
clearTimeout(notification.timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.timeoutId = setTimeout(() => {
|
|
||||||
notification.classList.remove('show');
|
|
||||||
// Optional: remove the element after fade out if dynamically created
|
|
||||||
// setTimeout(() => notification.remove(), 300);
|
|
||||||
}, duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create notification element if it doesn't exist
|
notification.timeoutId = setTimeout(() => {
|
||||||
function createNotificationElement() {
|
notification.classList.remove("show");
|
||||||
let notification = document.getElementById('notification');
|
// Optional: remove the element after fade out if dynamically created
|
||||||
if (!notification) {
|
// setTimeout(() => notification.remove(), 300);
|
||||||
notification = document.createElement('div');
|
}, duration);
|
||||||
notification.id = 'notification';
|
}
|
||||||
notification.className = 'notification';
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
}
|
|
||||||
return notification;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面刷新带加载状态
|
// Helper to create notification element if it doesn't exist
|
||||||
function refreshPage(button) {
|
function createNotificationElement() {
|
||||||
if (button) {
|
let notification = document.getElementById("notification");
|
||||||
const icon = button.querySelector('i');
|
if (!notification) {
|
||||||
if (icon) {
|
notification = document.createElement("div");
|
||||||
icon.classList.add('loading-spin');
|
notification.id = "notification";
|
||||||
}
|
notification.className = "notification";
|
||||||
}
|
document.body.appendChild(notification);
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300); // Short delay to show spinner
|
|
||||||
}
|
}
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面刷新带加载状态
|
||||||
|
function refreshPage(button) {
|
||||||
|
if (button) {
|
||||||
|
const icon = button.querySelector("i");
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add("loading-spin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 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-700">检查更新中...</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-800">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>
|
</script>
|
||||||
{% block body_scripts %}{% endblock %}
|
{% block body_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,242 +1,636 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}错误日志管理 - Gemini Balance{%
|
||||||
|
endblock %} {% block head_extra_styles %}
|
||||||
{% block title %}错误日志管理 - Gemini Balance{% endblock %}
|
|
||||||
|
|
||||||
{% block head_extra_styles %}
|
|
||||||
<style>
|
<style>
|
||||||
/* error_logs.html specific styles */
|
/* error_logs.html specific styles */
|
||||||
/* Table styles */
|
.styled-table th {
|
||||||
.styled-table th {
|
position: sticky;
|
||||||
position: sticky;
|
top: 0;
|
||||||
top: 0;
|
background-color: rgba(80, 60, 160, 0.8); /* theming: table header bg */
|
||||||
background: #f3f4f6; /* bg-gray-100 */
|
color: #ffffff !important; /* theming: table header text, ensured light */
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
border-bottom: 1px solid rgba(120, 100, 200, 0.4);
|
||||||
|
}
|
||||||
|
.styled-table tbody tr:hover {
|
||||||
|
background-color: rgba(90, 70, 170, 0.4); /* theming: table row hover */
|
||||||
|
}
|
||||||
|
.styled-table td {
|
||||||
|
padding: 12px 20px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 250px;
|
||||||
|
color: #d1d5db; /* theming: table cell text (gray-300) */
|
||||||
|
border-bottom: 1px solid rgba(120, 100, 200, 0.2); /* theming: cell border */
|
||||||
|
}
|
||||||
|
.styled-table td:nth-child(4) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-view-details {
|
||||||
|
background-color: rgba(107, 70, 193, 0.4); /* theming */
|
||||||
|
color: #c4b5fd; /* theming */
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
border: 1px solid rgba(120, 100, 200, 0.6); /* theming */
|
||||||
|
}
|
||||||
|
.btn-view-details:hover {
|
||||||
|
background-color: rgba(120, 100, 200, 0.6); /* theming */
|
||||||
|
color: #ede9fe; /* theming */
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.styled-table tbody tr:hover {
|
}
|
||||||
background-color: #f9fafb; /* bg-gray-50 */
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
height: 36px !important;
|
||||||
|
}
|
||||||
|
.form-input-themed,
|
||||||
|
input[type="datetime-local"],
|
||||||
|
select#pageSize {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
border-color: rgba(120, 100, 200, 0.5) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.form-input-themed::placeholder,
|
||||||
|
input[type="datetime-local"]::placeholder {
|
||||||
|
color: #a0aec0 !important;
|
||||||
|
}
|
||||||
|
.form-input-themed:focus,
|
||||||
|
input[type="datetime-local"]:focus,
|
||||||
|
select#pageSize:focus {
|
||||||
|
border-color: #a78bfa !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.4) !important;
|
||||||
|
}
|
||||||
|
select#pageSize {
|
||||||
|
/* Styles from config_editor.html .form-select-themed, adapted for select#pageSize */
|
||||||
|
background-color: rgba(60, 40, 130, 0.6) !important;
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.7) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23d8b4fe' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 8l4 4 4-4'/%3e%3c/svg%3e") !important;
|
||||||
|
appearance: none !important;
|
||||||
|
padding: 0.6rem 2.5rem 0.6rem 0.8rem !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
background-position: right 0.6rem center !important;
|
||||||
|
background-size: 1.5em 1.5em !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
height: 36px !important; /* Retain original height or use auto */
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
select#pageSize:focus {
|
||||||
|
border-color: #d8b4fe !important; /* violet-300 */
|
||||||
|
box-shadow: 0 0 0 3px rgba(216, 180, 254, 0.4) !important; /* ring-violet-300 */
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
select#pageSize option {
|
||||||
|
background-color: rgba(76, 29, 149, 0.95) !important; /* 暗紫色背景 */
|
||||||
|
color: #ffffff !important;
|
||||||
|
padding: 8px !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%;
|
||||||
}
|
}
|
||||||
.styled-table td {
|
}
|
||||||
padding: 12px 20px;
|
label {
|
||||||
vertical-align: middle;
|
color: #e2e8f0 !important; /* Light gray/white for labels */
|
||||||
white-space: nowrap;
|
font-weight: 500;
|
||||||
overflow: hidden;
|
}
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 250px;
|
/* 导航链接悬停样式 (从 config_editor.html 复制) */
|
||||||
}
|
.nav-link {
|
||||||
/* Ensure error log column does not wrap and remove max-width */
|
transition: all 0.2s ease-in-out;
|
||||||
.styled-table td:nth-child(4) { /* Assuming error log is the 4th column */
|
}
|
||||||
/* max-width: none; */
|
|
||||||
white-space: nowrap;
|
.nav-link:hover {
|
||||||
}
|
background-color: rgba(120, 100, 200, 0.6) !important;
|
||||||
.btn-view-details {
|
transform: translateY(-2px);
|
||||||
background-color: #eef2ff; /* primary-50 */
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
color: #4f46e5; /* primary-600 */
|
}
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 6px;
|
/* Ensure text around pageSize select is light */
|
||||||
font-weight: 500;
|
.pagination-text {
|
||||||
transition: all 0.2s ease-in-out;
|
color: #e2e8f0 !important; /* Light gray/white for text */
|
||||||
border: 1px solid #c7d2fe; /* primary-200 */
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.btn-view-details:hover {
|
|
||||||
background-color: #c7d2fe; /* primary-200 */
|
/* Pagination custom styles */
|
||||||
color: #4338ca; /* primary-700 */
|
.pagination li a, .pagination li span { /* Assuming 'span' might be used for non-clickable items like '...' */
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
display: flex; /* For centering content if icons are used */
|
||||||
}
|
align-items: center;
|
||||||
@media (max-width: 768px) {
|
justify-content: center;
|
||||||
.search-container {
|
padding: 0.5rem 0.75rem; /* Adjust padding as needed */
|
||||||
grid-template-columns: 1fr;
|
line-height: 1.25;
|
||||||
}
|
color: #e2e8f0; /* Light gray/white text */
|
||||||
}
|
background-color: rgba(107, 70, 193, 0.4); /* Consistent with other buttons */
|
||||||
/* Modal styles are in base.html */
|
border: 1px solid rgba(120, 100, 200, 0.6); /* Consistent with other buttons */
|
||||||
|
border-radius: 0.375rem; /* Tailwind's rounded-md */
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
min-width: 36px; /* Ensure minimum width for small numbers */
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li a:hover, .pagination li span:hover:not(.disabled) { /* Avoid hover on disabled spans */
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: rgba(120, 100, 200, 0.6); /* Consistent with other button hovers */
|
||||||
|
border-color: rgba(167, 139, 250, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li.active a, .pagination li.active span { /* Assuming 'active' class for current page */
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-color: #7c3aed !important; /* Violet-600, ensure it overrides */
|
||||||
|
border-color: #7c3aed !important;
|
||||||
|
font-weight: 600; /* Make active page number bolder */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li.disabled a, .pagination li.disabled span { /* Assuming 'disabled' class */
|
||||||
|
color: rgba(226, 232, 240, 0.6) !important;
|
||||||
|
background-color: rgba(80, 60, 160, 0.3) !important; /* Slightly more visible than pure disabled */
|
||||||
|
border-color: rgba(120, 100, 200, 0.4) !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %} {% block content %}
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl shadow-xl p-6 md:p-8"
|
||||||
|
style="
|
||||||
|
background-color: rgba(80, 60, 160, 0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(150, 130, 230, 0.3);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-pink-400 mb-4"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/static/icons/logo.png"
|
||||||
|
alt="Gemini Balance Logo"
|
||||||
|
class="h-9 inline-block align-middle mr-2"
|
||||||
|
/>
|
||||||
|
Gemini Balance - 错误日志
|
||||||
|
</h1>
|
||||||
|
|
||||||
{% block content %}
|
<!-- Navigation Tabs -->
|
||||||
<div class="container mx-auto px-4"> <!-- Removed max-width-7xl for wider content -->
|
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
|
||||||
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
|
<a
|
||||||
<!-- Removed refresh button from top right -->
|
href="/config"
|
||||||
|
class="nav-link whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg text-gray-200 hover:text-white transition-all duration-200"
|
||||||
<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">
|
style="background-color: rgba(107, 70, 193, 0.4)"
|
||||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
>
|
||||||
Gemini Balance - 错误日志
|
<i class="fas fa-cog"></i> 配置编辑
|
||||||
</h1>
|
</a>
|
||||||
|
<a
|
||||||
<!-- Navigation Tabs -->
|
href="/keys"
|
||||||
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
|
class="nav-link whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg text-gray-200 hover:text-white transition-all duration-200"
|
||||||
<a href="/config" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
style="background-color: rgba(107, 70, 193, 0.4)"
|
||||||
<i class="fas fa-cog"></i> 配置编辑
|
>
|
||||||
</a>
|
<i class="fas fa-tachometer-alt"></i> 监控面板
|
||||||
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
</a>
|
||||||
<i class="fas fa-tachometer-alt"></i> 监控面板
|
<a
|
||||||
</a>
|
href="/logs"
|
||||||
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
|
class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-violet-600 text-white shadow-md"
|
||||||
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
>
|
||||||
</a>
|
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
|
||||||
<div class="bg-white bg-opacity-70 rounded-xl p-6 shadow-lg animate-fade-in">
|
|
||||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
|
||||||
<i class="fas fa-bug text-primary-600"></i> 错误日志列表
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- 控制区域 (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>
|
|
||||||
<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-5 py-3 font-semibold">Gemini密钥</th>
|
|
||||||
<th class="px-5 py-3 font-semibold">错误类型</th>
|
|
||||||
<th class="px-5 py-3 font-semibold">错误码</th>
|
|
||||||
<th class="px-5 py-3 font-semibold">模型名称</th>
|
|
||||||
<th class="px-5 py-3 font-semibold">请求时间</th>
|
|
||||||
<th class="px-5 py-3 font-semibold rounded-tr-lg">操作</th> <!-- Adjusted rounding -->
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="errorLogsTable" class="divide-y divide-gray-200">
|
|
||||||
<!-- 错误日志数据将通过JavaScript动态加载 -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 状态指示器 -->
|
|
||||||
<div id="loadingIndicator" class="flex items-center justify-center p-8 hidden">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
|
||||||
<p class="ml-4 text-lg text-gray-700 font-medium">加载中,请稍候...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="noDataMessage" class="text-center py-12 text-gray-500 hidden">
|
|
||||||
<i class="fas fa-inbox text-5xl mb-3"></i>
|
|
||||||
<p class="text-lg">暂无错误日志数据</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="errorMessage" class="bg-danger-50 text-danger-600 p-4 rounded-lg font-medium text-center hidden">
|
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
|
||||||
加载错误日志失败,请稍后重试。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页与每页显示控件 -->
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-center mt-6 gap-4">
|
|
||||||
<!-- 每页显示控件 (Moved here) -->
|
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<label for="pageSize" class="font-medium">每页显示:</label>
|
|
||||||
<select id="pageSize" class="rounded-md border border-gray-300 focus:ring focus:ring-primary-200 focus:border-primary-500 px-2 py-1 bg-white text-sm">
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="20" selected>20</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
<span>条</span>
|
|
||||||
</div>
|
|
||||||
<!-- 分页控件 -->
|
|
||||||
<div class="flex items-center gap-4"> <!-- Wrapper for pagination and input -->
|
|
||||||
<ul class="pagination flex items-center gap-1" id="pagination">
|
|
||||||
<!-- 分页控件将通过JavaScript动态加载 -->
|
|
||||||
</ul>
|
|
||||||
<!-- 页码输入跳转 -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input type="number" id="pageInput" min="1" class="w-16 px-2 py-1 rounded-md border border-gray-300 text-sm focus:ring focus:ring-primary-200 focus:border-primary-500" placeholder="页码">
|
|
||||||
<button id="goToPageBtn" class="px-3 py-1 bg-primary-600 hover:bg-primary-700 text-white text-sm rounded-md transition">跳转</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scroll buttons are now in base.html -->
|
|
||||||
<div class="scroll-buttons">
|
|
||||||
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
|
|
||||||
<i class="fas fa-chevron-up"></i>
|
|
||||||
</button>
|
|
||||||
<button class="scroll-button" onclick="scrollToBottom()" title="滚动到底部">
|
|
||||||
<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>
|
|
||||||
<!-- Footer is now in base.html -->
|
|
||||||
|
|
||||||
<!-- 日志详情模态框 -->
|
|
||||||
<div id="logDetailModal" class="modal">
|
|
||||||
<div class="w-full max-w-6xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in"> <!-- Increased max-width to 6xl -->
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex justify-between items-center border-b border-gray-200 pb-4 mb-4">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">错误日志详情</h2>
|
|
||||||
<button id="closeLogDetailModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
<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="复制错误日志">
|
|
||||||
<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>
|
|
||||||
<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="复制请求消息">
|
|
||||||
<i class="far fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
|
|
||||||
<p id="modalModelName" class="font-medium"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
|
|
||||||
<p id="modalRequestTime" class="font-medium"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end mt-6">
|
|
||||||
<button type="button" id="closeModalFooterBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg font-medium transition">关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_scripts %}
|
<!-- 主内容区域 -->
|
||||||
<script src="/static/js/error_logs.js"></script>
|
<div
|
||||||
<script>
|
class="rounded-xl p-6 shadow-lg animate-fade-in"
|
||||||
// error_logs.html specific JS initialization (if any)
|
style="
|
||||||
// e.g., initialize date pickers or other elements if needed
|
background-color: rgba(70, 50, 150, 0.5);
|
||||||
// The main logic is in error_logs.js
|
backdrop-filter: blur(5px);
|
||||||
</script>
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
border: 1px solid rgba(120, 100, 200, 0.2);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
class="text-xl font-bold mb-6 pb-3 border-b flex items-center gap-2 text-gray-100 border-violet-300 border-opacity-30"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bug text-violet-400"></i> 错误日志列表
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 搜索与操作控件 -->
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="keySearch"
|
||||||
|
placeholder="搜索密钥 (部分)"
|
||||||
|
class="px-3 py-1 rounded-lg border form-input-themed"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="errorSearch"
|
||||||
|
placeholder="搜索错误类型/日志"
|
||||||
|
class="px-3 py-1 rounded-lg border form-input-themed"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="errorCodeSearch"
|
||||||
|
placeholder="搜索错误码"
|
||||||
|
class="px-3 py-1 rounded-lg border form-input-themed"
|
||||||
|
/>
|
||||||
|
<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-300 whitespace-nowrap"
|
||||||
|
>开始时间:</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="startDate"
|
||||||
|
class="px-3 py-1 rounded-lg border text-sm w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-300 whitespace-nowrap"
|
||||||
|
>结束时间:</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="endDate"
|
||||||
|
class="px-3 py-1 rounded-lg border text-sm w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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-violet-600 hover:bg-violet-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<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-sky-600 hover:bg-sky-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"
|
||||||
|
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-red-600 hover:bg-red-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"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt mr-1.5"></i>删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格容器 -->
|
||||||
|
<div
|
||||||
|
class="overflow-x-auto rounded-lg border mb-6"
|
||||||
|
style="border-color: rgba(120, 100, 200, 0.3)"
|
||||||
|
>
|
||||||
|
<table class="styled-table w-full min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left">
|
||||||
|
<th
|
||||||
|
class="px-3 py-3 font-semibold rounded-tl-lg w-12 text-center"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="selectAllCheckbox"
|
||||||
|
class="form-checkbox h-4 w-4 text-violet-500 border-gray-500 rounded focus:ring-violet-500 bg-transparent"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
|
||||||
|
ID <i class="fas fa-sort ml-1"></i>
|
||||||
|
</th>
|
||||||
|
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
|
||||||
|
<th class="px-5 py-3 font-semibold">错误类型</th>
|
||||||
|
<th class="px-5 py-3 font-semibold">错误码</th>
|
||||||
|
<th class="px-5 py-3 font-semibold">模型名称</th>
|
||||||
|
<th class="px-5 py-3 font-semibold">请求时间</th>
|
||||||
|
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
id="errorLogsTable"
|
||||||
|
class="divide-y"
|
||||||
|
style="border-color: rgba(120, 100, 200, 0.2)"
|
||||||
|
>
|
||||||
|
<!-- 错误日志数据将通过JavaScript动态加载 -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态指示器 -->
|
||||||
|
<div
|
||||||
|
id="loadingIndicator"
|
||||||
|
class="flex items-center justify-center p-8 hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="animate-spin rounded-full h-12 w-12 border-b-2 border-violet-400"
|
||||||
|
></div>
|
||||||
|
<p class="ml-4 text-lg text-gray-300 font-medium">加载中,请稍候...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="noDataMessage" class="text-center py-12 text-gray-400 hidden">
|
||||||
|
<i class="fas fa-inbox text-5xl mb-3"></i>
|
||||||
|
<p class="text-lg">暂无错误日志数据</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="errorMessage"
|
||||||
|
class="p-4 rounded-lg font-medium text-center hidden"
|
||||||
|
style="background-color: rgba(220, 38, 38, 0.2); color: #fca5a5"
|
||||||
|
>
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
加载错误日志失败,请稍后重试。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页与每页显示控件 -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row justify-between items-center mt-6 gap-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<label for="pageSize" class="font-medium pagination-text"
|
||||||
|
>每页显示:</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="pageSize"
|
||||||
|
class="rounded-md border focus:ring focus:border-violet-400 px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20" selected>20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span class="pagination-text">条</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<ul class="pagination flex items-center gap-1" id="pagination">
|
||||||
|
<!-- 分页控件将通过JavaScript动态加载 -->
|
||||||
|
</ul>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="pageInput"
|
||||||
|
min="1"
|
||||||
|
class="w-16 px-2 py-1 rounded-md border text-sm focus:ring focus:border-violet-400 form-input-themed"
|
||||||
|
placeholder="页码"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="goToPageBtn"
|
||||||
|
class="px-3 py-1 bg-violet-600 hover:bg-violet-700 text-white text-sm rounded-md transition"
|
||||||
|
>
|
||||||
|
跳转
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll buttons are now in base.html -->
|
||||||
|
<div class="scroll-buttons">
|
||||||
|
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
|
||||||
|
<i class="fas fa-chevron-up"></i>
|
||||||
|
</button>
|
||||||
|
<button class="scroll-button" onclick="scrollToBottom()" title="滚动到底部">
|
||||||
|
<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>
|
||||||
|
<!-- Footer is now in base.html -->
|
||||||
|
|
||||||
|
<!-- 日志详情模态框 -->
|
||||||
|
<div id="logDetailModal" class="modal">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-6xl mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
|
||||||
|
style="
|
||||||
|
background-color: rgba(70, 50, 150, 0.95);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(120, 100, 200, 0.4);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center pb-4 mb-4"
|
||||||
|
style="border-bottom: 1px solid rgba(120, 100, 200, 0.4)"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-bold text-gray-100">错误日志详情</h2>
|
||||||
|
<button
|
||||||
|
id="closeLogDetailModalBtn"
|
||||||
|
class="text-gray-300 hover:text-gray-100 text-xl"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg relative group"
|
||||||
|
style="background-color: rgba(80, 60, 160, 0.3)"
|
||||||
|
>
|
||||||
|
<h6 class="text-sm font-semibold text-violet-200 mb-1">
|
||||||
|
Gemini密钥:
|
||||||
|
</h6>
|
||||||
|
<pre
|
||||||
|
id="modalGeminiKey"
|
||||||
|
class="font-mono text-sm p-3 rounded overflow-x-auto"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.2); color: #e5e7eb"
|
||||||
|
></pre>
|
||||||
|
<button
|
||||||
|
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.3)"
|
||||||
|
data-target="modalGeminiKey"
|
||||||
|
title="复制密钥"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg relative group"
|
||||||
|
style="background-color: rgba(80, 60, 160, 0.3)"
|
||||||
|
>
|
||||||
|
<h6 class="text-sm font-semibold text-violet-200 mb-1">错误类型:</h6>
|
||||||
|
<p id="modalErrorType" class="text-red-300 font-medium pr-8"></p>
|
||||||
|
<button
|
||||||
|
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.3)"
|
||||||
|
data-target="modalErrorType"
|
||||||
|
title="复制错误类型"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg relative group"
|
||||||
|
style="background-color: rgba(80, 60, 160, 0.3)"
|
||||||
|
>
|
||||||
|
<h6 class="text-sm font-semibold text-violet-200 mb-1">错误日志:</h6>
|
||||||
|
<pre
|
||||||
|
id="modalErrorLog"
|
||||||
|
class="font-mono text-sm p-3 rounded overflow-x-auto whitespace-pre-wrap"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.2); color: #e5e7eb"
|
||||||
|
></pre>
|
||||||
|
<button
|
||||||
|
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.3)"
|
||||||
|
data-target="modalErrorLog"
|
||||||
|
title="复制错误日志"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg relative group"
|
||||||
|
style="background-color: rgba(80, 60, 160, 0.3)"
|
||||||
|
>
|
||||||
|
<h6 class="text-sm font-semibold text-violet-200 mb-1">请求消息:</h6>
|
||||||
|
<pre
|
||||||
|
id="modalRequestMsg"
|
||||||
|
class="font-mono text-sm p-3 rounded overflow-x-auto whitespace-pre-wrap"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.2); color: #e5e7eb"
|
||||||
|
></pre>
|
||||||
|
<button
|
||||||
|
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.3)"
|
||||||
|
data-target="modalRequestMsg"
|
||||||
|
title="复制请求消息"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg relative group"
|
||||||
|
style="background-color: rgba(80, 60, 160, 0.3)"
|
||||||
|
>
|
||||||
|
<h6 class="text-sm font-semibold text-violet-200 mb-1">模型名称:</h6>
|
||||||
|
<p id="modalModelName" class="font-medium pr-8 text-gray-200"></p>
|
||||||
|
<button
|
||||||
|
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.3)"
|
||||||
|
data-target="modalModelName"
|
||||||
|
title="复制模型名称"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg relative group"
|
||||||
|
style="background-color: rgba(80, 60, 160, 0.3)"
|
||||||
|
>
|
||||||
|
<h6 class="text-sm font-semibold text-violet-200 mb-1">请求时间:</h6>
|
||||||
|
<p id="modalRequestTime" class="font-medium pr-8 text-gray-200"></p>
|
||||||
|
<button
|
||||||
|
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.3)"
|
||||||
|
data-target="modalRequestTime"
|
||||||
|
title="复制请求时间"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex justify-end mt-6 pt-4"
|
||||||
|
style="border-top: 1px solid rgba(120, 100, 200, 0.4)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="closeModalFooterBtn"
|
||||||
|
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-6 py-2 rounded-lg font-medium transition"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认模态框 -->
|
||||||
|
<div id="deleteConfirmModal" class="modal">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-md mx-auto rounded-xl shadow-xl overflow-hidden animate-fade-in"
|
||||||
|
style="
|
||||||
|
background-color: rgba(70, 50, 150, 0.95);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(120, 100, 200, 0.4);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center pb-3 mb-4"
|
||||||
|
style="border-bottom: 1px solid rgba(120, 100, 200, 0.4)"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100">确认删除</h2>
|
||||||
|
<button
|
||||||
|
id="closeDeleteConfirmModalBtn"
|
||||||
|
class="text-gray-300 hover:text-gray-100 text-xl"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="deleteConfirmMessage" class="text-gray-300 mb-6">
|
||||||
|
你确定要删除选中的项目吗?此操作不可恢复!
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
id="cancelDeleteBtn"
|
||||||
|
type="button"
|
||||||
|
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-5 py-2 rounded-lg font-medium transition"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="confirmDeleteBtn"
|
||||||
|
type="button"
|
||||||
|
class="bg-red-600 hover:bg-red-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>
|
||||||
|
// error_logs.html specific JS initialization (if any)
|
||||||
|
// e.g., initialize date pickers or other elements if needed
|
||||||
|
// The main logic is in error_logs.js
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,19 @@ import re
|
|||||||
import base64
|
import base64
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
import logging # Import logging
|
||||||
|
|
||||||
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
||||||
|
|
||||||
|
# Define logger for helper functions if needed, or use specific loggers
|
||||||
|
helper_logger = logging.getLogger("app.utils") # 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]:
|
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
|
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,5 +1,5 @@
|
|||||||
fastapi
|
fastapi
|
||||||
httpx
|
httpx[socks]
|
||||||
openai
|
openai
|
||||||
pydantic
|
pydantic
|
||||||
pydantic_settings
|
pydantic_settings
|
||||||
@@ -14,8 +14,8 @@ cryptography # 支持 MySQL 8+ caching_sha2_password 验证
|
|||||||
pymysql
|
pymysql
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
aiomysql
|
aiomysql
|
||||||
|
aiosqlite
|
||||||
databases
|
databases
|
||||||
python-dotenv
|
python-dotenv
|
||||||
apscheduler # 添加定时任务库
|
apscheduler
|
||||||
|
|
||||||
packaging
|
packaging
|
||||||
|
|||||||
Reference in New Issue
Block a user