Compare commits

...

128 Commits

Author SHA1 Message Date
snaily
929592bbc4 chore: 更新版本号至 2.1.2 2025-05-02 22:49:50 +08:00
snaily
2225a40bbe feat: 增加 Gemini 安全设置支持
- 新增 `SAFETY_SETTINGS` 配置项,允许用户通过环境变量或数据库配置 Gemini 模型的安全过滤级别。
- 更新后端服务 (`config.py`, `constants.py`, `gemini_routes.py`, `openai_routes.py`, `openai_chat_service.py`, `api_client.py`, `model_service.py`) 以支持和传递 `safety_settings` 参数。
- 在配置编辑器前端 (`config_editor.js`, `config_editor.html`) 添加了用于管理安全设置的用户界面。
- 将模型获取逻辑 (`model_service.py`, `api_client.py`) 改为异步。
- 优化 Service Worker (`service-worker.js`) 的缓存策略为 "cache then network"。

Bump version to 2.1.2
2025-05-02 22:49:36 +08:00
snaily
3480fa3b0f Merge branch 'pr/tbphp/74' 2025-05-02 18:17:50 +08:00
tbphp
d7113f5fc4 fix: 修复安全设置对输出速度的影响 2025-05-02 17:07:50 +08:00
snaily
2072f54ca1 refactor: 重构错误处理并优化路由与服务结构
主要变更:
- 新增 `app/handler/error_handler.py`,引入 `handle_route_errors` 异步上下文管理器,用于统一处理路由中的错误和日志记录。
- 在 `openai_routes` 和 `openai_compatiable_routes` 中应用 `handle_route_errors`,移除冗余的 try-except 块,简化路由逻辑。
- 将 `OpenAICompatiableService` 移动到 `app/service/openai_compatiable/` 目录下。
- 将 `StatsService` 移动到 `app/service/stats/` 目录下,并更新相关导入路径。
- 修复 `response_handler` 中处理 Gemini API 响应时 `inlineData` 字段的错误(原为 `inline_data`)。
- 修复 `openai_routes` 和 `openai_compatiable_routes` 中处理图像生成聊天(如 imagen3-chat)时未正确使用付费 API key 的问题。
- 在 `requirements.txt` 中将 `httpx` 更改为 `httpx[socks]`,以增加 SOCKS 代理支持。
2025-05-02 01:20:05 +08:00
snaily
7c9b721164 chore:更新 README.md,在 API 端点部分添加新的 OpenAI 兼容接口信息。 2025-04-30 20:49:14 +08:00
snaily
83ce50975a feat: 实现 OpenAI 兼容 API 端点和批量代理删除
新增与 OpenAI 规范兼容的 API 端点:
- `/openai/v1/models`
- `/openai/v1/chat/completions` (支持流式传输、重试和密钥切换)
- `/openai/v1/embeddings`
- `/openai/v1/images/generations`

包含:
- 在 `app/router/openai_compatiable_routes.py` 中新增路由。
- `OpenAICompatiableService` 用于处理请求逻辑、日志记录和错误管理。
- 更新 `OpenaiApiClient` 以支持新方法和代理使用。
- 修改 `app/domain/openai_models.py` 以实现兼容性。
- 为新 API 添加专用日志记录器 (`openai_compatible`)。
- 为新路由 (`/openai`, `/api/version/check`) 添加认证中间件豁免。

增强配置编辑器 UI:
- 在 `app/static/js/config_editor.js` 和 `app/templates/config_editor.html` 中添加批量代理删除功能。
2025-04-30 20:39:47 +08:00
snaily
7da9110704 feat: 添加代理支持 (HTTP/SOCKS5)
为应用程序添加了通过代理服务器访问 Gemini API 的功能。

主要变更包括:

*   **配置**:
    *   在 `.env.example` 和 `app/config/config.py` 中添加了 `PROXIES` 配置项,允许用户指定一个或多个 HTTP 或 SOCKS5 代理服务器列表。
    *   更新 `README.md` 以包含关于代理配置的说明。
*   **后端**:
    *   修改 `app/service/client/api_client.py` 中的 `GeminiApiClient`,使其在发起请求时能从配置的 `PROXIES` 列表中随机选择一个代理使用。
    *   添加了 `app/log/logger.py` 中的 `get_api_client_logger`,用于记录 API 客户端(包括代理使用)的相关日志。
*   **前端**:
    *   在 `app/templates/config_editor.html` 配置编辑器页面添加了代理列表的显示区域和“添加代理”按钮。
    *   实现了用于批量添加代理的模态框 UI。
    *   在 `app/static/js/config_editor.js` 中添加了处理代理列表显示、打开/关闭模态框以及处理批量添加代理(包括提取、去重和更新 UI)的 JavaScript 逻辑。
    *   确保在初始化配置时为 `PROXIES` 设置默认空列表。

此功能使得用户可以在需要通过代理访问外部网络的环境下使用该应用。
2025-04-30 10:57:17 +08:00
snaily
e9d19de7c6 refactor: 迁移媒体常量并重构相关处理逻辑
将音频/视频相关的配置(支持格式、大小限制、MIME类型)从 `config.py` 移动到 `core/constants.py`,以集中管理常量。

更新 `message_converter.py`:
- 从 `core.constants` 导入媒体常量。
- 添加并使用 `message_converter` 的专用日志记录器。
- 清理导入和代码格式。

更新 `openai_chat_service.py`:
- 调整 `_has_media_parts` 函数以正确检测 `inline_data`。
- 清理导入和代码格式。

在 `log/logger.py` 中添加 `get_message_converter_logger` 函数。

对 `config.py` 和 `response_handler.py` 进行了相关的移除和微小的代码清理。
2025-04-29 17:54:48 +08:00
Your Name (aider)
e822831178 fix: remove duplicate convert method in message converter 2025-04-26 03:35:16 +00:00
Your Name (aider)
775930edce feat: add support for audio and video input via base64
This commit adds configuration and conversion logic to handle audio and video inputs in base64 format, similar to existing image support. It includes:

1. Added supported formats and size limits in config
2. Implemented media validation and conversion in message converter
3. Updated payload building to handle media parts
4. Improved error handling and logging for media processing
2025-04-26 03:07:54 +00:00
snaily
cb40848c04 chore: 更新版本号至2.1.0 2025-04-26 03:34:06 +08:00
snaily
7098c8755f refactor: 改进调度器启动逻辑并清理日志
- 修改 `key_checker.py` 中的调度器启动逻辑,确保即使实例存在但未运行时也能启动。
- 在 `key_checker.py` 中添加了调度器启动和状态日志。
- 移除了 `application.py` 中数据库断开连接和调度器停止时的冗余关闭日志。
2025-04-26 03:27:13 +08:00
snaily
705d602dee refactor: 集中版本逻辑并添加版本检查API
- 将 `get_current_version` 函数从 `application.py` 移动到 `helpers.py` 以实现更好的代码组织和可重用性。
- 在 `version_routes.py` 中引入新的 API 端点 `/api/version/check`,以提供当前版本、最新可用版本和更新状态。
- 更新了 `base.html`,通过调用新的 API 端点,使用 JavaScript 异步获取和显示版本信息。这取代了以前服务器端渲染版本信息的方式,并增加了定期检查。
- 移除了应用程序启动时(`lifespan` 函数)的自动更新检查,因为版本检查现在由前端通过 API 处理。
- 在 `routes.py` 中注册了新的版本路由。
2025-04-26 03:04:40 +08:00
snaily
cd257a9406 feat(错误日志): 添加排序和删除功能
为错误日志页面增加了按 ID 排序以及单条和批量删除日志的功能。

主要变更:

后端 (Python/FastAPI):
- `services.py`:
    - `get_error_logs`: 添加 `sort_by` 和 `sort_order` 参数以支持排序。
    - 新增 `delete_error_logs`: 实现基于 ID 列表的批量删除。
    - 新增 `delete_error_log_by_id`: 实现基于单个 ID 的删除。
- `error_log_routes.py`:
    - `GET /api/logs/errors`: 添加 `sortBy` 和 `sortOrder` 查询参数以支持前端排序请求。
    - 新增 `DELETE /api/logs/errors`: 处理批量删除请求。
    - 新增 `DELETE /api/logs/errors/{log_id}`: 处理单条删除请求。
- `connection.py`: 移除了不再使用的同步 SQLAlchemy Session 相关代码。

前端 (HTML/JavaScript):
- `error_logs.html`:
    - 调整了搜索/操作区域布局,添加了批量删除按钮。
    - ID 表头增加排序图标和点击事件。
    - 表格行操作列添加了删除按钮。
    - 新增了删除确认模态框。
- `error_logs.js`:
    - 添加了处理 ID 排序点击的逻辑,更新排序状态并重新加载数据。
    - 添加了处理单条和批量删除按钮点击的逻辑。
    - 实现了删除确认模态框的显示/隐藏及确认逻辑。
    - 修改 `loadErrorLogs` 以包含排序参数。
    - 修改 `renderErrorLogs` 以添加行删除按钮和必要的 `data-log-id` 属性。
    - 更新了全选/取消全选逻辑以同步批量删除按钮状态。
2025-04-26 02:39:55 +08:00
snaily
cd54650431 feat(keys): 实现密钥状态页面的客户端分页、搜索与筛选
- 在 keys_status.html 中:
  - 重新设计有效密钥列表头部,添加密钥搜索框、失败次数筛选器和每页显示数量选择器,并优化布局。
  - 为有效和无效密钥列表添加分页控件容器。
  - 更新 CSS 样式以支持新的筛选/分页控件、Grid 布局和改进的响应式设计。
  - 移除内联的 DOMContentLoaded 初始化脚本,相关逻辑已移至 keys_status.js。
  - 为显示/隐藏密钥按钮添加 `title` 属性以提升可访问性。
  - 调整批量操作栏布局,允许换行。
- 在 keys_status.js 中:
  - 修改 `verifyKey` 函数,在验证成功或失败后通过 `showResultModal` 关闭时强制刷新页面。
  - 调整 `verifyKey` 和 `resetKeyFailCount` 中的按钮状态恢复逻辑,以适应页面刷新行为。
  - 清理了部分冗余代码和空行。
2025-04-25 23:56:48 +08:00
snaily
a5602c602e refactor:Enhances key verification and management UI
Refactors bulk key verification for improved error handling and reporting.
The UI is updated to use checkboxes for key selection and batch actions.
Adds detailed verification results modal to display success and failure details.
Improves key filtering, selection and actions for both valid and invalid keys.
Fixes visual glitches with section collapsing/expanding animations.
2025-04-25 20:34:11 +08:00
snaily
dd70fd4c44 fix(verify-keys): 修复无效秘钥的批量验证 2025-04-25 10:38:25 +08:00
snaily
dbe50628b3 feat(error-logs): 增强错误日志功能和UI交互
- 新增错误码搜索功能,支持精确匹配错误码
- 重构复制功能,支持批量选择和复制密钥
- 优化UI布局和交互体验,添加悬停复制按钮
- 重构路由结构,将log_routes.py重命名为error_log_routes.py
2025-04-23 18:31:19 +08:00
snaily
83ed0527d3 chore: 更新版本号至 2.0.11 2025-04-23 01:48:47 +08:00
snaily
ab31f4bb98 fix: 修正字段别名以保持一致性,调整 safetySettings、generationConfig 和 systemInstruction 的命名风格 2025-04-23 01:48:20 +08:00
snaily
734a8c4bc4 chore: 更新版本号至 2.0.10 2025-04-23 01:34:38 +08:00
snaily
fea3af4692 refactor: 优化代码格式,增强可读性;调整类型注解和字段命名风格 2025-04-23 01:33:47 +08:00
snaily
9302cf295e fix: 修复日志格式化器以支持文件名和行号,优化日志输出格式 2025-04-22 18:48:51 +08:00
snaily
b4f040e77a docs: 添加项目支持说明,鼓励用户通过爱发电支持项目 2025-04-22 13:08:42 +08:00
snaily
defabf4355 fix: 更新 SystemInstruction 的 parts 类型为支持 List 和单个字典;更新 base.html 添加支持作者的链接和警告信息 2025-04-22 13:04:32 +08:00
snaily
f3ed3168e4 Update README.md 2025-04-22 01:19:09 +08:00
snaily
01765b1731 refactor: 更新日志格式,增强可读性;移除初始化模块,整合初始化逻辑 2025-04-21 20:54:34 +08:00
snaily
f83f0fa768 chore:清理代码,移除不必要的注释和导入,优化日志记录和错误处理 2025-04-21 13:20:32 +08:00
snaily
a7085964e8 Update README.md 2025-04-21 10:54:25 +08:00
snaily
d3cd2856b7 Update README.md 2025-04-21 10:52:07 +08:00
snaily
353d22cc70 Update README.md 2025-04-21 10:51:51 +08:00
snaily
eb96474c19 Update README.md 2025-04-21 10:40:46 +08:00
snaily
0c48a2d74d Update README.md 2025-04-21 10:40:22 +08:00
snaily
1b23d574a5 feat: Dockerfile 中添加 VERSION 文件复制
将 VERSION 文件复制到 Docker 镜像中,以便在运行时可以访问版本信息。
2025-04-20 12:12:52 +08:00
snaily
ebc5dc571b chore: bump version to 2.0.8 2025-04-20 12:03:28 +08:00
snaily
9a7a1d7c2f feat(日志): 添加数据库日志记录并增强API重试/错误处理
- 为 Gemini 聊天(流式/非流式)、OpenAI 图像聊天(流式/非流式)和 embedding 服务的 API 调用实现全面的数据库日志记录。日志包括请求详情、成功/失败状态、状态码、延迟和错误消息。
- 重构 Gemini 流式聊天服务 (`stream_generate_content`) 以整合使用 `KeyManager` 的重试逻辑,与非流式实现保持一致,包括失败时的 API 密钥切换。
- 增强重试处理器 (`RetryHandler`) 的日志记录,以提高密钥切换和失败场景下的清晰度。
- 确保 `api_key` 正确传递给 OpenAI 图像聊天完成。
- 改进 embedding 服务中的错误处理,区分 `APIStatusError` 和通用异常,并将错误记录到数据库。
- 为 embedding 服务日志添加请求负载截断。
- 修复 Gemini `_build_payload` 中使用正确的 `model` 变量获取 `THINKING_BUDGET_MAP` 的错误。
- 移除 `ImageCreateService` 中未使用的 `paid_key` 类变量。
2025-04-20 12:02:00 +08:00
snaily
c99e090ea9 feat(stats): 添加密钥使用详情统计功能
新增功能允许用户在 Keys 状态页面点击“详情”按钮,查看指定 API 密钥在过去 24 小时内按模型分类的请求次数统计。

主要变更包括:

后端:
- 新增 `app/router/stats_routes.py`,包含 `/api/key-usage-details/{key}` API 端点用于获取密钥使用详情。
- 重构 `app/service/stats_service.py`,将统计相关函数封装到 `StatsService` 类中,并添加 `get_key_usage_details_last_24h` 方法。
- 在 `app/router/routes.py` 中注册新的 `stats_routes`,并更新对 `stats_service` 的调用方式以使用类实例。
- 更新 `app/log/logger.py` 添加 `get_scheduler_routes` 日志记录器,并在 `app/router/scheduler_routes.py` 中使用它。

前端:
- 在 `app/templates/keys_status.html` 中为每个有效和无效密钥列表项添加“详情”按钮。
- 在 `app/templates/keys_status.html` 中添加用于显示密钥使用详情的模态框 HTML 结构。
- 在 `app/static/js/keys_status.js` 中添加 JavaScript 函数 (`showKeyUsageDetails`, `closeKeyUsageDetailsModal`, `renderKeyUsageDetails`) 来处理按钮点击事件、调用后端 API、控制模态框显示/隐藏以及渲染获取到的统计数据。
2025-04-20 01:41:22 +08:00
snaily
eb311de0c2 feat: 添加思考模型配置并修复统计状态处理
- 在 README.md 中添加 THINKING_MODELS 和 THINKING_BUDGET_MAP 环境变量文档。
- 修复 stats_service.py 中的 get_api_call_details 函数,以正确处理 status_code 为 None 的情况,确保状态判断的健壮性。
2025-04-20 01:10:51 +08:00
snaily
c254077a66 feat(update): 实现应用内更新检查和版本显示
- 新增 `VERSION` 文件用于跟踪当前应用版本 (当前为 2.0.7)。
- 创建 `app/service/update/update_service.py` 服务,用于:
    - 从 `VERSION` 文件读取当前版本。
    - 通过 GitHub API 获取指定仓库 (`GITHUB_REPO_OWNER`/`GITHUB_REPO_NAME`) 的最新 Release Tag。
    - 使用 `packaging` 库比较版本,判断是否有可用更新。
- 在应用启动 (`app/core/application.py`) 时异步调用更新检查服务。
- 将当前版本和更新检查结果(是否可用、最新版本号、错误信息)存储在 `app.state.update_info` 中,供模板使用。
- 在基础模板 (`app/templates/base.html`) 的页脚动态显示当前版本。
- 如果检测到新版本,在页脚显示更新提示和指向最新 Release 的链接。
- 如果更新检查失败,在页脚显示错误提示。
- 在 `app/config/config.py` 中添加 `GITHUB_REPO_OWNER` 和 `GITHUB_REPO_NAME` 配置项,并提供默认值。
- 在 `requirements.txt` 中添加 `packaging` 依赖。
- 添加 `update_service` 专用的 logger (`app/log/logger.py`)。
- 改进配置编辑器 (`config_editor.js`, `config_editor.html`):
    - 限制预算输入框 (`budget_map`) 的值在 0 到 24576 之间。
    - 移除了预算映射项的删除按钮(预算项应随模型列表自动增删)。
    - 更新了预算输入的提示文本。
2025-04-19 23:45:33 +08:00
snaily
ef4a528611 feat(config, chat, ui): 添加思考模型及预算管理功能
引入了思考模型 (THINKING_MODELS) 和相应的预算映射 (THINKING_BUDGET_MAP) 的概念,允许在配置中指定用于特定内部处理流程(如“思考过程”)的模型及其 token 预算。

主要变更包括:

后端 (Python):
- 在 `Settings` 中添加了 `THINKING_MODELS` (List[str]) 和 `THINKING_BUDGET_MAP` (Dict[str, float]) 配置项。
- 增强了 `config._parse_db_value` 函数,以正确解析来自数据库或环境变量的列表和字典字符串(包括处理单引号和提供更详细的日志)。
- 更新了相关服务(如 `GeminiChatService`, `ModelService`, `ConfigService`)以识别和利用这些新配置。
- 调整了中间件和路由以适应可能的逻辑变更。

前端 (HTML/JavaScript):
- 在配置编辑器 (`config_editor.html`, `config_editor.js`) 中添加了新的 UI 部分来管理思考模型列表和预算映射。
- 实现了动态添加/删除思考模型的功能,并自动关联/解除关联对应的预算映射条目。
- 预算映射中的模型名称(键)是只读的,自动从思考模型列表同步;预算值(值)是可编辑的数字输入。
- 更新了表单数据的加载 (`populateForm`) 和收集 (`collectFormData`) 逻辑,以正确处理新的列表和映射类型。
- 移除了手动添加预算映射的按钮,改为自动关联。
- 改进了数组和映射项的 DOM 操作逻辑,包括使用 UUID 来关联模型和预算项。
2025-04-19 19:21:06 +08:00
snaily
f593d97381 Merge pull request #49 from toddyoe/main
chore: typo fixed for missing param
2025-04-18 23:43:54 +08:00
Toddy
053ef631c4 chore: typo fixed for missing param 2025-04-18 15:38:16 +00:00
snaily
075d20c62d chore: 已在 README.md 文件中添加了 LOG_LEVEL 环境变量的说明。 2025-04-18 22:03:23 +08:00
snaily
0768aed179 Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-04-18 21:54:04 +08:00
snaily
c2eac24175 feat: 添加可配置的日志级别
引入可配置的日志级别功能,允许用户通过配置编辑器和 `.env` 文件设置所需的日志详细程度。

主要变化:
- 在 `.env.example` 和 `app/config/config.py` 中添加了 `LOG_LEVEL` 设置。
- 修改了 `app/log/logger.py`,使其从设置中读取日志级别,并实现了对现有 logger 进行动态日志级别更新的功能。
- 更新了 `app/router/config_routes.py`,以便在保存配置后触发日志级别更新。
- 在 `app/templates/config_editor.html` 和 `app/static/js/config_editor.js` 中添加了日志级别选择的 UI 元素。
- 将 `app/router/gemini_routes.py` 和 `app/router/openai_routes.py` 中的一些日志调用从 `info` 调整为 `debug`,以降低默认输出的详细程度。
- 在 `README.md` 的“特别鸣谢”部分添加了 🎉 表情符号。
2025-04-18 21:53:54 +08:00
snaily
1c6dabcea7 更新 docker-compose.yml 2025-04-17 23:13:41 +08:00
snaily
76937aa24f chore:
增强文档: 在 README.md 文件中,新增了“特别鸣谢”部分,以感谢 PicGo、SM.MS 和 CloudFlare-ImgBed 为本项目提供的图床服务。同时,添加了“ Star History”部分,用于展示项目的 Star 历史,增强了文档的信息量和项目展示效果。
配置更正: 在配置编辑器 config_editor.html 中,更正了 Cloudflare 图床的 provider 名称。将原先的 cloudflare 更正为 cloudflare_imgbed,确保配置项名称的准确性和一致性。
2025-04-17 17:42:42 +08:00
snaily
b96ce8f15a Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-04-17 09:26:45 +08:00
snaily
87d60117c5 refactor:将 config_editor 页面中的提示(notification)样式完全统一为与 keys_status 页面一致的黑色半透明风格,无论提示类型均不会再出现绿色等色块。 2025-04-17 09:19:41 +08:00
snaily
a53a30fd38 Merge pull request #44 from yanhao98/0415-docker-compose 2025-04-16 13:57:12 +08:00
严浩
98e7fb62d5 feat(docker): 更新 MySQL 服务配置,添加健康检查 2025-04-16 10:19:40 +08:00
snaily
6a59b4f847 feat: 更新许可证为 CC BY-NC 4.0 并补充相关说明
- README.md 中将原 MIT 许可证声明修改为 CC BY-NC 4.0(署名-非商业性使用),并在开头和结尾增加了相关说明,明确禁止任何形式的商业倒卖服务,详情见 LICENSE 文件。
- 新增 LICENSE 文件,补充项目完整的 CC BY-NC 4.0 许可证内容。
2025-04-16 00:19:51 +08:00
snaily
d1ba2c4ae9 feat(config): 认证令牌输入框支持一键生成随机令牌
- 新增“生成随机令牌”按钮,优化认证令牌输入体验
- 支持自动生成并填充认证令牌,提升交互便捷性
2025-04-15 23:56:35 +08:00
snaily
0693a5c245 feat(keys_status): 支持批量验证密钥与选定密钥失败计数重置,增强自动刷新
- 后端新增 ResetSelectedKeysRequest、VerifySelectedKeysRequest 数据模型及相关 API 路由,实现批量重置选定密钥失败计数功能
- 前端 keys_status.js/keys_status.html 新增批量验证按钮、批量验证弹窗及交互逻辑,支持对筛选后密钥进行批量验证
- 自动刷新功能支持开关,优化用户体验
- UI 细节优化,提升密钥管理便捷性
2025-04-15 23:15:29 +08:00
snaily
742db744d1 feat(config_editor): 新增批量删除 API 密钥及令牌生成功能
- 实现 API 密钥的批量删除功能:
  - 在配置编辑器中添加“删除密钥”按钮和批量删除模态框。
  - 用户可以在模态框中粘贴密钥列表进行批量删除。
  - JavaScript 逻辑负责提取、匹配并移除列表中的密钥。
- 为 ALLOWED_TOKENS 字段添加内联随机令牌生成按钮,方便快速生成。
- 优化配置编辑器中数组项(如 API Key, Allowed Token)的 UI 布局和样式。
2025-04-14 23:29:51 +08:00
snaily
12a84921c1 refactor: 更新贡献者展示方式并添加友情项目链接 2025-04-13 17:22:14 +08:00
snaily
73e98a185d fix:修复gemini格式不能查询模型列表的问题 2025-04-13 12:45:23 +08:00
snaily
73a7c81f85 feat(logs): 添加错误日志详情查看功能并优化列表显示
本次提交主要围绕错误日志模块进行了功能增强和优化:

- **后端 (`database/services.py`, `router/log_routes.py`):**
    - 新增了根据日志 ID 获取单个错误日志完整详情(包括 `error_log` 和 `request_msg`)的数据库服务函数 (`get_error_log_details`) 和对应的 API 路由 (`/api/logs/errors/{log_id}/details`)。
    - 修改了获取错误日志列表的 API (`/api/logs/errors`):
        - 在返回数据中增加了 `error_code` 字段,以便前端展示。
        - 优化了数据库查询,明确指定需要选择的列,提升性能。
        - 将默认排序方式从按请求时间改为按日志 ID 降序排列,使最新的错误优先显示。
        - 改进了未授权访问时的处理,返回标准的 401 HTTP 状态码。
    - 更新了相关的 Pydantic 模型以匹配新的数据结构。

- **前端 (`static/js/error_logs.js`, `templates/error_logs.html`):**
    - 在错误日志列表页面,将原先显示部分错误日志内容的列修改为显示 "错误码"。
    - 实现了点击 "详情" 按钮时,通过异步请求新的详情 API 获取并展示完整的错误日志信息(包括详细错误日志和请求消息)的功能。
    - 在详情模态框中添加了加载状态提示和获取数据失败时的错误处理逻辑。
2025-04-13 04:36:34 +08:00
snaily
86dba93974 fix: 修复 error_logs.html 中的脚本路径错误 2025-04-13 01:16:59 +08:00
snaily
439165bc6c refactor: 移除 auth.js 并修复 error_logs.html 脚本路径
- 删除了不再使用的 `app/static/js/auth.js` 文件。
- 修正了 `app/templates/error_logs.html` 中 `error_logs.js` 的脚本引用路径,移除了 `url_for` 函数调用,直接使用静态路径。
2025-04-13 01:08:42 +08:00
snaily
0dd9dd5380 refactor(config): 将服务配置改为从 settings 获取
将 SecurityService, ModelService, EmbeddingService 的配置依赖从构造函数注入改为直接从 app.config.config.settings 获取。

这简化了服务类的实例化过程,并实现了配置的集中管理。
2025-04-12 21:35:38 +08:00
snaily
aea2f39952 feat: 更新文档、数据库配置和认证流程
- 重构 README.md,更新项目描述、结构、配置说明和 API 端点信息。
- 在 .env.example 中添加 MySQL 数据库配置项。
- 将数据库连接池回收时间从 1 小时减少到 30 分钟 (app/database/connection.py)。
- 修复认证成功后的重定向 URL,从 /keys 指向 /config (app/router/routes.py)。
- 微调认证页面的背景透明度 (app/templates/auth.html)。
- 添加 cryptography 依赖以支持 MySQL 8+ 认证 (requirements.txt)。
- 添加示例图片文件 (files/image*.png)。
2025-04-12 01:44:32 +08:00
snaily
f7cfc8952f feat(stats): 添加 API 调用详情查看功能
- 在 keys_status 页面添加了 API 调用统计卡片(1分钟/1小时/24小时)的可点击功能。
- 点击卡片会弹出一个模态框,显示对应时间段内的详细 API 调用记录,包括时间戳、部分 API 密钥、模型名称和调用状态(成功/失败)。
- 后端新增 `/api/stats/details` API 端点,用于根据请求的时间段('1m', '1h', '24h')从数据库查询并返回调用详情。
- 新增 `stats_service.get_api_call_details` 服务函数处理数据查询和格式化逻辑。
- 前端 `keys_status.js` 添加了 fetch 调用、模态框显示/隐藏以及数据渲染逻辑。
- 为 `keys_status` 页面添加了每 60 秒自动刷新的功能。
- 优化数据库连接配置,在 `create_engine` 中添加 `pool_pre_ping=True` 以提高连接可靠性。
2025-04-11 15:36:56 +08:00
snaily
7b4652c802 feat(monitoring): 添加 API 请求统计和监控面板
本次提交引入了 API 请求统计功能,并将原“密钥状态”页面重构为功能更全面的“监控面板”。

主要变更包括:

- **数据库与服务层:**
    - 新增 `RequestLog` 数据模型 (`app/database/models.py`),用于存储 API 请求的详细信息(时间、模型、密钥、成功状态、状态码、耗时)。
    - 在 `app/database/services.py` 中添加 `add_request_log` 和 `get_request_stats` 函数,分别用于记录单次请求和获取时间窗口内的统计数据。
    - 新增 `app/service/stats_service.py`,封装了获取 API 调用统计逻辑。

- **API 请求日志记录:**
    - 在 Gemini (`gemini_chat_service.py`) 和 OpenAI (`openai_chat_service.py`) 聊天服务中,于 API 调用前后添加了 `add_request_log` 调用,以记录请求的成功与否及耗时。

- **前端监控面板:**
    - 将 `/keys` 路由对应的页面 (`keys_status.html`) 从“密钥状态”重构为“监控面板”。
    - 页面顶部新增统计卡片区域,展示:
        - 密钥统计:总数、有效数、无效数。
        - API 调用统计:1分钟内、1小时内、24小时内、本月调用次数。
    - 密钥列表(有效/无效)采用响应式网格布局 (`grid`),并增加了悬停动效和边框高亮。
    - 优化了有效密钥列表的筛选逻辑,在无匹配项时显示提示信息。
    - 为新的统计卡片和列表项添加了相应的 CSS 样式。
    - 更新了 `keys_status.js` 以支持筛选无结果时的提示。

- **路由与导航:**
    - 在 `app/router/routes.py` 中添加了 `/stats` 端点,用于获取 API 统计数据。
    - 更新了 `config_editor.html` 和 `error_logs.html` 中的导航链接,使其指向新的“监控面板”。

- **日志配置:**
    - 在 `app/log/logger.py` 中,为 `sqlalchemy.exc` 设置了 WARNING 日志级别。

这些更改旨在提供更好的系统可观测性,方便用户监控 API 密钥状态和请求频率。
2025-04-11 14:45:03 +08:00
snaily
51bb71bdb5 ```git
feat: 添加密钥检查调度器并重构前端UI

主要变更:

- **调度器功能:**
    - 集成 APScheduler 实现定时任务,用于定期检查API密钥的有效性。
    - 在 `.env.example` 和 `app/config/config.py` 中添加了 `CHECK_INTERVAL_HOURS` 和 `TIMEZONE` 配置项。
    - 在应用生命周期 (`app/core/application.py`) 中添加了调度器的启动和停止逻辑。
    - 新增 `app/scheduler/` 目录及相关实现 (`key_checker.py`)。
    - 新增 `app/router/scheduler_routes.py` 用于调度器相关API (如果未来需要)。
    - 在 `requirements.txt` 中添加 `apscheduler` 依赖。

- **前端重构与改进:**
    - 引入 `app/templates/base.html` 作为基础模板,统一页面结构和样式引入。
    - 使用新的样式(推测为Tailwind CSS)重构了 `auth.html`, `config_editor.html`, `error_logs.html`, `keys_status.html` 页面,提升了UI一致性和响应式布局。
    - 删除了旧的CSS文件 (`auth.css`, `config_editor.css`, `error_logs.css`, `keys_status.css`)。
    - 更新了对应的 JavaScript 文件 (`config_editor.js`, `error_logs.js`, `keys_status.js`) 以适应新的HTML结构和交互。
    - 在 `keys_status.html` 页面增加了按失败次数过滤密钥、批量重置失败次数、确认模态框等功能。
    - 添加了新的 Logo 图片 (`logo.png`, `logo1.png`)。

- **其他:**
    - 更新了 `app/router/routes.py` 以包含新的路由。
    - 对 `app/service/key/key_manager.py` 和 `app/database/services.py` 进行了相关调整以支持新功能。
```
2025-04-11 03:16:51 +08:00
snaily
69261e98de feat(error_logs): 添加错误日志搜索和日期过滤功能
- 在后端 (`services.py`, `log_routes.py`) 实现按 Gemini 密钥(模糊匹配)、错误类型/内容(模糊匹配)和日期范围(开始/结束日期)过滤错误日志的逻辑。
- 添加新函数 `get_error_logs_count` 以高效获取符合过滤条件的总日志数,用于分页。
- 更新 `/api/logs/errors` API 端点以接受 `key_search`, `error_search`, `start_date`, `end_date` 查询参数。端点现在返回包含过滤后日志和总数的对象。
- 增强前端 (`error_logs.html`, `error_logs.js`, `error_logs.css`):
    - 添加用于密钥搜索、错误/日志搜索和日期范围选择的输入字段。
    - 实现 JavaScript 逻辑以捕获搜索参数,使用过滤器触发 API 调用,并在新搜索时重置到第一页。
    - 更新表格渲染以显示顺序行号而非数据库 ID。
    - 在表格视图中遮罩 Gemini 密钥(显示前/后 4 个字符)以提高可读性,同时仍在详细信息模态框中显示完整密钥。
    - 优化新搜索控件、表格外观(内边距、边框、悬停效果、斑马条纹)和按钮样式的 CSS,以提供更清晰的用户界面。
- 通过使用 `logger.exception` 包含堆栈跟踪来改进后端服务中的错误日志记录。
2025-04-10 19:16:06 +08:00
snaily
f05d67939f feat: 实现API请求重试并改进UI/UX
主要变更:

1.  **API 请求重试机制:**
    *   在配置 (`.env.example`, `config.py`, `constants.py`) 中添加 `MAX_RETRIES` 设置,用于控制 API 请求失败后的最大重试次数 (默认为 3)。
    *   更新 `RetryHandler` (`retry_handler.py`) 以使用此配置。
    *   将 `RetryHandler` 应用于 Gemini 和 OpenAI 的内容生成路由 (`gemini_routes.py`, `openai_routes.py`),使其能够根据配置进行重试。
    *   在配置编辑器页面 (`config_editor.html`) 添加 `MAX_RETRIES` 的输入字段。

2.  **密钥状态页面 (Keys Status) UI/UX 改进:**
    *   默认隐藏 API 密钥的完整内容,仅显示部分字符 (`keys_status.html`),提高安全性。
    *   添加了切换按钮和相应的 JavaScript (`keys_status.js`) 及 CSS (`keys_status.css`),允许用户点击查看或隐藏完整的密钥。
    *   更新了“复制密钥”功能 (`keys_status.js`),确保复制的是完整的密钥而非掩码后的部分。

3.  **错误日志页面 (Error Logs) 重构与改进:**
    *   重构了 HTML 结构 (`error_logs.html`),使用更一致和语义化的 class(如 `config-section`, `controls-container`, `styled-table`, `status-indicator`),并移除了 Bootstrap 依赖。
    *   更新了 CSS (`error_logs.css`) 以匹配新的 HTML 结构,改进了页面布局和视觉样式。
    *   改进了 JavaScript (`error_logs.js`),优化了加载、无数据、错误状态的显示逻辑,改进了分页功能,并添加了通用的通知显示函数 (`showNotification`)。
    *   在错误日志表格和详情弹窗中添加了“错误类型”列/字段。

4.  **其他:**
    *   对聊天服务 (`gemini_chat_service.py`, `openai_chat_service.py`) 和密钥管理器 (`key_manager.py`) 进行了相关更新
2025-04-10 18:32:21 +08:00
snaily
d94d24f96c feat(error_handling): 增强 API 错误处理和日志记录
- 扩展 ErrorLog 数据模型,增加 model_name, error_type, error_code 字段,以记录更详细的错误信息。
- 在 GeminiChatService 和 OpenAIChatService 中添加了 try-except 块,用于捕获 API 调用(包括普通和流式调用)时发生的异常。
- 实现从异常消息中通过正则表达式提取 HTTP 状态码的功能。
- 调用 add_error_log 服务将详细的错误信息(包括模型、错误类型、代码、请求体)持久化到数据库。
- 更新了 error_logs 前端页面,增加显示模型名称列及详情。
- 优化数据库连接池配置 (pool_recycle=3600),提高连接稳定性。
2025-04-10 15:40:02 +08:00
snaily
0f28173b0e refactor(config): 移除不必要的配置重新加载函数并优化设置更新逻辑 2025-04-10 09:34:29 +08:00
snaily
af310ffb6b refactor(router): Use dependency injection for chat services
Refactor GeminiChatService and OpenAIChatService instantiation
in gemini_routes.py and openai_routes.py respectively.

Utilize FastAPI's dependency injection (`Depends`) to manage
chat service instances per request, ensuring consistency and
adhering to FastAPI best practices. This removes manual
service creation within the route handlers.
2025-04-09 15:36:11 +08:00
snaily
169488851f feat: 集成数据库配置管理并添加错误日志查看器
主要变更:

1.  **数据库集成**:
    *   引入 MySQL 数据库支持,使用 SQLAlchemy 和 `databases` 库持久化存储应用程序设置。
    *   添加了 `app/database` 目录,包含数据库连接、模型和初始化逻辑。
    *   更新 `requirements.txt` 添加数据库相关依赖 (`pymysql`, `sqlalchemy`, `aiomysql`, `databases`, `python-dotenv`)。

2.  **配置管理重构**:
    *   重构 `ConfigService` (`app/service/config/config_service.py`),使其从数据库加载和保存设置,并支持从 `.env` 文件同步初始配置到数据库。
    *   修改 `Settings` 模型 (`app/config/config.py`) 以包含数据库连接信息,并添加了从数据库加载/同步配置的逻辑。
    *   配置相关的路由 (`app/router/config_routes.py`) 更新为异步,并调用新的 `ConfigService` 方法。
    *   `KeyManager` (`app/service/key/key_manager.py`) 现在可以在配置更新后重置和重新初始化。

3.  **错误日志查看器**:
    *   新增 `/logs` 页面 (`app/templates/error_logs.html`) 用于展示应用程序错误日志。
    *   添加了相应的路由 (`app/router/log_routes.py`)、静态资源 (`app/static/css/error_logs.css`, `app/static/js/error_logs.js`) 和日志记录器 (`app/log/logger.py`)。
    *   在配置页面和密钥管理页面的导航栏中添加了指向日志页面的链接。

4.  **异步操作**:
    *   将配置服务和相关路由转换为异步 (`async def`) 以支持异步数据库操作。

5.  **其他**:
    *   更新了应用程序初始化逻辑 (`app/core/application.py`, `app/core/initialization.py`) 以包含数据库连接的建立和关闭。
2025-04-09 15:04:29 +08:00
snaily
a7dc05a359 feat(keys_status): 更新验证按钮样式
- 将背景渐变更改为绿色调。
- 更新悬停状态下的 box-shadow 以匹配新颜色。
- 移除了 active 状态的样式以简化。
2025-04-06 01:20:59 +08:00
snaily
d0cc48ad63 Refactor: 优化配置编辑器模态框样式与结构
- 调整模态框 CSS (`app/static/css/config_editor.css`):
    - 将 `position` 改回 `fixed` 以确保其相对于视口定位。
    - 移除 `overflow: auto`,因为模态框内容通常不需要滚动条。
    - 移除 `backdrop-filter: blur(5px)` 以简化背景效果。
    - 添加 `align-items: center` 和 `justify-content: center` 以在 flex 容器中更好地居中模态框。
- 调整模态框 HTML (`app/templates/config_editor.html`):
    - 将 `apiKeyModal` 和 `resetConfirmModal` 两个模态框的 HTML 结构从主配置表单容器中移出,放置到 `</body>` 标签之前。这有助于改善 DOM 结构,并可能解决潜在的层叠或定位问题。

这些更改旨在改进配置编辑器页面上模态框的显示效果、定位方式和 DOM 结构。
2025-04-05 23:14:37 +08:00
snaily
5fc59a00d0 Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-04-05 21:54:02 +08:00
snaily
619f81cce4 feat: 添加Web配置编辑器界面
新增 `/config` 路由,提供一个可视化的配置编辑页面 (`config_editor.html`)。
用户现在可以通过网页界面管理:
- API 密钥(包括批量添加和重置确认)
- API 基础配置 (允许的令牌, 认证令牌, 基础URL, 最大失败次数, 超时)
- 模型相关配置 (测试模型, 图像/搜索/过滤模型列表, 代码执行/搜索链接/思考过程开关)
- 图像生成配置 (付费密钥, 模型, 上传提供商及相关密钥/URL)
- 流式输出优化器配置 (开关, 延迟, 阈值, 分块大小)

同时更新了 `/keys` 页面 (`keys_status.html`):
- 页面主标题更改为 "Gemini Balance"。
- 添加了顶部导航选项卡,方便在 "配置编辑" (`/config`) 和 "密钥管理" (`/keys`) 之间切换。
2025-04-05 21:52:58 +08:00
snaily
a6c162b223 Merge pull request #26 from toddyoe/main 2025-04-03 11:35:03 +08:00
Toddy
4c2f3ed9b0 typo: 解决TIME_OUT环境变量不生效的问题 2025-04-03 00:43:08 +00:00
snaily
ba38f14cd8 chore: 维护doc 2025-04-03 06:53:29 +08:00
snaily
47bf47d90e chore: 维护doc 2025-04-03 06:50:41 +08:00
snaily
cc36ba4c9e feat(config): 新增流式输出优化器开关配置
在环境变量示例文件(.env.example)和配置类(config.py)中新增 STREAM_OPTIMIZER_ENABLED 配置项,用于控制流式输出优化器的启用状态,默认设为 false

调整 Gemini 和 OpenAI 聊天服务的流式响应处理逻辑:
- 仅在流式优化器启用时(settings.STREAM_OPTIMIZER_ENABLED 为 true)
- 才会对文本内容执行流式输出优化处理
- 保持原有文本提取逻辑不变,仅增加配置条件判断

该变更使流式输出优化器变为可选功能,方便根据实际需求进行开关控制
2025-04-03 04:47:06 +08:00
snaily
baf643e884 feat: 新增请求超时配置及优化模型列表接口api_key获取方式
1. 新增功能:
   - 在`.env.example`中添加`TIME_OUT=300`配置项(包含中文注释)
   - 在`Settings`类中增加`TIME_OUT`字段(读取自`DEFAULT_TIMEOUT`)

2. 优化内容:
   - 生成配置:
     * 为`GenerationConfig`设置默认温度/TOP_P/TOP_K值
     * 移除`maxOutputTokens`默认值,改为可选传递
   - OpenAI请求:
     * 移除`max_tokens`默认值
     * 只有当`max_tokens`有值时才添加到请求payload
   - 日志优化:
     * 注释掉`stream_optimizer.py`中部分调试日志

3. 模型列表接口api_key获取方式
2025-04-03 03:12:59 +08:00
严浩
360bc9e48d feat(ci): 更新Docker发布工作流 2025-04-02 13:49:05 +08:00
snaily
c0a27d0542 Update README.md 2025-03-29 01:03:36 +08:00
snaily
84052a2179 feat(auth): 增强Gemini API的认证机制支持URL参数
- 将generate_content和stream_generate_content端点的认证依赖从verify_goog_api_key更改为verify_key_or_goog_api_key
- 使Gemini API同时支持URL参数中的key和请求头中的x-goog-api-key进行认证
- 提高API的灵活性,便于不同客户端集成
2025-03-28 23:44:40 +08:00
snaily
2e7ecd88b5 feat: 增强Gemini API tools参数处理
- 修改GeminiRequest模型,使tools字段支持单个工具对象或工具对象列表
- 在gemini_chat_service中添加类型转换逻辑,确保tools始终以列表形式处理
- 提高API的灵活性和兼容性
2025-03-28 20:50:01 +08:00
snaily
0b1f3dfc04 feat(auth): 支持x-goog-api-key请求头认证
- 添加verify_key_or_goog_api_key方法,支持同时验证URL参数中的key和请求头中的x-goog-api-key
- 更新models接口使用新的认证方法,提高与Google API客户端的兼容性
2025-03-28 19:27:42 +08:00
snaily
c691c7c1cf fix:当没有可用工具时返回空列表而非包含空字典的列表
在_build_tools函数中,当没有工具配置可用时(即tool为空字典),现在会返回空列表[]而不是[{}]。这个防御性编程修复可以避免向Gemini API发送无效的工具配置,防止可能的API调用错误。
2025-03-25 15:18:27 +08:00
snaily
97db7eebf1 chore:修改图片处理逻辑,统一使用base64编码
将_convert_image函数中对非data:image格式URL的处理方式从直接返回URL改为转换为base64编码的内联数据。这样无论图片是以data URI形式还是URL形式提供,都会统一转换为base64编码,确保与API交互时图片数据格式的一致性。
2025-03-25 13:23:17 +08:00
snaily
60dca70fcd fix: 改进图片显示和移除调试输出
优化图片链接格式,在图片前后添加空行以改善显示效果
注释掉OpenAI聊天服务中的调试打印语句
2025-03-22 03:38:45 +08:00
snaily
89b9f7919a feat: 添加对OpenAI工具调用功能的支持
改进消息转换器以处理OpenAI的tool_calls格式
添加JSON解析以正确转换函数调用参数
优化消息处理逻辑,增加更多空值检查
在流式响应中添加工具调用检测和处理
根据工具调用状态设置适当的finish_reason
2025-03-22 02:48:25 +08:00
Toddy
a8dc98ab6a fix tool use with function calling is unsupported error 2025-03-21 05:04:53 +00:00
snaily
b3a057b6ba refactor: 代码结构优化与常量化
将日志系统从 app/logger/ 移至 app/log/ 目录
将路由配置从 routers.py 重命名为 routes.py
将硬编码配置值移至 constants.py 中的默认常量
统一代码格式和导入排序
优化函数参数对齐方式
2025-03-20 21:59:18 +08:00
snaily
b14bb93d8f refactor: 项目结构优化与FastAPI生命周期更新
重构项目目录结构,提高代码组织性和可维护性

将schemas目录重命名为domain,更好地表达领域模型概念
将services目录细分为service/chat、service/image等子目录
将api目录重命名为router,更符合FastAPI惯例
创建utils目录存放通用工具函数
更新FastAPI应用程序生命周期管理

替换已弃用的on_event方法为推荐的lifespan事件处理器
添加应用程序关闭时的日志记录
代码质量改进

抽取常量到constants.py,减少硬编码值
添加helpers.py提供通用工具函数
优化配置管理,使用环境变量和默认值
完善文档字符串,提高代码可读性
2025-03-20 17:13:03 +08:00
snaily
8ca62707ea feat: 添加搜索模型配置并改进Markdown链接处理
在Dockerfile中添加SEARCH_MODELS环境变量,支持gemini-2.0-flash-exp和gemini-2.0-pro-exp模型
改进message_converter中的图片链接正则表达式
2025-03-19 19:56:50 +08:00
Toddy
21444ed6c7 chore: 统一从model_service读取模型列表 2025-03-18 18:05:00 +00:00
Toddy
ba292dbedd chore: 规范变量名 2025-03-18 17:54:18 +00:00
snaily
6ba58ce9d1 fix: 重构图片MIME类型转换逻辑
将"image/jpg"到"image/jpeg"的MIME类型转换逻辑从_convert_image函数移至_get_mime_type_and_data函数,避免代码重复并提高一致性。这确保了MIME类型的标准化处理发生在数据提取的同一位置。
2025-03-18 21:50:27 +08:00
snaily
16f16a3ae9 Merge branch 'pr/yangtb2024/13' 2025-03-18 21:46:34 +08:00
snaily
26dcb64687 fix: 将image/jpg MIME类型转换为标准的image/jpeg
修复了图像转换过程中的MIME类型处理,确保当遇到非标准的"image/jpg"类型时,将其转换为标准的"image/jpeg"类型。这样可以提高与接收图像数据的API和系统的兼容性
2025-03-18 21:35:19 +08:00
yangtb2024
df88492113 将chat-bison-001、text-bison-001和embedding-gecko-001添加到FILTERED_MODELS列表 2025-03-18 15:21:29 +08:00
yangtb2024
851bb9c09b 将 filtered_models 从硬编码改为可配置参数
1. 在 config.py 中添加 FILTERED_MODELS 配置项
2. 在 .env.example 中添加 FILTERED_MODELS 示例
3. 修改 model_service.py 以使用配置的过滤模型列表
4. 优化模型过滤逻辑
2025-03-18 14:47:58 +08:00
yangtb2024
0cac178572 Merge branch 'snailyp:main' into model 2025-03-18 12:44:09 +08:00
snaily
67c85c994a Merge pull request #14 from cr-zhichen/main
fix: 更新Cloudflare ImgBed上传请求URL,新增uploadNameType参数,以保持正确的目录结构命名。
2025-03-17 15:24:39 +08:00
cr-zhichen
ee979dd568 Merge branch 'main' of https://github.com/cr-zhichen/gemini-balance 2025-03-17 07:12:43 +00:00
cr-zhichen
e79a1ba56c feat: 更新CloudFlare ImgBed上传请求URL,新增uploadNameType参数,以保持正确的日期命名目录结构。 2025-03-17 07:10:21 +00:00
yangtb2024
016e6e06ee Filter out vision-based Gemini models from model list 2025-03-17 13:56:01 +08:00
snaily
8779a5f0b3 feat: 添加对 image-generation 模型的支持
在 gemini_chat_service 和 openai_chat_service 中添加对 "-image-generation" 后缀模型的支持
确保 image-generation 模型与 image 模型有相同的处理逻辑
2025-03-16 23:53:53 +08:00
cr-zhichen
89f2825ac7 feat: 新增对CloudFlare ImgBed的支持,更新环境变量和文档 2025-03-16 04:39:40 +00:00
snaily
985a12554d fix:修改OpenAI消息转换器中assistant消息处理逻辑,将特殊处理的目标从最后一条消息调整为倒数第二条消息。 2025-03-15 21:18:20 +08:00
snaily
37a7a140fc feat:改进消息转换器中的图像处理和消息分割逻辑
添加 _get_mime_type_and_data 函数从 base64 字符串中提取 MIME 类型和数据
修改 _convert_image 函数使用动态检测的 MIME 类型,而非硬编码
将 _process_text_with_image 中的 MIME 类型从 image/jpeg 改为 image/png
简化异常处理逻辑
优化 OpenAIMessageConverter 中的消息分割逻辑,仅对最后一个 assistant 消息进行分割处理
2025-03-15 21:11:10 +08:00
zhanghaoyu
28e67cc3fa 1. modify IMAGE_URL_PATTERN
2. modify import
2025-03-15 12:37:56 +08:00
zhanghaoyu7
d99a0bde93 feat: 新增图文上下文同步 2025-03-14 16:29:03 +08:00
snaily
cb5cd92041 fix: 修正Dockerfile中TOOLS_CODE_EXECUTION_ENABLED环境变量的拼写错误
将TOOLS_CODE_EXECUTION_ENABLED环境变量的值从"fasle"更正为"false",修复了拼写错误。
2025-03-14 13:46:31 +08:00
snaily
0be85e9536 feat(gemini_routes): 添加deepcopy导入
在gemini_routes.py中添加了Python标准库copy模块中的deepcopy函数导入,用于创建对象的深度副本,确保数据操作过程中不会意外修改原始对象。
2025-03-14 13:43:17 +08:00
Toddy
632dee38b3 check model before send request 2025-03-14 04:11:21 +00:00
Toddy
16c28bf1ba combine multiple system instructions into one 2025-03-14 02:55:29 +00:00
snaily
71af1db330 feat: 添加Gemini图像生成与处理功能
主要更新:

添加图像模型支持

新增MODEL_IMAGE配置项
在模型列表中添加gemini-2.0-flash-exp-image模型
修改ModelService以支持图像模型
增强图像处理能力

添加PicGoUploader类用于图像上传
实现图像响应处理逻辑(_extract_image_data)
支持base64图像数据的解码与上传
优化请求与响应处理

为图像模型添加特殊处理逻辑
修改API客户端以支持图像模型
更新GeminiRequest默认值
安全性调整

将TOOLS_CODE_EXECUTION_ENABLED默认设置为false
2025-03-14 00:27:23 +08:00
snaily
fb523f4a2e feat: 将 StreamOptimizer 参数改为可配置
将 StreamOptimizer 中的硬编码参数改为通过配置文件可配置的参数,提高了系统的灵活性。具体修改包括:

在 .env.example 中添加 stream_optimizer 相关配置参数
在 app/core/config.py 中添加对应的配置项
修改 app/services/chat/stream_optimizer.py 从配置中读取参数
在 README.md 中添加流式输出优化配置的详细说明
2025-03-06 16:56:01 +08:00
snaily
40e5ffa5f4 chore: Adjust StreamOptimizer parameters for improved performance
- Reduced long_text_threshold from 100 to 50 characters
- Decreased chunk_size from 10 to 5

These changes aim to optimize the streaming output for better user experience
and responsiveness, particularly for medium-length texts.
2025-03-06 16:45:35 +08:00
snaily
0871548b07 feat: 添加流式输出优化器以改善聊天体验
新增StreamOptimizer类用于优化API响应的流式输出
实现智能延迟调整算法,根据文本长度动态计算延迟时间
添加长文本分块输出功能,提高大段文本的显示效果
将优化器集成到Gemini和OpenAI聊天服务中
优化后的输出更接近自然打字效果,提升用户体验
2025-03-06 15:53:58 +08:00
snaily
5a44a76c48 Merge remote-tracking branch 'BetterAndBetterII/main' 2025-03-03 18:45:56 +08:00
Toddy
7b5b6c7d4c if role is tool then set to user 2025-03-03 08:23:04 +00:00
Yuzhong Zhang
68ed4da789 Update Dockerfile 2025-03-03 14:09:45 +08:00
Yuzhong Zhang
cdbca7ec62 优化dockerfile,增加docker-compose,async openai 2025-03-03 13:55:09 +08:00
Yuzhong Zhang
48d58ef2e8 异步生成完成 2025-03-03 13:41:06 +08:00
snaily
88d483c1ef Merge pull request #4 from toddyoe/main
chore: add system instruction to enhance compliance with function call
2025-02-27 19:17:39 +08:00
Toddy
8d48db026c chore: add system instruction to enhance compliance with function call 2025-02-27 10:35:25 +00:00
91 changed files with 12378 additions and 2905 deletions

View File

@@ -1,15 +1,53 @@
# MySQL数据库配置
MYSQL_HOST=gemini-balance-mysql
MYSQL_PORT=3306
MYSQL_USER=gemini
MYSQL_PASSWORD=change_me
MYSQL_DATABASE=default_db
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
ALLOWED_TOKENS=["sk-123456"]
# AUTH_TOKEN=sk-123456
MODEL_SEARCH=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
TOOLS_CODE_EXECUTION_ENABLED=true
TEST_MODEL=gemini-1.5-flash
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
IMAGE_MODELS=["gemini-2.0-flash-exp"]
SEARCH_MODELS=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
FILTERED_MODELS=["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"]
TOOLS_CODE_EXECUTION_ENABLED=false
SHOW_SEARCH_LINK=true
SHOW_THINKING_PROCESS=true
BASE_URL=https://generativelanguage.googleapis.com/v1beta
MAX_FAILURES=10
MAX_RETRIES=3
CHECK_INTERVAL_HOURS=1
TIMEZONE=Asia/Shanghai
# 请求超时时间(秒)
TIME_OUT=300
# 代理服务器配置 (支持 http 和 socks5)
# 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"]
PROXIES=[]
#########################image_generate 相关配置###########################
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
UPLOAD_PROVIDER=smms
SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PICGO_API_KEY=xxxx
CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
##########################################################################
#########################stream_optimizer 相关配置########################
STREAM_OPTIMIZER_ENABLED=false
STREAM_MIN_DELAY=0.016
STREAM_MAX_DELAY=0.024
STREAM_SHORT_TEXT_THRESHOLD=10
STREAM_LONG_TEXT_THRESHOLD=50
STREAM_CHUNK_SIZE=5
##########################################################################
######################### 日志配置 #######################################
# 日志级别 (debug, info, warning, error, critical),默认为 info
LOG_LEVEL=info
##########################################################################
# 安全设置 (JSON 字符串格式)
# 注意:这里的示例值可能需要根据实际模型支持情况调整
SAFETY_SETTINGS='[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}]'

View File

@@ -2,8 +2,6 @@ name: Docker Image CI
on:
push:
# branches: [ "main" ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
@@ -43,20 +41,30 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
# https://github.com/docker/metadata-action/tree/v5/?tab=readme-ov-file#semver
# Event: push, Ref: refs/head/main, Tags: main
# Event: push tag, Ref: refs/tags/v1.2.3, Tags: 1.2.3, 1.2, 1, latest
# Event: push tag, Ref: refs/tags/v2.0.8-rc1, Tags: 2.0.8-rc1
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=long
type=semver,pattern={{major}}
labels: |
org.opencontainers.image.description=OpenAI API Compatible Server
org.opencontainers.image.source=${{ github.event.repository.html_url }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
load: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,scope=${{ github.workflow }}

View File

@@ -3,15 +3,17 @@ FROM python:3.10-slim
WORKDIR /app
# 复制所需文件到容器中
COPY ./app /app/app
COPY ./requirements.txt /app
COPY ./VERSION /app
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /app/app
ENV API_KEYS='["your_api_key_1"]'
ENV ALLOWED_TOKENS='["your_token_1"]'
ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta
ENV TOOLS_CODE_EXECUTION_ENABLED=true
ENV MODEL_SEARCH='["gemini-2.0-flash-exp"]'
ENV TOOLS_CODE_EXECUTION_ENABLED=false
ENV IMAGE_MODELS='["gemini-2.0-flash-exp"]'
ENV SEARCH_MODELS='["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]'
# Expose port
EXPOSE 8000

17
LICENSE Normal file
View File

@@ -0,0 +1,17 @@
知识共享署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 协议
您可以自由地:
- 共享 — 在任何媒介以任何形式复制、发行本作品
- 演绎 — 修改、转换或以本作品为基础进行创作
惟须遵守下列条件:
- 署名 — 您必须给出适当的署名,提供指向本协议的链接,并指明是否(对原作)作了修改。您可以以任何合理方式进行,但不得以任何方式暗示许可方认可您或您的使用。
- 非商业性使用 — 您不得将本作品用于商业目的包括但不限于任何形式的商业倒卖、SaaS、API 付费接口、二次销售、打包出售、收费分发或其他直接或间接盈利行为。
如需商业授权,请联系原作者获得书面许可。违者将承担相应法律责任。
Creative Commons Attribution-NonCommercial 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode

546
README.md
View File

@@ -1,144 +1,79 @@
# 🚀 FastAPI OpenAI (Gemini) 代理服务
# Gemini Balance - Gemini API 代理和负载均衡器
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
## 📝 项目简介
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
本项目是一个基于 FastAPI 框架开发的高性能、易于部署的Gemini OpenAI兼容 和 Gemini API 代理服务。它不仅兼容 OpenAI 的 API 接口,还支持 Google 的 Gemini 原生接口。该代理服务内置了多 API Key 轮询、负载均衡、自动重试、访问控制Bearer Token 认证)、流式响应等功能,旨在简化 AI 应用的开发和部署流程。
[![Python](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://www.python.org/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.100%2B-green.svg)](https://fastapi.tiangolo.com/)
[![Uvicorn](https://img.shields.io/badge/Uvicorn-running-purple.svg)](https://www.uvicorn.org/)
**核心功能与优势:**
## 项目简介
- **多协议支持**: 无缝切换 OpenAI兼容 和 Gemini 协议
- **智能 API Key 管理**: 自动轮询多个 API Key实现负载均衡和故障转移。
- **安全访问控制**: 使用 Bearer Token 进行身份验证,保护 API 访问。
- **流式响应支持**: 提供实时的流式数据传输,提升用户体验。
- **内置工具支持**: 支持代码执行和 Google 搜索等工具, 丰富模型功能 (可选)。
- **灵活配置**: 通过环境变量或 `.env` 文件轻松配置。
- **易于部署**: 提供 Docker 一键部署,也支持手动部署。
- **健康检查**: 提供健康检查接口,方便监控服务状态。
- **图片生成支持**: 支持使用OpenAI的DALL-E模型生成图片
Gemini Balance 是一个基于 Python FastAPI 构建的应用程序,旨在提供 Google Gemini API 的代理和负载均衡功能。它允许您管理多个 Gemini API Key并通过简单的配置实现 Key 的轮询、认证、模型过滤和状态监控。此外,项目还集成了图像生成和多种图床上传功能,并支持 OpenAI API 格式的代理
## 🛠️ 技术栈
**项目结构:**
- **FastAPI**: 高性能 Web 框架。
- **Python 3.9+**: 编程语言。
- **Pydantic**: 数据验证和设置管理
- **httpx**: 异步 HTTP 客户端。
- **uvicorn**: ASGI 服务器。
- **Docker**: 容器化部署 (可选)
```plaintext
app/
├── config/ # 配置管理
├── core/ # 核心应用逻辑 (FastAPI 实例创建, 中间件等)
├── database/ # 数据库模型和连接
├── domain/ # 业务领域对象 (可选)
├── exception/ # 自定义异常
├── handler/ # 请求处理器 (可选, 或在 router 中处理)
├── log/ # 日志配置
├── main.py # 应用入口
├── middleware/ # FastAPI 中间件
├── router/ # API 路由 (Gemini, OpenAI, 状态页等)
├── scheduler/ # 定时任务 (如 Key 状态检查)
├── service/ # 业务逻辑服务 (聊天, Key 管理, 统计等)
├── static/ # 静态文件 (CSS, JS)
├── templates/ # HTML 模板 (如 Key 状态页)
├── utils/ # 工具函数
```
## ✨ 功能亮点
* **多 Key 负载均衡**: 支持配置多个 Gemini API Key (`API_KEYS`),自动按顺序轮询使用,提高可用性和并发能力。
* **可视化配置即时生效**: 通过管理后台修改配置后,无需重启服务即可生效,切记要点击保存才会生效。
![配置面板](files/image4.png)
* **双协议API 兼容**: 同时支持 Gemini 和 OpenAI 格式的 CHAT API 请求转发。
```palintext
openai baseurl `http://localhost:8000(/hf)/v1`
gemini baseurl `http://localhost:8000(/gemini)/v1beta`
```
* **支持图文对话和修改图片**: `IMAGE_MODELS`配置哪个模型可以图文对话和修图的功能,实际调用的时候,用 `配置模型-image`这个模型名对话使用该功能。
![对话生图](files/image6.png)
![修改图片](files/image7.png)
* **支持联网搜索**: 支持联网搜索,`SEARCH_MODELS`配置哪些模型可以联网搜索,实际调用的时候,用 `配置模型-search`这个模型名对话使用该功能
![联网搜索](files/image8.png)
* **Key 状态监控**: 提供 `/keys_status` 页面(需要认证),实时查看各 Key 的状态和使用情况。
![监控面板](files/image.png)
* **详细的日志记录**: 提供详细的错误日志,方便排查。
![调用详情](files/image1.png)
![日志列表](files/image2.png)
![日志详情](files/image3.png)
* **支持自定义gemini代理**: 支持自定义gemini代理比如自行在deno或者cloudflare上搭建gemini代理
* **openai画图接口兼容**: 将`imagen-3.0-generate-002`模型接口改造成openai画图接口支持客户端调用。
* **灵活的添加密钥方式**: 灵活的添加密钥方式,采用正则匹配`gemini_key`,密钥去重
![添加密钥](files/image5.png)
* **兼容openai格式embeddings接口**完美适配openai格式的`embeddings`接口,可用于本地文档向量化。
* **流式响应优化**: 可选的流式输出优化器 (`STREAM_OPTIMIZER_ENABLED`),改善长文本流式响应的体验。
* **失败重试与 Key 管理**: 自动处理 API 请求失败,进行重试 (`MAX_RETRIES`),并在 Key 失效次数过多时自动禁用 (`MAX_FAILURES`),定时检查恢复 (`CHECK_INTERVAL_HOURS`)。
* **Docker 支持**: 支持AMDARM架构的docker部署也可自行构建docker镜像。
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
* **模型列表自动维护**: 支持openai和gemini模型列表获取与newapi自动获取模型列表完美兼容无需手动填写。
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
* **代理支持**: 支持配置 HTTP/SOCKS5 代理服务器 (`PROXIES`),用于访问 Gemini API方便在特殊网络环境下使用。支持批量添加代理。
## 🚀 快速开始
### 环境要求
### 自行构建 Docker (推荐)
- Python 3.9 或更高版本
- Docker (可选,推荐用于生产环境)
### 📦 安装与配置
1. **克隆项目**:
```bash
git clone https://github.com/snailyp/gemini-balance.git
cd gemini-balance
```
2. **安装依赖**:
```bash
pip install -r requirements.txt
```
3. **配置**:
创建 `.env` 文件,并按以下分类配置环境变量:
```env
# 基础配置
BASE_URL="https://generativelanguage.googleapis.com/v1beta" # Gemini API 基础 URL默认无需修改
MAX_FAILURES=3 # 允许单个key失败的次数默认3次
# 认证与安全配置
API_KEYS=["your-gemini-api-key-1", "your-gemini-api-key-2"] # Gemini API 密钥列表,用于负载均衡
ALLOWED_TOKENS=["your-access-token-1", "your-access-token-2"] # 允许访问的 Token 列表
AUTH_TOKEN="" # 超级管理员token具有所有权限默认使用 ALLOWED_TOKENS 的第一个
# 模型功能配置
MODEL_SEARCH=["gemini-2.0-flash-exp"] # 支持搜索功能的模型列表
TOOLS_CODE_EXECUTION_ENABLED=false # 是否启用代码执行工具默认false
SHOW_SEARCH_LINK=true # 是否在响应中显示搜索结果链接默认true
SHOW_THINKING_PROCESS=true # 是否显示模型思考过程默认true
# 图片生成配置
PAID_KEY="your-paid-api-key" # 付费版API Key用于图片生成等高级功能
CREATE_IMAGE_MODEL="imagen-3.0-generate-002" # 图片生成模型默认使用imagen-3.0
# 图片上传配置
UPLOAD_PROVIDER="smms" # 图片上传提供商目前支持smms
SMMS_SECRET_TOKEN="your-smms-token" # SM.MS图床的API Token
```
### 配置说明
#### 基础配置
- `BASE_URL`: Gemini API 的基础 URL
- 默认值: `https://generativelanguage.googleapis.com/v1beta`
- 说明: 通常无需修改,除非 API 地址发生变化
- `MAX_FAILURES`: API Key 允许的最大失败次数
- 默认值: `3`
- 说明: 超过此次数后Key 将被暂时标记为无效
#### 认证与安全配置
- `API_KEYS`: Gemini API 密钥列表
- 格式: JSON 数组字符串
- 用途: 支持多个 Key 轮询,实现负载均衡
- 建议: 至少配置 2 个 Key 以保证服务可用性
- `ALLOWED_TOKENS`: 访问令牌列表
- 格式: JSON 数组字符串
- 用途: 用于客户端认证
- 安全提示: 请使用足够复杂的令牌
- `AUTH_TOKEN`: 超级管理员令牌
- 可选配置,留空则使用 ALLOWED_TOKENS 的第一个
- 具有查看 API Key 状态等特权操作权限
#### 模型功能配置
- `MODEL_SEARCH`: 搜索功能支持的模型
- 默认值: `["gemini-2.0-flash-exp"]`
- 说明: 仅列表中的模型可使用搜索功能
- `TOOLS_CODE_EXECUTION_ENABLED`: 代码执行功能
- 默认值: `false`
- 安全提示: 生产环境建议禁用
- `SHOW_SEARCH_LINK`: 搜索结果链接显示
- 默认值: `true`
- 用途: 控制搜索结果中是否包含原始链接
- `SHOW_THINKING_PROCESS`: 思考过程显示
- 默认值: `true`
- 用途: 显示模型的推理过程,便于调试
#### 图片生成配置
- `PAID_KEY`: 付费版 API Key
- 用途: 用于图片生成等高级功能
- 说明: 需要单独申请的付费版 Key
- `CREATE_IMAGE_MODEL`: 图片生成模型
- 默认值: `imagen-3.0-generate-002`
- 说明: 当前支持的最新图片生成模型
#### 图片上传配置
- `UPLOAD_PROVIDER`: 图片上传服务提供商
- 默认值: `smms`
- 说明: 目前支持 SM.MS 图床
- `SMMS_SECRET_TOKEN`: SM.MS API Token
- 用途: 用于图片上传到 SM.MS 图床
- 获取方式: 需要在 SM.MS 官网注册并获取
### ▶️ 运行
#### 使用 Docker (推荐)
#### a) dockerfile构建
1. **构建镜像**:
@@ -152,282 +87,155 @@
docker run -d -p 8000:8000 --env-file .env gemini-balance
```
- `-d`: 后台运行。
- `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
- `--env-file .env`: 使用 `.env` 文件设置环境变量。
* `-d`: 后台运行。
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
* `--env-file .env`: 使用 `.env` 文件设置环境变量。
#### 手动运行
#### b) 用现有的docker镜像部署
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
1. **拉取镜像**:
- `--reload`: 开启热重载,方便开发调试 (生产环境不建议开启)。
```bash
docker pull ghcr.io/snailyp/gemini-balance:latest
```
## 🔌 API 接口
2. **运行容器**:
### 认证
```bash
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
```
所有 API 请求都需要在 Header 中添加 `Authorization` 字段,值为 `Bearer <your-token>`,其中 `<your-token>` 需要替换为你在 `.env` 文件中配置的 `ALLOWED_TOKENS` 中的一个或者 `AUTH_TOKEN`
* `-d`: 后台运行
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口 (根据需要调整)。
* `--env-file .env`: 使用 `.env` 文件设置环境变量 (确保 `.env` 文件存在于执行命令的目录)。
### API 路由
### 本地运行 (适用于开发和测试)
本服务提供两种API路由
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作
1. **OpenAI 兼容路由** (推荐)
- 基础路径: `/v1`
- 完全兼容OpenAI API格式
- 支持所有Gemini模型
1. **确保已完成准备工作**:
* 克隆仓库到本地。
* 安装 Python 3.9 或更高版本。
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的“配置环境变量”部分)。
* 安装项目依赖:
2. **Gemini 原生路由**
- 基础路径: `/gemini/v1beta` 或 `/v1beta`
- 遵循Google原生API格式
- 适用于需要直接使用Gemini API的场景
```bash
pip install -r requirements.txt
```
### OpenAI兼容路由
2. **启动应用**:
在项目根目录下运行以下命令:
#### 获取模型列表
- **URL**: `/v1/models`
- **Method**: `GET`
- **Header**: `Authorization: Bearer <your-token>`
- **Response**: 返回支持的所有模型列表,包括最新的`gemini-2.0-flash-exp-search`等模型
#### 聊天补全 (Chat Completions)
- **URL**: `/v1/chat/completions`
- **Method**: `POST`
- **Header**: `Authorization: Bearer <your-token>`
- **Body** (JSON):
```json
{
"messages": [
{
"role": "user",
"content": "你好"
}
],
"model": "gemini-1.5-flash-002",
"temperature": 0.7,
"stream": false,
"tools": [],
"max_tokens": 8192,
"stop": [],
"top_p": 0.9,
"top_k": 40
}
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
- `messages`: 消息列表,格式与 OpenAI API 相同
- `model`: 模型名称支持所有Gemini模型包括:
- `gemini-1.5-flash-002`: 快速响应模型
- `gemini-2.0-flash-exp`: 实验性快速响应模型
- `gemini-2.0-flash-exp-search`: 支持搜索功能的实验性模型
- `stream`: 是否开启流式响应,`true` 或 `false`
- `tools`: 使用的工具列表
- 其他参数:与 OpenAI API 兼容的参数,如 `temperature`, `max_tokens` 等
* `app.main:app`: 指定 FastAPI 应用实例的位置 (`app` 模块中的 `main.py` 文件里的 `app` 对象)。
* `--host 0.0.0.0`: 使应用可以从本地网络中的任何 IP 地址访问。
* `--port 8000`: 指定应用监听的端口号 (您可以根据需要修改)。
* `--reload`: 启用自动重载功能。当您修改代码时,服务会自动重启,非常适合开发环境 (生产环境请移除此选项)。
### Gemini原生路由
3. **访问应用**:
应用启动后,您可以通过浏览器或 API 工具访问 `http://localhost:8000` (或您指定的主机和端口)。
#### 获取模型列表
### 完整配置项列表
- **URL**: `/gemini/v1beta/models` 或 `/v1beta/models`
- **Method**: `GET`
- **Header**: `Authorization: Bearer <your-token>`
| 配置项 | 说明 | 默认值 |
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
| **数据库配置** | | |
| `MYSQL_HOST` | 必填MySQL 数据库主机地址 | `localhost` |
| `MYSQL_PORT` | 必填MySQL 数据库端口 | `3306` |
| `MYSQL_USER` | 必填MySQL 数据库用户名 | `your_db_user` |
| `MYSQL_PASSWORD` | 必填MySQL 数据库密码 | `your_db_password` |
| `MYSQL_DATABASE` | 必填MySQL 数据库名称 | `defaultdb` |
| **API 相关配置** | | |
| `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"]` |
| `AUTH_TOKEN` | 可选超级管理员token具有所有权限不填默认使用 ALLOWED_TOKENS 的第一个 | `""` |
| `TEST_MODEL` | 可选,用于测试密钥是否可用的模型名 | `gemini-1.5-flash` |
| `IMAGE_MODELS` | 可选,支持绘图功能的模型列表 | `["gemini-2.0-flash-exp"]` |
| `SEARCH_MODELS` | 可选,支持搜索功能的模型列表 | `["gemini-2.0-flash-exp"]` |
| `FILTERED_MODELS` | 可选,被禁用的模型列表 | `["gemini-1.0-pro-vision-latest", ...]` |
| `TOOLS_CODE_EXECUTION_ENABLED` | 可选,是否启用代码执行工具 | `false` |
| `SHOW_SEARCH_LINK` | 可选,是否在响应中显示搜索结果链接 | `true` |
| `SHOW_THINKING_PROCESS` | 可选,是否显示模型思考过程 | `true` |
| `THINKING_MODELS` | 可选,支持思考功能的模型列表 | `[]` |
| `THINKING_BUDGET_MAP` | 可选,思考功能预算映射 (模型名:预算值) | `{}` |
| `BASE_URL` | 可选Gemini API 基础 URL默认无需修改 | `https://generativelanguage.googleapis.com/v1beta` |
| `MAX_FAILURES` | 可选允许单个key失败的次数 | `3` |
| `MAX_RETRIES` | 可选API 请求失败时的最大重试次数 | `3` |
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
| `PROXIES` | 可选,代理服务器列表 (例如 `http://user:pass@host:port`, `socks5://host:port`) | `[]` |
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
| **图像生成相关** | | |
| `PAID_KEY` | 可选付费版API Key用于图片生成等高级功能 | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
| `UPLOAD_PROVIDER` | 可选,图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
| `SMMS_SECRET_TOKEN` | 可选SM.MS图床的API Token | `your-smms-token` |
| `PICGO_API_KEY` | 可选,[PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
| `CLOUDFLARE_IMGBED_URL` | 可选,[CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
| `CLOUDFLARE_IMGBED_AUTH_CODE`| 可选CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
| **流式优化器相关** | | |
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
| `STREAM_MIN_DELAY` | 可选,流式输出最小延迟 | `0.016` |
| `STREAM_MAX_DELAY` | 可选,流式输出最大延迟 | `0.024` |
| `STREAM_SHORT_TEXT_THRESHOLD`| 可选,短文本阈值 | `10` |
| `STREAM_LONG_TEXT_THRESHOLD` | 可选,长文本阈值 | `50` |
| `STREAM_CHUNK_SIZE` | 可选,流式输出块大小 | `5` |
#### 生成内容
## ⚙️ API 端点
- **URL**: `/gemini/v1beta/models/{model_name}:generateContent`
- **Method**: `POST`
- **Header**: `Authorization: Bearer <your-token>`
以下是服务提供的主要 API 端点:
#### 流式生成内容
### Gemini API 相关 (`(/gemini)/v1beta`)
- **URL**: `/gemini/v1beta/models/{model_name}:streamGenerateContent`
- **Method**: `POST`
- **Header**: `Authorization: Bearer <your-token>`
* `GET /models`: 列出可用的 Gemini 模型。
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
### 获取词向量 (Embeddings)
### OpenAI API 相关
- **URL**: `/v1/embeddings`
- **Method**: `POST`
- **Header**: `Authorization: Bearer <your-token>`
- **Body** (JSON):
```json
{
"input": "你的文本",
"model": "text-embedding-004"
}
```
- `input`: 输入文本。
- `model`: 模型名称。
### 健康检查
- **URL**: `/health`
- **Method**: `GET`
### Web界面功能
#### 验证页面 (auth.html)
- **URL**: `/auth`
- **说明**: 提供了一个简洁的Web界面用于验证访问令牌
- **功能特点**:
- 现代化的渐变背景设计
- 响应式布局,完美支持移动端
- 毛玻璃效果的卡片设计
- 优雅的动画效果(淡入、滑动、悬浮)
- 安全的令牌验证机制
- 清晰的错误提示功能
- PWA支持可安装为本地应用
- 底部版权信息和GitHub链接
- 支持暗色主题适配
#### API密钥状态管理 (keys_status.html)
- **URL**: `/v1/keys/list`
- **Method**: `GET`
- **Header**: `Authorization: Bearer <your-auth-token>`
- **功能特点**:
- 只有使用 `AUTH_TOKEN` 才能访问此接口
- 分类展示API密钥状态有效/无效)
- 可折叠的密钥列表分组
- 每个密钥显示:
- 状态标识(有效/无效)
- 密钥内容
- 失败次数统计
- 高级功能:
- 一键复制单个密钥
- 批量复制分组密钥JSON格式
- 实时刷新功能
- 回到顶部/底部快捷按钮
- 界面特性:
- 响应式设计,适配各种屏幕
- 优雅的动画效果
- 操作反馈(复制成功提示)
- PWA支持
- 暗色主题适配
### 图片生成 (Image Generation)
- **URL**: `/v1/images/generations`
- **Method**: `POST`
- **Header**: `Authorization: Bearer <your-auth-token>`
- **说明**: Body示例和参数说明
```json
{
"model": "dall-e-3",
"prompt": "{n:2} {ratio:16:9} 汉服美女",
"n": 1,
"size": "1024x1024"
}
```
**Prompt参数说明:**
prompt支持通过特殊标记来控制生成参数
1. 图片数量控制:
- 格式: `{n:数量}`
- 示例: `{n:2} 一只可爱的猫` - 生成2张图片
- 取值范围: 1-4
- 说明: 如果在prompt中指定了n将覆盖请求body中的n参数
2. 图片比例控制:
- 格式: `{ratio:宽:高}`
- 示例: `{ratio:16:9} 一片森林` - 生成16:9比例的图片
- 支持的比例: "1:1"、"3:4"、"4:3"、"9:16"、"16:9"
- 说明: 如果指定了size参数将优先使用size对应的比例
3. 参数组合:
- 示例: `{n:2} {ratio:16:9} 一片美丽的森林` - 生成2张16:9比例的图片
- 说明: 这些参数标记会自动从prompt中移除不会影响实际的图片生成提示词
> 注意n的取值范围[1,4], ratio取值范围"1:1"、"3:4"、"4:3"、"9:16" 和 "16:9"
## 📚 代码结构
```plaintext
.
├── app/
│ ├── api/ # API 路由
│ │ ├── gemini_routes.py # Gemini 模型路由
│ │ └── openai_routes.py # OpenAI 兼容路由
│ ├── core/ # 核心组件
│ │ ├── config.py # 配置管理
│ │ ├── logger.py # 日志配置
│ │ └── security.py # 安全认证
│ ├── middleware/ # 中间件
│ │ └── request_logging_middleware.py # 请求日志中间件
│ ├── schemas/ # 数据模型
│ │ ├── gemini_models.py # Gemini 原始请求/响应模型
│ │ └── openai_models.py # OpenAI 兼容请求/响应模型
│ ├── services/ # 服务层
│ │ ├── chat/ # 聊天相关服务
│ │ │ ├── api_client.py # API 客户端
│ │ │ ├── message_converter.py # 消息转换器
│ │ │ ├── response_handler.py # 响应处理器
│ │ │ └── retry_handler.py #重试处理器
│ │ ├── gemini_chat_service.py # Gemini 原始聊天服务
│ │ ├── openai_chat_service.py # OpenAI 兼容聊天服务
│ │ ├── embedding_service.py # 向量服务
│ │ ├── key_manager.py # API Key 管理
│ │ └── model_service.py # 模型服务
│ └── main.py # 主程序入口
├── Dockerfile # Dockerfile
├── requirements.txt # 项目依赖
└── README.md # 项目说明
```
## 🔒 安全性
- **API Key 轮询**: 自动轮换 API Key提高可用性和负载均衡。
- **Bearer Token 认证**: 保护 API 端点,防止未经授权的访问。
- **请求日志记录**: 记录详细的请求信息,便于调试和审计 (可选,通过取消 `app.add_middleware(RequestLoggingMiddleware)` 的注释来启用)。
- **自动重试**: 在 API 请求失败时自动重试,提高服务的稳定性。
* `GET (/hf)/v1/models`: 列出可用的模型 (底层用的gemini格式)。
* `POST (/hf)/v1/chat/completions`: 进行聊天补全 (底层用的gemini格式, 支持流式传输)。
* `POST (/hf)/v1/embeddings`: 创建文本嵌入 (底层用的gemini格式)。
* `POST (/hf)/v1/images/generations`: 生成图像 (底层用的gemini格式)。
* `GET /openai/v1/models`: 列出可用的模型 (底层用的openai格式)。
* `POST /openai/v1/chat/completions`: 进行聊天补全 (底层用的openai格式, 支持流式传输, 可防止截断,速度也快)。
* `POST /openai/v1/embeddings`: 创建文本嵌入 (底层用的openai格式)。
* `POST /openai/v1/images/generations`: 生成图像 (底层用的openai格式)。
## 🤝 贡献
欢迎任何形式的贡献!如果你发现 bug、有新功能建议或者想改进代码请随时提交 Issue 或 Pull Request。
欢迎提交 Pull Request 或 Issue
1. Fork 本项目。
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)。
3. 提交你的改动 (`git commit -m 'Add some AmazingFeature'`)。
4. 推送到你的分支 (`git push origin feature/AmazingFeature`)。
5. 创建一个新的 Pull Request。
## 🎉 特别鸣谢
## ❓ 常见问题解答 (FAQ)
特别鸣谢以下项目和平台为本项目提供图床服务:
**Q: 如何获取 Gemini API Key**
* [PicGo](https://www.picgo.net/)
* [SM.MS](https://smms.app/)
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 开源项目
A: 请参考 Gemini API 的官方文档,申请 API Key。
## 🙏 感谢贡献者
**Q: 如何配置多个 API Key**
感谢所有为本项目做出贡献的开发者!
A: 在 `.env` 文件的 `API_KEYS` 变量中,用列表的形式添加多个 Key例如`API_KEYS=["key1", "key2", "key3"]`。
[![Contributors](https://contrib.rocks/image?repo=snailyp/gemini-balance)](https://github.com/snailyp/gemini-balance/graphs/contributors)
**Q: 为什么我的 API Key 总是失败?**
## ⭐ Star History
A: 请检查以下几点:
[![Star History Chart](https://api.star-history.com/svg?repos=snailyp/gemini-balance&type=Date)](https://star-history.com/#snailyp/gemini-balance&Date)
- API Key 是否正确。
- API Key 是否已过期或被禁用。
- 是否超出了 API Key 的速率限制或配额。
- 网络连接是否正常。
## 💖 友情项目
**Q: 如何启用流式响应?**
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线AI驱动的热点事件时间轴生成工具
A: 在请求的 Body 中,将 `stream` 参数设置为 `true` 即可。
## 🎁 项目支持
**Q: 如何启用代码执行工具?**
如果你觉得这个项目对你有帮助,可以考虑通过 [爱发电](https://afdian.com/a/snaily) 支持我。
A: 在 `.env` 文件的 `TOOLS_CODE_EXECUTION_ENABLED` 变量中, 设置为 `true` 即可。
## 许可证
## 📄 许可证
本项目采用 MIT 许可证。有关详细信息,请参阅 [LICENSE](LICENSE) 文件 (你需要创建一个 LICENSE 文件)。
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2.1.2

View File

@@ -1,131 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from app.core.config import settings
from app.core.logger import get_gemini_logger
from app.core.security import SecurityService
from app.schemas.gemini_models import GeminiContent, GeminiRequest
from app.services.gemini_chat_service import GeminiChatService
from app.services.key_manager import KeyManager, get_key_manager_instance
from app.services.model_service import ModelService
from app.services.chat.retry_handler import RetryHandler
router = APIRouter(prefix="/gemini/v1beta")
router_v1beta = APIRouter(prefix="/v1beta")
logger = get_gemini_logger()
# 初始化服务
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
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()
model_service = ModelService(settings.MODEL_SEARCH)
@router.get("/models")
@router_v1beta.get("/models")
async def list_models(_=Depends(security_service.verify_key),
key_manager: KeyManager = Depends(get_key_manager)):
"""获取可用的Gemini模型列表"""
logger.info("-" * 50 + "list_gemini_models" + "-" * 50)
logger.info("Handling Gemini models list request")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
models_json = model_service.get_gemini_models(api_key)
models_json["models"].append({"name": "models/gemini-2.0-flash-exp-search", "version": "2.0",
"displayName": "Gemini 2.0 Flash Search Experimental",
"description": "Gemini 2.0 Flash Search Experimental", "inputTokenLimit": 32767,
"outputTokenLimit": 8192,
"supportedGenerationMethods": ["generateContent", "countTokens"], "temperature": 1,
"topP": 0.95, "topK": 64, "maxTemperature": 2})
return models_json
@router.post("/models/{model_name}:generateContent")
@router_v1beta.post("/models/{model_name}:generateContent")
@RetryHandler(max_retries=3, key_arg="api_key")
async def generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_goog_api_key),
api_key: str = Depends(get_next_working_key_wrapper),
key_manager: KeyManager = Depends(get_key_manager)
):
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
"""非流式生成内容"""
logger.info("-" * 50 + "gemini_generate_content" + "-" * 50)
logger.info(f"Handling Gemini content generation request for model: {model_name}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
try:
response = chat_service.generate_content(
model=model_name,
request=request,
api_key=api_key
)
return response
except Exception as e:
logger.error(f"Chat completion failed after retries: {str(e)}")
raise HTTPException(status_code=500, detail="Chat completion failed") from e
@router.post("/models/{model_name}:streamGenerateContent")
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
@RetryHandler(max_retries=3, key_arg="api_key")
async def stream_generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_goog_api_key),
api_key: str = Depends(get_next_working_key_wrapper),
key_manager: KeyManager = Depends(get_key_manager)
):
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
"""流式生成内容"""
logger.info("-" * 50 + "gemini_stream_generate_content" + "-" * 50)
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
try:
response_stream = chat_service.stream_generate_content(
model=model_name,
request=request,
api_key=api_key
)
return StreamingResponse(response_stream, media_type="text/event-stream")
except Exception as e:
logger.error(f"Streaming request failed: {str(e)}")
@router.post("/verify-key/{api_key}")
async def verify_key(api_key: str):
key_manager = await get_key_manager()
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
"""验证Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
logger.info("Verifying API key validity")
try:
# 使用generate_content接口测试key的有效性
gemini_requset = GeminiRequest(
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}]
)
]
)
response = chat_service.generate_content(settings.TEST_MODEL,gemini_requset, api_key)
if response:
return JSONResponse({"status": "valid"})
return JSONResponse({"status": "invalid"})
except Exception as e:
logger.error(f"Key verification failed: {str(e)}")
return JSONResponse({"status": "invalid", "error": str(e)})

View File

@@ -1,141 +0,0 @@
from fastapi import HTTPException, APIRouter, Depends
from fastapi.responses import StreamingResponse
from app.core.config import settings
from app.core.logger import get_openai_logger
from app.core.security import SecurityService
from app.schemas.openai_models import ChatRequest, EmbeddingRequest, ImageGenerationRequest
from app.services.chat.retry_handler import RetryHandler
from app.services.embedding_service import EmbeddingService
from app.services.image_create_service import ImageCreateService
from app.services.key_manager import KeyManager, get_key_manager_instance
from app.services.model_service import ModelService
from app.services.openai_chat_service import OpenAIChatService
router = APIRouter()
logger = get_openai_logger()
# 初始化服务
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
model_service = ModelService(settings.MODEL_SEARCH)
embedding_service = EmbeddingService(settings.BASE_URL)
image_create_service = ImageCreateService()
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()
@router.get("/v1/models")
@router.get("/hf/v1/models")
async def list_models(
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager)
):
logger.info("-" * 50 + "list_models" + "-" * 50)
logger.info("Handling models list request")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
try:
return model_service.get_gemini_openai_models(api_key)
except Exception as e:
logger.error(f"Error getting models list: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error while fetching models list") from e
@router.post("/v1/chat/completions")
@router.post("/hf/v1/chat/completions")
@RetryHandler(max_retries=3, 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)
):
# 如果model是imagen3,使用paid_key
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
api_key = await key_manager.get_paid_key()
chat_service = OpenAIChatService(settings.BASE_URL, key_manager)
logger.info("-" * 50 + "chat_completion" + "-" * 50)
logger.info(f"Handling chat completion request for model: {request.model}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
try:
# 如果model是imagen3,使用paid_key
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
response = await chat_service.create_image_chat_completion(request=request)
else:
response = await chat_service.create_chat_completion(request, api_key)
# 处理流式响应
if request.stream:
return StreamingResponse(response, media_type="text/event-stream")
logger.info("Chat completion request successful")
return response
except Exception as e:
logger.error(f"Chat completion failed after retries: {str(e)}")
raise HTTPException(status_code=500, detail="Chat completion failed") from e
@router.post("/v1/images/generations")
@router.post("/hf/v1/images/generations")
async def generate_image(
request: ImageGenerationRequest,
_=Depends(security_service.verify_authorization),
):
logger.info("-" * 50 + "generate_image" + "-" * 50)
logger.info(f"Handling image generation request for prompt: {request.prompt}")
try:
response = image_create_service.generate_images(request)
logger.info("Image generation request successful")
return response
except Exception as e:
logger.error(f"Image generation request failed: {str(e)}")
raise HTTPException(status_code=500, detail="Image generation request failed") from e
@router.post("/v1/embeddings")
@router.post("/hf/v1/embeddings")
async def embedding(
request: EmbeddingRequest,
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager)
):
logger.info("-" * 50 + "embedding" + "-" * 50)
logger.info(f"Handling embedding request for model: {request.model}")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
try:
response = await embedding_service.create_embedding(
input_text=request.input, model=request.model, api_key=api_key
)
logger.info("Embedding request successful")
return response
except Exception as e:
logger.error(f"Embedding request failed: {str(e)}")
raise HTTPException(status_code=500, detail="Embedding request failed") from e
@router.get("/v1/keys/list")
@router.get("/hf/v1/keys/list")
async def get_keys_list(
_=Depends(security_service.verify_auth_token),
key_manager: KeyManager = Depends(get_key_manager)
):
"""获取有效和无效的API key列表"""
logger.info("-" * 50 + "get_keys_list" + "-" * 50)
logger.info("Handling keys list request")
try:
keys_status = await key_manager.get_keys_by_status()
return {
"status": "success",
"data": {
"valid_keys": keys_status["valid_keys"],
"invalid_keys": 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

336
app/config/config.py Normal file
View File

@@ -0,0 +1,336 @@
"""
应用程序配置模块
"""
import datetime
import json
from typing import List, Any, Dict, Type
from pydantic import ValidationError
from pydantic_settings import BaseSettings
from sqlalchemy import insert, update, select
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_SAFETY_SETTINGS, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES
from app.log.logger import Logger
class Settings(BaseSettings):
# 数据库配置
MYSQL_HOST: str
MYSQL_PORT: int
MYSQL_USER: str
MYSQL_PASSWORD: str
MYSQL_DATABASE: str
# API相关配置
API_KEYS: List[str]
ALLOWED_TOKENS: List[str]
BASE_URL: str = f"https://generativelanguage.googleapis.com/{API_VERSION}"
AUTH_TOKEN: str = ""
MAX_FAILURES: int = 3
TEST_MODEL: str = DEFAULT_MODEL
TIME_OUT: int = DEFAULT_TIMEOUT
MAX_RETRIES: int = MAX_RETRIES
PROXIES: List[str] = [] # 新增:代理服务器列表
# 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
FILTERED_MODELS: List[str] = DEFAULT_FILTER_MODELS
TOOLS_CODE_EXECUTION_ENABLED: bool = False
SHOW_SEARCH_LINK: bool = True
SHOW_THINKING_PROCESS: bool = True
THINKING_MODELS: List[str] = [] # 新增:用于思考过程的模型列表
THINKING_BUDGET_MAP: Dict[str, float] = {} # 新增:模型对应的预算映射
# 图像生成相关配置
PAID_KEY: str = ""
CREATE_IMAGE_MODEL: str = DEFAULT_CREATE_IMAGE_MODEL
UPLOAD_PROVIDER: str = "smms"
SMMS_SECRET_TOKEN: str = ""
PICGO_API_KEY: str = ""
CLOUDFLARE_IMGBED_URL: str = ""
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
# 流式输出优化器配置
STREAM_OPTIMIZER_ENABLED: bool = False
STREAM_MIN_DELAY: float = DEFAULT_STREAM_MIN_DELAY
STREAM_MAX_DELAY: float = DEFAULT_STREAM_MAX_DELAY
STREAM_SHORT_TEXT_THRESHOLD: int = DEFAULT_STREAM_SHORT_TEXT_THRESHOLD
STREAM_LONG_TEXT_THRESHOLD: int = DEFAULT_STREAM_LONG_TEXT_THRESHOLD
STREAM_CHUNK_SIZE: int = DEFAULT_STREAM_CHUNK_SIZE
# 调度器配置
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
TIMEZONE: str = "Asia/Shanghai" # 默认时区
# github
GITHUB_REPO_OWNER: str = "snailyp"
GITHUB_REPO_NAME: str = "gemini-balance"
# 日志配置
LOG_LEVEL: str = "INFO" # 默认日志级别
SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS # 新增:安全设置
def __init__(self, **kwargs):
super().__init__(**kwargs)
# 设置默认AUTH_TOKEN如果未提供
if not self.AUTH_TOKEN and self.ALLOWED_TOKENS:
self.AUTH_TOKEN = self.ALLOWED_TOKENS[0]
# 创建全局配置实例
settings = Settings()
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
"""尝试将数据库字符串值解析为目标 Python 类型"""
from app.log.logger import get_config_logger # 函数内导入
logger = get_config_logger() # 函数内初始化
try:
# 处理 List[str]
if target_type == List[str]:
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
return [str(item) for item in parsed]
except json.JSONDecodeError:
return [item.strip() for item in db_value.split(',') if item.strip()]
logger.warning(f"Could not parse '{db_value}' as List[str] for key '{key}', falling back to comma split or empty list.")
return [item.strip() for item in db_value.split(',') if item.strip()]
# 处理 Dict[str, float]
elif target_type == Dict[str, float]:
parsed_dict = {}
try:
# First attempt: standard JSON parsing
parsed = json.loads(db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
else:
logger.warning(f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}")
except (json.JSONDecodeError, ValueError, TypeError) as e1:
# Second attempt: try replacing single quotes if JSONDecodeError occurred
if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
logger.warning(f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}")
try:
corrected_db_value = db_value.replace("'", '"')
parsed = json.loads(corrected_db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
else:
logger.warning(f"Parsed DB value (after quote replacement) for key '{key}' is not a dictionary type. Value: {corrected_db_value}")
except (json.JSONDecodeError, ValueError, TypeError) as e2:
logger.error(f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict.")
else:
# Log other errors (ValueError, TypeError) or JSON errors without single quotes
logger.error(f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict.")
return parsed_dict # Return the parsed dict or an empty one if all attempts fail
# 处理 List[Dict[str, str]]
elif target_type == List[Dict[str, str]]:
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
# 验证列表中的每个元素是否为字典,并且键和值都是字符串
valid = all(
isinstance(item, dict) and
all(isinstance(k, str) for k in item.keys()) and
all(isinstance(v, str) for v in item.values())
for item in parsed
)
if valid:
return parsed
else:
logger.warning(f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}")
return [] # 或者返回默认值?这里返回空列表
else:
logger.warning(f"Parsed DB value for key '{key}' is not a list type. Value: {db_value}")
return []
except json.JSONDecodeError:
logger.error(f"Could not parse '{db_value}' as JSON for List[Dict[str, str]] for key '{key}'. Returning empty list.")
return []
except Exception as e:
logger.error(f"Error parsing List[Dict[str, str]] for key '{key}': {e}. Value: {db_value}. Returning empty list.")
return []
# 处理 bool
elif target_type == bool:
return db_value.lower() in ('true', '1', 'yes', 'on')
# 处理 int
elif target_type == int:
return int(db_value)
# 处理 float
elif target_type == float:
return float(db_value)
# 默认为 str 或其他 pydantic 能直接处理的类型
else:
return db_value
except (ValueError, TypeError, json.JSONDecodeError) as e:
logger.warning(f"Failed to parse db_value '{db_value}' for key '{key}' as type {target_type}: {e}. Using original string value.")
return db_value # 解析失败则返回原始字符串
async def sync_initial_settings():
"""
应用启动时同步配置:
1. 从数据库加载设置。
2. 将数据库设置合并到内存 settings (数据库优先)。
3. 将最终的内存 settings 同步回数据库。
"""
from app.log.logger import get_config_logger # 函数内导入
logger = get_config_logger() # 函数内初始化
# 延迟导入以避免循环依赖和确保数据库连接已初始化
from app.database.connection import database
from app.database.models import Settings as SettingsModel
global settings
logger.info("Starting initial settings synchronization...")
if not database.is_connected:
try:
await database.connect()
logger.info("Database connection established for initial sync.")
except Exception as e:
logger.error(f"Failed to connect to database for initial settings sync: {e}. Skipping sync.")
return
try:
# 1. 从数据库加载设置
db_settings_raw: List[Dict[str, Any]] = []
try:
query = select(SettingsModel.key, SettingsModel.value)
results = await database.fetch_all(query)
db_settings_raw = [{"key": row["key"], "value": row["value"]} for row in results]
logger.info(f"Fetched {len(db_settings_raw)} settings from database.")
except Exception as e:
logger.error(f"Failed to fetch settings from database: {e}. Proceeding with environment/dotenv settings.")
# 即使数据库读取失败,也要继续执行,确保基于 env/dotenv 的配置能同步到数据库
db_settings_map: Dict[str, str] = {s['key']: s['value'] for s in db_settings_raw}
# 2. 将数据库设置合并到内存 settings (数据库优先)
updated_in_memory = False
for key, db_value in db_settings_map.items():
if hasattr(settings, key):
target_type = Settings.__annotations__.get(key)
if target_type:
try:
parsed_db_value = _parse_db_value(key, db_value, target_type)
memory_value = getattr(settings, key)
# 比较解析后的值和内存中的值
# 注意:对于列表等复杂类型,直接比较可能不够健壮,但这里简化处理
if parsed_db_value != memory_value:
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
type_match = False
if target_type == List[str] and isinstance(parsed_db_value, list):
type_match = True
elif target_type == Dict[str, float] and isinstance(parsed_db_value, dict):
type_match = True
elif target_type not in (List[str], Dict[str, float]) and isinstance(parsed_db_value, target_type):
type_match = True
if type_match:
setattr(settings, key, parsed_db_value)
logger.debug(f"Updated setting '{key}' in memory from database value ({target_type}).")
updated_in_memory = True
else:
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update.")
except Exception as e:
logger.error(f"Error processing database setting for key '{key}': {e}")
else:
logger.warning(f"Database setting '{key}' not found in Settings model definition. Ignoring.")
# 如果内存中有更新,重新验证 Pydantic 模型(可选但推荐)
if updated_in_memory:
try:
# 重新加载以确保类型转换和验证
settings = Settings(**settings.model_dump())
logger.info("Settings object re-validated after merging database values.")
except ValidationError as e:
logger.error(f"Validation error after merging database settings: {e}. Settings might be inconsistent.")
# 3. 将最终的内存 settings 同步回数据库
final_memory_settings = settings.model_dump()
settings_to_update: List[Dict[str, Any]] = []
settings_to_insert: List[Dict[str, Any]] = []
now = datetime.datetime.now(datetime.timezone.utc)
existing_db_keys = set(db_settings_map.keys())
for key, value in final_memory_settings.items():
# 序列化值为字符串或 JSON 字符串
if isinstance(value, (list, dict)): # 处理列表和字典
db_value = json.dumps(value, ensure_ascii=False) # 使用 ensure_ascii=False 以支持非 ASCII 字符
elif isinstance(value, bool):
db_value = str(value).lower()
elif value is None: # 处理 None 值
db_value = "" # 或者根据需要设为 NULL 或其他标记
else:
db_value = str(value)
data = {
'key': key,
'value': db_value,
'description': f"{key} configuration setting", # 默认描述
'updated_at': now
}
if key in existing_db_keys:
# 仅当值与数据库中的不同时才更新
if db_settings_map[key] != db_value:
settings_to_update.append(data)
else:
# 如果键不在数据库中,则插入
data['created_at'] = now
settings_to_insert.append(data)
# 在事务中执行批量插入和更新
if settings_to_insert or settings_to_update:
try:
async with database.transaction():
if settings_to_insert:
# 获取现有描述以避免覆盖
query_existing = select(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:
item['description'] = existing_desc.get(item['key'], item['description'])
query_insert = insert(SettingsModel).values(settings_to_insert)
await database.execute(query=query_insert)
logger.info(f"Synced (inserted) {len(settings_to_insert)} settings to database.")
if settings_to_update:
# 获取现有描述以避免覆盖
query_existing = select(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:
setting_data['description'] = existing_desc.get(setting_data['key'], setting_data['description'])
query_update = (
update(SettingsModel)
.where(SettingsModel.key == setting_data['key'])
.values(
value=setting_data['value'],
description=setting_data['description'],
updated_at=setting_data['updated_at']
)
)
await database.execute(query=query_update)
logger.info(f"Synced (updated) {len(settings_to_update)} settings to database.")
except Exception as e:
logger.error(f"Failed to sync settings to database during startup: {str(e)}")
else:
logger.info("No setting changes detected between memory and database during initial sync.")
# 刷新日志等级
Logger.update_log_levels(final_memory_settings.get("LOG_LEVEL"))
except Exception as e:
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
finally:
if database.is_connected:
try:
pass
except Exception as e:
logger.error(f"Error disconnecting database after initial sync: {e}")
logger.info("Initial settings synchronization finished.")

165
app/core/application.py Normal file
View File

@@ -0,0 +1,165 @@
from contextlib import asynccontextmanager
from pathlib import Path # Add pathlib import
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config.config import settings, sync_initial_settings
from app.log.logger import get_application_logger
from app.middleware.middleware import setup_middlewares
from app.exception.exceptions import setup_exception_handlers
from app.router.routes import setup_routers
from app.service.key.key_manager import get_key_manager_instance
from app.database.connection import connect_to_db, disconnect_from_db
from app.utils.helpers import get_current_version # Import from helpers
from app.database.initialization import initialize_database
from app.scheduler.key_checker import start_scheduler, stop_scheduler
from app.service.update.update_service import check_for_updates
logger = get_application_logger()
# Define project paths using pathlib
# Assuming this file is at app/core/application.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py
STATIC_DIR = PROJECT_ROOT / "app" / "static"
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
# Removed _get_current_version function definition, moved to helpers.py
# 初始化模板引擎,并添加全局变量
templates = Jinja2Templates(directory="app/templates")
# 定义一个函数来更新模板全局变量
def update_template_globals(app: FastAPI, update_info: dict):
# Jinja2Templates 实例没有直接更新全局变量的方法
# 我们需要在请求上下文中传递这些变量,或者修改 Jinja 环境
# 更简单的方法是将其存储在 app.state 中,并在渲染时传递
app.state.update_info = update_info
logger.info(f"Update info stored in app.state: {update_info}")
# --- Helper functions for lifespan ---
async def _setup_database_and_config(app_settings):
"""Initializes database, syncs settings, and initializes KeyManager."""
initialize_database()
logger.info("Database initialized successfully")
await connect_to_db()
await sync_initial_settings()
# Initialize KeyManager using potentially updated settings
await get_key_manager_instance(app_settings.API_KEYS)
logger.info("Database, config sync, and KeyManager initialized successfully")
async def _shutdown_database():
"""Disconnects from the database."""
await disconnect_from_db()
def _start_scheduler():
"""Starts the background scheduler."""
try:
start_scheduler()
logger.info("Scheduler started successfully.")
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
def _stop_scheduler():
"""Stops the background scheduler."""
stop_scheduler()
async def _perform_update_check(app: FastAPI):
"""Checks for updates and stores the info in app.state."""
update_available, latest_version, error_message = await check_for_updates()
current_version = get_current_version() # Use imported function
update_info = {
"update_available": update_available,
"latest_version": latest_version,
"error_message": error_message,
"current_version": current_version
}
# Ensure app.state exists and store update info
if not hasattr(app, "state"):
from starlette.datastructures import State
app.state = State()
app.state.update_info = update_info
logger.info(f"Update check completed. Info: {update_info}")
# --- Application Lifespan ---
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Manages the application startup and shutdown events.
Args:
app: FastAPI应用实例
"""
# Startup events
logger.info("Application starting up...")
try:
# Setup database, config, and KeyManager
await _setup_database_and_config(settings) # Pass settings object
# Perform update check after core components are ready
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
# Start the scheduler
_start_scheduler()
except Exception as e:
logger.critical(f"Critical error during application startup: {str(e)}", exc_info=True)
# Depending on the severity, you might want to prevent the app from fully starting
# For now, we log critically and let it yield, potentially in a broken state.
# Consider adding more robust error handling here if startup failures should halt the app.
yield # Application runs
# Shutdown events
logger.info("Application shutting down...")
_stop_scheduler()
await _shutdown_database()
def create_app() -> FastAPI:
"""
创建并配置FastAPI应用程序实例
Returns:
FastAPI: 配置好的FastAPI应用程序实例
"""
# Removed: initialize_app() call
# 创建FastAPI应用
# Read version from file for consistency
current_version = get_current_version() # Use imported function
app = FastAPI(
title="Gemini Balance API",
description="Gemini API代理服务支持负载均衡和密钥管理",
version=current_version,
lifespan=lifespan
)
# Initialize app.state early to ensure it exists before lifespan potentially uses it
if not hasattr(app, "state"):
from starlette.datastructures import State
app.state = State()
# Set a default/initial state for update_info
app.state.update_info = {
"update_available": False,
"latest_version": None,
"error_message": "Initializing...",
"current_version": current_version # Use version read earlier
}
# 配置静态文件
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# 配置中间件
setup_middlewares(app)
# 配置异常处理器
setup_exception_handlers(app)
# 配置路由
setup_routers(app)
return app

View File

@@ -1,30 +0,0 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
API_KEYS: List[str]
ALLOWED_TOKENS: List[str]
BASE_URL: str = "https://generativelanguage.googleapis.com/v1beta"
MODEL_SEARCH: List[str] = ["gemini-2.0-flash-exp"]
TOOLS_CODE_EXECUTION_ENABLED: bool = False
SHOW_SEARCH_LINK: bool = True
SHOW_THINKING_PROCESS: bool = True
AUTH_TOKEN: str = ""
MAX_FAILURES: int = 3
PAID_KEY: str = ""
CREATE_IMAGE_MODEL: str = "imagen-3.0-generate-002"
UPLOAD_PROVIDER: str = "smms"
SMMS_SECRET_TOKEN: str = ""
TEST_MODEL: str = "gemini-1.5-flash"
def __init__(self):
super().__init__()
if not self.AUTH_TOKEN:
self.AUTH_TOKEN = self.ALLOWED_TOKENS[0] if self.ALLOWED_TOKENS else ""
class Config:
env_file = ".env"
settings = Settings()

79
app/core/constants.py Normal file
View File

@@ -0,0 +1,79 @@
"""
常量定义模块
"""
# API相关常量
API_VERSION = "v1beta"
DEFAULT_TIMEOUT = 300 # 秒
MAX_RETRIES = 3 # 最大重试次数
# 模型相关常量
SUPPORTED_ROLES = ["user", "model", "system"]
DEFAULT_MODEL = "gemini-1.5-flash"
DEFAULT_TEMPERATURE = 0.7
DEFAULT_MAX_TOKENS = 8192
DEFAULT_TOP_P = 0.9
DEFAULT_TOP_K = 40
DEFAULT_FILTER_MODELS = [
"gemini-1.0-pro-vision-latest",
"gemini-pro-vision",
"chat-bison-001",
"text-bison-001",
"embedding-gecko-001"
]
DEFAULT_CREATE_IMAGE_MODEL = "imagen-3.0-generate-002"
# 图像生成相关常量
VALID_IMAGE_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"]
# 上传提供商
UPLOAD_PROVIDERS = ["smms", "picgo", "cloudflare_imgbed"]
DEFAULT_UPLOAD_PROVIDER = "smms"
# 流式输出相关常量
DEFAULT_STREAM_MIN_DELAY = 0.016
DEFAULT_STREAM_MAX_DELAY = 0.024
DEFAULT_STREAM_SHORT_TEXT_THRESHOLD = 10
DEFAULT_STREAM_LONG_TEXT_THRESHOLD = 50
DEFAULT_STREAM_CHUNK_SIZE = 5
# 正则表达式模式
IMAGE_URL_PATTERN = r'!\[(.*?)\]\((.*?)\)'
DATA_URL_PATTERN = r'data:([^;]+);base64,(.+)'
# Audio/Video Settings
SUPPORTED_AUDIO_FORMATS = ["wav", "mp3", "flac", "ogg"]
SUPPORTED_VIDEO_FORMATS = ["mp4", "mov", "avi", "webm"]
MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024 # Example: 50MB limit for Base64 payload
MAX_VIDEO_SIZE_BYTES = 200 * 1024 * 1024 # Example: 200MB limit
# Optional: Define MIME type mappings if needed, or handle directly in converter
AUDIO_FORMAT_TO_MIMETYPE = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"flac": "audio/flac",
"ogg": "audio/ogg",
}
VIDEO_FORMAT_TO_MIMETYPE = {
"mp4": "video/mp4",
"mov": "video/quicktime",
"avi": "video/x-msvideo",
"webm": "video/webm",
}
GEMINI_2_FLASH_EXP_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
]
DEFAULT_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
]

View File

@@ -1,135 +0,0 @@
import logging
import sys
from typing import Dict, Optional
import platform
# ANSI转义序列颜色代码
COLORS = {
'DEBUG': '\033[34m', # 蓝色
'INFO': '\033[32m', # 绿色
'WARNING': '\033[33m', # 黄色
'ERROR': '\033[31m', # 红色
'CRITICAL': '\033[1;31m' # 红色加粗
}
# Windows系统启用ANSI支持
if platform.system() == 'Windows':
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
class ColoredFormatter(logging.Formatter):
"""
自定义的日志格式化器,添加颜色支持
"""
def format(self, record):
# 获取对应级别的颜色代码
color = COLORS.get(record.levelname, '')
# 添加颜色代码和重置代码
record.levelname = f"{color}{record.levelname}\033[0m"
return super().format(record)
# 日志格式
FORMATTER = ColoredFormatter(
"%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s"
)
# 日志级别映射
LOG_LEVELS = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
class Logger:
def __init__(self):
pass
_loggers: Dict[str, logging.Logger] = {}
@staticmethod
def setup_logger(
name: str,
level: str = "debug",
) -> logging.Logger:
"""
设置并获取logger
:param name: logger名称
:param level: 日志级别
:return: logger实例
"""
if name in Logger._loggers:
return Logger._loggers[name]
logger = logging.getLogger(name)
logger.setLevel(LOG_LEVELS.get(level.lower(), logging.INFO))
logger.propagate = False
# 添加控制台输出
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(FORMATTER)
logger.addHandler(console_handler)
Logger._loggers[name] = logger
return logger
@staticmethod
def get_logger(name: str) -> Optional[logging.Logger]:
"""
获取已存在的logger
:param name: logger名称
:return: logger实例或None
"""
return Logger._loggers.get(name)
# 预定义的loggers
def get_openai_logger():
return Logger.setup_logger("openai")
def get_gemini_logger():
return Logger.setup_logger("gemini")
def get_chat_logger():
return Logger.setup_logger("chat")
def get_model_logger():
return Logger.setup_logger("model")
def get_security_logger():
return Logger.setup_logger("security")
def get_key_manager_logger():
return Logger.setup_logger("key_manager")
def get_main_logger():
return Logger.setup_logger("main")
def get_embeddings_logger():
return Logger.setup_logger("embeddings")
def get_request_logger():
return Logger.setup_logger("request")
def get_retry_logger():
return Logger.setup_logger("retry")
def get_image_create_logger():
return Logger.setup_logger("image_create")

View File

@@ -1,26 +1,27 @@
from fastapi import HTTPException, Header
from typing import Optional
from app.core.logger import get_security_logger
from app.core.config import settings
from fastapi import Header, HTTPException
from app.config.config import settings
from app.log.logger import get_security_logger
logger = get_security_logger()
def verify_auth_token(token: str) -> bool:
return token == settings.AUTH_TOKEN
class SecurityService:
def __init__(self, allowed_tokens: list, auth_token: str):
self.allowed_tokens = allowed_tokens
self.auth_token = auth_token
async def verify_key(self, key: str):
if key not in self.allowed_tokens and key != self.auth_token:
if key not in settings.ALLOWED_TOKENS and key != settings.AUTH_TOKEN:
logger.error("Invalid key")
raise HTTPException(status_code=401, detail="Invalid key")
return key
async def verify_authorization(
self, authorization: Optional[str] = Header(None)
self, authorization: Optional[str] = Header(None)
) -> str:
if not authorization:
logger.error("Missing Authorization header")
@@ -33,31 +34,57 @@ class SecurityService:
)
token = authorization.replace("Bearer ", "")
if token not in self.allowed_tokens and token != self.auth_token:
if token not in settings.ALLOWED_TOKENS and token != settings.AUTH_TOKEN:
logger.error("Invalid token")
raise HTTPException(status_code=401, detail="Invalid token")
return token
async def verify_goog_api_key(self, x_goog_api_key: Optional[str] = Header(None)) -> str:
async def verify_goog_api_key(
self, x_goog_api_key: Optional[str] = Header(None)
) -> str:
"""验证Google API Key"""
if not x_goog_api_key:
logger.error("Missing x-goog-api-key header")
raise HTTPException(status_code=401, detail="Missing x-goog-api-key header")
if x_goog_api_key not in self.allowed_tokens and x_goog_api_key != self.auth_token:
if (
x_goog_api_key not in settings.ALLOWED_TOKENS
and x_goog_api_key != settings.AUTH_TOKEN
):
logger.error("Invalid x-goog-api-key")
raise HTTPException(status_code=401, detail="Invalid x-goog-api-key")
return x_goog_api_key
async def verify_auth_token(self, authorization: Optional[str] = Header(None)) -> str:
async def verify_auth_token(
self, authorization: Optional[str] = Header(None)
) -> str:
if not authorization:
logger.error("Missing auth_token header")
raise HTTPException(status_code=401, detail="Missing auth_token header")
token = authorization.replace("Bearer ", "")
if token != self.auth_token:
if token != settings.AUTH_TOKEN:
logger.error("Invalid auth_token")
raise HTTPException(status_code=401, detail="Invalid auth_token")
return token
async def verify_key_or_goog_api_key(
self, key: Optional[str] = None , x_goog_api_key: Optional[str] = Header(None)
) -> str:
"""验证URL中的key或请求头中的x-goog-api-key"""
# 如果URL中的key有效直接返回
if key in settings.ALLOWED_TOKENS or key == settings.AUTH_TOKEN:
return key
# 否则检查请求头中的x-goog-api-key
if not x_goog_api_key:
logger.error("Invalid key and missing x-goog-api-key header")
raise HTTPException(status_code=401, detail="Invalid key and missing x-goog-api-key header")
if x_goog_api_key not in settings.ALLOWED_TOKENS and x_goog_api_key != settings.AUTH_TOKEN:
logger.error("Invalid key and invalid x-goog-api-key")
raise HTTPException(status_code=401, detail="Invalid key and invalid x-goog-api-key")
return x_goog_api_key

View File

@@ -1,163 +0,0 @@
import requests
from app.schemas.image_models import ImageMetadata, ImageUploader, UploadResponse
from enum import Enum
from typing import Optional, Any
class UploadErrorType(Enum):
"""上传错误类型枚举"""
NETWORK_ERROR = "network_error" # 网络请求错误
AUTH_ERROR = "auth_error" # 认证错误
INVALID_FILE = "invalid_file" # 无效文件
SERVER_ERROR = "server_error" # 服务器错误
PARSE_ERROR = "parse_error" # 响应解析错误
UNKNOWN = "unknown" # 未知错误
class UploadError(Exception):
"""图片上传错误异常类"""
def __init__(
self,
message: str,
error_type: UploadErrorType = UploadErrorType.UNKNOWN,
status_code: Optional[int] = None,
details: Optional[dict] = None,
original_error: Optional[Exception] = None
):
"""
初始化上传错误异常
Args:
message: 错误消息
error_type: 错误类型
status_code: HTTP状态码
details: 详细错误信息
original_error: 原始异常
"""
self.message = message
self.error_type = error_type
self.status_code = status_code
self.details = details or {}
self.original_error = original_error
# 构建完整错误信息
full_message = f"[{error_type.value}] {message}"
if status_code:
full_message = f"{full_message} (Status: {status_code})"
if details:
full_message = f"{full_message} - Details: {details}"
super().__init__(full_message)
@classmethod
def from_response(cls, response: Any, message: Optional[str] = None) -> "UploadError":
"""
从HTTP响应创建错误实例
Args:
response: HTTP响应对象
message: 自定义错误消息
"""
try:
error_data = response.json()
details = error_data.get("data", {})
return cls(
message=message or error_data.get("message", "Unknown error"),
error_type=UploadErrorType.SERVER_ERROR,
status_code=response.status_code,
details=details
)
except Exception:
return cls(
message=message or "Failed to parse error response",
error_type=UploadErrorType.PARSE_ERROR,
status_code=response.status_code
)
class SmMsUploader(ImageUploader):
API_URL = "https://sm.ms/api/v2/upload"
def __init__(self, api_key: str):
self.api_key = api_key
def upload(self, file: bytes, filename: str) -> UploadResponse:
try:
# 准备请求头
headers = {
"Authorization": f"Basic {self.api_key}"
}
# 准备文件数据
files = {
"smfile": (filename, file, "image/png")
}
# 发送请求
response = requests.post(
self.API_URL,
headers=headers,
files=files
)
# 检查响应状态
response.raise_for_status()
# 解析响应
result = response.json()
# 验证上传是否成功
if not result.get("success"):
raise UploadError(result.get("message", "Upload failed"))
# 转换为统一格式
data = result["data"]
image_metadata = ImageMetadata(
width=data["width"],
height=data["height"],
filename=data["filename"],
size=data["size"],
url=data["url"],
delete_url=data["delete"]
)
return UploadResponse(
success=True,
code="success",
message="Upload success",
data=image_metadata
)
except requests.RequestException as e:
# 处理网络请求相关错误
raise UploadError(f"Upload request failed: {str(e)}")
except (KeyError, ValueError) as e:
# 处理响应解析错误
raise UploadError(f"Invalid response format: {str(e)}")
except Exception as e:
# 处理其他未预期的错误
raise UploadError(f"Upload failed: {str(e)}")
class QiniuUploader(ImageUploader):
def __init__(self, access_key: str, secret_key: str):
self.access_key = access_key
self.secret_key = secret_key
def upload(self, file: bytes, filename: str) -> UploadResponse:
# 实现七牛云的具体上传逻辑
pass
class ImageUploaderFactory:
@staticmethod
def create(provider: str, **credentials) -> ImageUploader:
if provider == "smms":
return SmMsUploader(credentials["api_key"])
elif provider == "qiniu":
return QiniuUploader(
credentials["access_key"],
credentials["secret_key"]
)
raise ValueError(f"Unknown provider: {provider}")

3
app/database/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
数据库模块
"""

View File

@@ -0,0 +1,58 @@
"""
数据库连接池模块
"""
from databases import Database
from sqlalchemy import create_engine, MetaData
# from sqlalchemy.orm import sessionmaker # 不再需要
from sqlalchemy.ext.declarative import declarative_base
from app.config.config import settings
from app.log.logger import get_database_logger
logger = get_database_logger()
# 数据库URL
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
# 创建数据库引擎
# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
# 创建元数据对象
metadata = MetaData()
# 创建基类
Base = declarative_base(metadata=metadata)
# 创建数据库连接池,并配置连接池参数
# min_size/max_size: 连接池的最小/最大连接数
# pool_recycle=3600: 连接在池中允许存在的最大秒数(生命周期)。
# 设置为 3600 秒1小时确保在 MySQL 默认的 wait_timeout (通常8小时) 或其他网络超时之前回收连接。
# 如果遇到连接失效问题,可以尝试调低此值,使其小于实际的 wait_timeout 或网络超时时间。
# databases 库会自动处理连接失效后的重连尝试。
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins
# 移除了 SessionLocal 和 get_db 函数
# --- Async connection functions for lifespan/async routes ---
async def connect_to_db():
"""
连接到数据库
"""
try:
await database.connect()
logger.info("Connected to database")
except Exception as e:
logger.error(f"Failed to connect to database: {str(e)}")
raise
async def disconnect_from_db():
"""
断开数据库连接
"""
try:
await database.disconnect()
logger.info("Disconnected from database")
except Exception as e:
logger.error(f"Failed to disconnect from database: {str(e)}")

View File

@@ -0,0 +1,77 @@
"""
数据库初始化模块
"""
from dotenv import dotenv_values
from sqlalchemy import inspect
from sqlalchemy.orm import Session
from app.database.connection import engine, Base
from app.database.models import Settings
from app.log.logger import get_database_logger
logger = get_database_logger()
def create_tables():
"""
创建数据库表
"""
try:
# 创建所有表
Base.metadata.create_all(engine)
logger.info("Database tables created successfully")
except Exception as e:
logger.error(f"Failed to create database tables: {str(e)}")
raise
def import_env_to_settings():
"""
将.env文件中的配置项导入到t_settings表中
"""
try:
# 获取.env文件中的所有配置项
env_values = dotenv_values(".env")
# 获取检查器
inspector = inspect(engine)
# 检查t_settings表是否存在
if "t_settings" in inspector.get_table_names():
# 使用Session进行数据库操作
with Session(engine) as session:
# 获取所有现有的配置项
current_settings = {setting.key: setting for setting in session.query(Settings).all()}
# 遍历所有配置项
for key, value in env_values.items():
# 检查配置项是否已存在
if key not in current_settings:
# 插入配置项
new_setting = Settings(key=key, value=value)
session.add(new_setting)
logger.info(f"Inserted setting: {key}")
# 提交事务
session.commit()
logger.info("Environment variables imported to settings table successfully")
except Exception as e:
logger.error(f"Failed to import environment variables to settings table: {str(e)}")
raise
def initialize_database():
"""
初始化数据库
"""
try:
# 创建表
create_tables()
# 导入环境变量
import_env_to_settings()
except Exception as e:
logger.error(f"Failed to initialize database: {str(e)}")
raise

61
app/database/models.py Normal file
View File

@@ -0,0 +1,61 @@
"""
数据库模型模块
"""
import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean # 添加 Boolean
from app.database.connection import Base
class Settings(Base):
"""
设置表,对应.env中的配置项
"""
__tablename__ = "t_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), nullable=False, unique=True, comment="配置项键名")
value = Column(Text, nullable=True, comment="配置项值")
description = Column(String(255), nullable=True, comment="配置项描述")
created_at = Column(DateTime, default=datetime.datetime.now, comment="创建时间")
updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment="更新时间")
def __repr__(self):
return f"<Settings(key='{self.key}', value='{self.value}')>"
class ErrorLog(Base):
"""
错误日志表
"""
__tablename__ = "t_error_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
gemini_key = Column(String(100), nullable=True, comment="Gemini API密钥")
model_name = Column(String(100), nullable=True, comment="模型名称")
error_type = Column(String(50), nullable=True, comment="错误类型")
error_log = Column(Text, nullable=True, comment="错误日志")
error_code = Column(Integer, nullable=True, comment="错误代码")
request_msg = Column(JSON, nullable=True, comment="请求消息")
request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间")
def __repr__(self):
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
# 新增 RequestLog 模型
class RequestLog(Base):
"""
API 请求日志表
"""
__tablename__ = "t_request_log"
id = Column(Integer, primary_key=True, autoincrement=True)
request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间")
model_name = Column(String(100), nullable=True, comment="模型名称")
api_key = Column(String(100), nullable=True, comment="使用的API密钥") # 考虑安全性,后续可优化
is_success = Column(Boolean, nullable=False, comment="请求是否成功")
status_code = Column(Integer, nullable=True, comment="API响应状态码")
latency_ms = Column(Integer, nullable=True, comment="请求耗时(毫秒)")
def __repr__(self):
return f"<RequestLog(id='{self.id}', key='{self.api_key[:4]}...', success='{self.is_success}')>"

419
app/database/services.py Normal file
View File

@@ -0,0 +1,419 @@
"""
数据库服务模块
"""
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
from sqlalchemy import func, desc, asc, select, insert, update, delete
import json
from app.database.connection import database
from app.database.models import Settings, ErrorLog, RequestLog
from app.log.logger import get_database_logger
logger = get_database_logger()
async def get_all_settings() -> List[Dict[str, Any]]:
"""
获取所有设置
Returns:
List[Dict[str, Any]]: 设置列表
"""
try:
query = select(Settings)
result = await database.fetch_all(query)
return [dict(row) for row in result]
except Exception as e:
logger.error(f"Failed to get all settings: {str(e)}")
raise
async def get_setting(key: str) -> Optional[Dict[str, Any]]:
"""
获取指定键的设置
Args:
key: 设置键名
Returns:
Optional[Dict[str, Any]]: 设置信息如果不存在则返回None
"""
try:
query = select(Settings).where(Settings.key == key)
result = await database.fetch_one(query)
return dict(result) if result else None
except Exception as e:
logger.error(f"Failed to get setting {key}: {str(e)}")
raise
async def update_setting(key: str, value: str, description: Optional[str] = None) -> bool:
"""
更新设置
Args:
key: 设置键名
value: 设置值
description: 设置描述
Returns:
bool: 是否更新成功
"""
try:
# 检查设置是否存在
setting = await get_setting(key)
if setting:
# 更新设置
query = (
update(Settings)
.where(Settings.key == key)
.values(
value=value,
description=description if description else setting["description"],
updated_at=datetime.now() # Use datetime.now()
)
)
await database.execute(query)
logger.info(f"Updated setting: {key}")
return True
else:
# 插入设置
query = (
insert(Settings)
.values(
key=key,
value=value,
description=description,
created_at=datetime.now(), # Use datetime.now()
updated_at=datetime.now() # Use datetime.now()
)
)
await database.execute(query)
logger.info(f"Inserted setting: {key}")
return True
except Exception as e:
logger.error(f"Failed to update setting {key}: {str(e)}")
return False
async def add_error_log(
gemini_key: Optional[str] = None,
model_name: Optional[str] = None,
error_type: Optional[str] = None,
error_log: Optional[str] = None,
error_code: Optional[int] = None,
request_msg: Optional[Union[Dict[str, Any], str]] = None
) -> bool:
"""
添加错误日志
Args:
gemini_key: Gemini API密钥
error_log: 错误日志
error_code: 错误代码 (例如 HTTP 状态码)
request_msg: 请求消息
Returns:
bool: 是否添加成功
"""
try:
# 如果request_msg是字典则转换为JSON字符串
if isinstance(request_msg, dict):
request_msg_json = request_msg
elif isinstance(request_msg, str):
try:
request_msg_json = json.loads(request_msg)
except json.JSONDecodeError:
request_msg_json = {"message": request_msg}
else:
request_msg_json = None
# 插入错误日志
query = (
insert(ErrorLog)
.values(
gemini_key=gemini_key,
error_type=error_type,
error_log=error_log,
model_name=model_name,
error_code=error_code,
request_msg=request_msg_json,
request_time=datetime.now()
)
)
await database.execute(query)
logger.info(f"Added error log for key: {gemini_key}")
return True
except Exception as e:
logger.error(f"Failed to add error log: {str(e)}")
return False
async def get_error_logs(
limit: int = 20,
offset: int = 0,
key_search: Optional[str] = None,
error_search: Optional[str] = None,
error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
sort_by: str = 'id', # 新增排序字段
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
) -> List[Dict[str, Any]]:
"""
获取错误日志,支持搜索、日期过滤和排序
Args:
limit (int): 限制数量
offset (int): 偏移量
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
start_date (Optional[datetime]): 开始日期时间
end_date (Optional[datetime]): 结束日期时间
sort_by (str): 排序字段 (例如 'id', 'request_time')
sort_order (str): 排序顺序 ('asc' or 'desc')
Returns:
List[Dict[str, Any]]: 错误日志列表
"""
try:
query = select(
ErrorLog.id,
ErrorLog.gemini_key,
ErrorLog.model_name,
ErrorLog.error_type,
ErrorLog.error_log,
ErrorLog.error_code,
ErrorLog.request_time
)
# Apply filters
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
query = query.where(
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
(ErrorLog.error_log.ilike(f"%{error_search}%"))
)
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
# or handle as needed (e.g., return no results for invalid code format)
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force no results if the format is invalid:
# query = query.where(False) # This ensures no rows are returned
# 添加排序逻辑
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id
if sort_order.lower() == 'asc':
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply limit and offset
query = query.limit(limit).offset(offset)
result = await database.fetch_all(query)
return [dict(row) for row in result]
except Exception as e:
logger.exception(f"Failed to get error logs with filters: {str(e)}") # Use exception for stack trace
raise
async def get_error_logs_count(
key_search: Optional[str] = None,
error_search: Optional[str] = None,
error_code_search: Optional[str] = None, # Added error code search
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> int:
"""
获取符合条件的错误日志总数
Args:
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
start_date (Optional[datetime]): 开始日期时间
end_date (Optional[datetime]): 结束日期时间
Returns:
int: 日志总数
"""
try:
query = select(func.count()).select_from(ErrorLog)
# Apply the same filters as get_error_logs
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
query = query.where(
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
(ErrorLog.error_log.ilike(f"%{error_search}%"))
)
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force count to 0 if the format is invalid:
# return 0 # Or query = query.where(False) before fetching
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
raise
# 新增函数:获取单条错误日志详情
async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
"""
根据 ID 获取单个错误日志的详细信息
Args:
log_id (int): 错误日志的 ID
Returns:
Optional[Dict[str, Any]]: 包含日志详细信息的字典,如果未找到则返回 None
"""
try:
query = select(ErrorLog).where(ErrorLog.id == log_id)
result = await database.fetch_one(query)
if result:
# 将 request_msg (JSONB) 转换为字符串以便在 API 中返回
log_dict = dict(result)
if 'request_msg' in log_dict and log_dict['request_msg'] is not None:
# 确保即使是 None 或非 JSON 数据也能处理
try:
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
except TypeError:
log_dict['request_msg'] = str(log_dict['request_msg']) # Fallback to string
return log_dict
else:
return None
except Exception as e:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise
# --- 异步删除函数 (使用 databases 库) ---
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
根据提供的 ID 列表批量删除错误日志 (异步)。
Args:
log_ids: 要删除的错误日志 ID 列表。
Returns:
int: 实际删除的日志数量。
"""
if not log_ids:
return 0
try:
# 使用 databases 执行删除
query = delete(ErrorLog).where(ErrorLog.id.in_(log_ids))
# execute 返回受影响的行数,但 databases 库的 execute 不直接返回 rowcount
# 我们需要先查询是否存在,或者依赖数据库约束/触发器(如果适用)
# 或者,我们可以执行删除并假设成功,除非抛出异常
# 为了简单起见,我们执行删除并记录日志,不精确返回删除数量
# 如果需要精确数量,需要先执行 SELECT COUNT(*)
await database.execute(query)
# 注意databases 的 execute 不返回 rowcount所以我们不能直接返回删除的数量
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
return len(log_ids) # 返回尝试删除的数量
except Exception as e:
# 数据库连接或执行错误
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
async def delete_error_log_by_id(log_id: int) -> bool:
"""
根据 ID 删除单个错误日志 (异步)。
Args:
log_id: 要删除的错误日志 ID。
Returns:
bool: 如果成功删除返回 True否则返回 False。
"""
try:
# 先检查是否存在 (可选,但更明确)
check_query = select(ErrorLog.id).where(ErrorLog.id == log_id)
exists = await database.fetch_one(check_query)
if not exists:
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
return False # 或者可以抛出 404 异常,由路由处理
# 执行删除
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
await database.execute(delete_query)
logger.info(f"Successfully deleted error log with ID: {log_id}")
return True
except Exception as e:
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
# --- RequestLog Services (保持异步) ---
# 新增函数:添加请求日志
async def add_request_log(
model_name: Optional[str],
api_key: Optional[str],
is_success: bool,
status_code: Optional[int] = None,
latency_ms: Optional[int] = None,
request_time: Optional[datetime] = None
) -> bool:
"""
添加 API 请求日志
Args:
model_name: 模型名称
api_key: 使用的 API 密钥
is_success: 请求是否成功
status_code: API 响应状态码
latency_ms: 请求耗时(毫秒)
request_time: 请求发生时间 (如果为 None, 则使用当前时间)
Returns:
bool: 是否添加成功
"""
try:
log_time = request_time if request_time else datetime.now()
query = insert(RequestLog).values(
request_time=log_time,
model_name=model_name,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms
)
await database.execute(query)
# logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level
return True
except Exception as e:
logger.error(f"Failed to add request log: {str(e)}")
return False

View File

@@ -0,0 +1,78 @@
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from app.core.constants import DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
class SafetySetting(BaseModel):
category: Optional[
Literal[
"HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_DANGEROUS_CONTENT",
"HARM_CATEGORY_HARASSMENT",
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
"HARM_CATEGORY_CIVIC_INTEGRITY",
]
] = None
threshold: Optional[
Literal[
"HARM_BLOCK_THRESHOLD_UNSPECIFIED",
"BLOCK_LOW_AND_ABOVE",
"BLOCK_MEDIUM_AND_ABOVE",
"BLOCK_ONLY_HIGH",
"BLOCK_NONE",
"OFF",
]
] = None
class GenerationConfig(BaseModel):
stopSequences: Optional[List[str]] = None
responseMimeType: Optional[str] = None
responseSchema: Optional[Dict[str, Any]] = None
candidateCount: Optional[int] = 1
maxOutputTokens: Optional[int] = None
temperature: Optional[float] = DEFAULT_TEMPERATURE
topP: Optional[float] = DEFAULT_TOP_P
topK: Optional[int] = DEFAULT_TOP_K
presencePenalty: Optional[float] = None
frequencyPenalty: Optional[float] = None
responseLogprobs: Optional[bool] = None
logprobs: Optional[int] = None
class SystemInstruction(BaseModel):
role: str = "system"
parts: List[Dict[str, Any]] | Dict[str, Any]
class GeminiContent(BaseModel):
role: str
parts: List[Dict[str, Any]]
class GeminiRequest(BaseModel):
contents: List[GeminiContent] = []
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
safetySettings: Optional[List[SafetySetting]] = Field(
default=None, alias="safety_settings"
)
generationConfig: Optional[GenerationConfig] = Field(
default=None, alias="generation_config"
)
systemInstruction: Optional[SystemInstruction] = Field(
default=None, alias="system_instruction"
)
class Config:
populate_by_name = True
class ResetSelectedKeysRequest(BaseModel):
keys: List[str]
key_type: str
class VerifySelectedKeysRequest(BaseModel):
keys: List[str]

View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel
from typing import Any, Dict, List, Optional, Union
from app.core.constants import DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
class ChatRequest(BaseModel):
messages: List[dict]
model: str = DEFAULT_MODEL
temperature: Optional[float] = DEFAULT_TEMPERATURE
stream: Optional[bool] = False
max_tokens: Optional[int] = None
top_p: Optional[float] = DEFAULT_TOP_P
top_k: Optional[int] = DEFAULT_TOP_K
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):
input: Union[str, List[str]]
model: str = "text-embedding-004"
encoding_format: Optional[str] = "float"
class ImageGenerationRequest(BaseModel):
model: str = "imagen-3.0-generate-002"
prompt: str = ""
n: int = 1
size: Optional[str] = "1024x1024"
quality: Optional[str] = None
style: Optional[str] = None
response_format: Optional[str] = "b64_json"

140
app/exception/exceptions.py Normal file
View File

@@ -0,0 +1,140 @@
"""
异常处理模块,定义应用程序中使用的自定义异常和异常处理器
"""
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.log.logger import get_exceptions_logger
logger = get_exceptions_logger()
class APIError(Exception):
"""API错误基类"""
def __init__(self, status_code: int, detail: str, error_code: str = None):
self.status_code = status_code
self.detail = detail
self.error_code = error_code or "api_error"
super().__init__(self.detail)
class AuthenticationError(APIError):
"""认证错误"""
def __init__(self, detail: str = "Authentication failed"):
super().__init__(
status_code=401, detail=detail, error_code="authentication_error"
)
class AuthorizationError(APIError):
"""授权错误"""
def __init__(self, detail: str = "Not authorized to access this resource"):
super().__init__(
status_code=403, detail=detail, error_code="authorization_error"
)
class ResourceNotFoundError(APIError):
"""资源未找到错误"""
def __init__(self, detail: str = "Resource not found"):
super().__init__(
status_code=404, detail=detail, error_code="resource_not_found"
)
class ModelNotSupportedError(APIError):
"""模型不支持错误"""
def __init__(self, model: str):
super().__init__(
status_code=400,
detail=f"Model {model} is not supported",
error_code="model_not_supported",
)
class APIKeyError(APIError):
"""API密钥错误"""
def __init__(self, detail: str = "Invalid or expired API key"):
super().__init__(status_code=401, detail=detail, error_code="api_key_error")
class ServiceUnavailableError(APIError):
"""服务不可用错误"""
def __init__(self, detail: str = "Service temporarily unavailable"):
super().__init__(
status_code=503, detail=detail, error_code="service_unavailable"
)
def setup_exception_handlers(app: FastAPI) -> None:
"""
设置应用程序的异常处理器
Args:
app: FastAPI应用程序实例
"""
@app.exception_handler(APIError)
async def api_error_handler(request: Request, exc: APIError):
"""处理API错误"""
logger.error(f"API Error: {exc.detail} (Code: {exc.error_code})")
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.error_code, "message": exc.detail}},
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""处理HTTP异常"""
logger.error(f"HTTP Exception: {exc.detail} (Status: {exc.status_code})")
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": "http_error", "message": exc.detail}},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
"""处理请求验证错误"""
error_details = []
for error in exc.errors():
error_details.append(
{"loc": error["loc"], "msg": error["msg"], "type": error["type"]}
)
logger.error(f"Validation Error: {error_details}")
return JSONResponse(
status_code=422,
content={
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": error_details,
}
},
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""处理通用异常"""
logger.exception(f"Unhandled Exception: {str(exc)}")
return JSONResponse(
status_code=500,
content={
"error": {
"code": "internal_server_error",
"message": "An unexpected error occurred",
}
},
)

View 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

View File

@@ -0,0 +1,359 @@
import base64
import json
import re
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
import requests
from app.core.constants import (
AUDIO_FORMAT_TO_MIMETYPE,
DATA_URL_PATTERN,
IMAGE_URL_PATTERN,
MAX_AUDIO_SIZE_BYTES,
MAX_VIDEO_SIZE_BYTES,
SUPPORTED_AUDIO_FORMATS,
SUPPORTED_ROLES,
SUPPORTED_VIDEO_FORMATS,
VIDEO_FORMAT_TO_MIMETYPE,
)
from app.log.logger import get_message_converter_logger
logger = get_message_converter_logger()
class MessageConverter(ABC):
"""消息转换器基类"""
@abstractmethod
def convert(
self, messages: List[Dict[str, Any]]
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
pass
def _get_mime_type_and_data(base64_string):
"""
从 base64 字符串中提取 MIME 类型和数据。
参数:
base64_string (str): 可能包含 MIME 类型信息的 base64 字符串
返回:
tuple: (mime_type, encoded_data)
"""
# 检查字符串是否以 "data:" 格式开始
if base64_string.startswith("data:"):
# 提取 MIME 类型和数据
pattern = DATA_URL_PATTERN
match = re.match(pattern, base64_string)
if match:
mime_type = (
"image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
)
encoded_data = match.group(2)
return mime_type, encoded_data
# 如果不是预期格式,假定它只是数据部分
return None, base64_string
def _convert_image(image_url: str) -> Dict[str, Any]:
if image_url.startswith("data:image"):
mime_type, encoded_data = _get_mime_type_and_data(image_url)
return {"inline_data": {"mime_type": mime_type, "data": encoded_data}}
else:
encoded_data = _convert_image_to_base64(image_url)
return {"inline_data": {"mime_type": "image/png", "data": encoded_data}}
def _convert_image_to_base64(url: str) -> str:
"""
将图片URL转换为base64编码
Args:
url: 图片URL
Returns:
str: base64编码的图片数据
"""
response = requests.get(url)
if response.status_code == 200:
# 将图片内容转换为base64
img_data = base64.b64encode(response.content).decode("utf-8")
return img_data
else:
raise Exception(f"Failed to fetch image: {response.status_code}")
def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
"""
处理可能包含图片URL的文本提取图片并转换为base64
Args:
text: 可能包含图片URL的文本
Returns:
List[Dict[str, Any]]: 包含文本和图片的部分列表
"""
parts = []
img_url_match = re.search(IMAGE_URL_PATTERN, text)
if img_url_match:
# 提取URL
img_url = img_url_match.group(2)
# 将URL对应的图片转换为base64
try:
base64_data = _convert_image_to_base64(img_url)
parts.append(
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
)
except Exception:
# 如果转换失败,回退到文本模式
parts.append({"text": text})
else:
# 没有图片URL作为纯文本处理
parts.append({"text": text})
return parts
class OpenAIMessageConverter(MessageConverter):
"""OpenAI消息格式转换器"""
def _validate_media_data(
self, format: str, data: str, supported_formats: List[str], max_size: int
) -> tuple[Optional[str], Optional[str]]:
"""Validates format and size of Base64 media data."""
if format.lower() not in supported_formats:
logger.error(
f"Unsupported media format: {format}. Supported: {supported_formats}"
)
raise ValueError(f"Unsupported media format: {format}")
try:
# Decode Base64 to check size
# Be careful with memory usage for very large files
# Consider streaming decoding or checking length heuristic first if memory is a concern
decoded_data = base64.b64decode(
data, validate=True
) # Use validate=True for stricter check
if len(decoded_data) > max_size:
logger.error(
f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)."
)
raise ValueError(
f"Media data size exceeds limit of {max_size // 1024 // 1024}MB"
)
# No need to return decoded_data, just the original base64 if valid
return data
except base64.binascii.Error as e:
logger.error(f"Invalid Base64 data provided: {e}")
raise ValueError("Invalid Base64 data")
except Exception as e:
logger.error(f"Error validating media data: {e}")
raise
def convert(
self, messages: List[Dict[str, Any]]
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
converted_messages = []
system_instruction_parts = []
for idx, msg in enumerate(messages):
role = msg.get("role", "")
parts = []
if "content" in msg and isinstance(msg["content"], list):
for content_item in msg["content"]:
if not isinstance(content_item, dict):
# Skip non-dict items if any unexpected format appears
logger.warning(
f"Skipping unexpected content item format: {type(content_item)}"
)
continue
content_type = content_item.get("type")
if content_type == "text" and content_item.get("text"):
parts.append({"text": content_item["text"]})
elif content_type == "image_url" and content_item.get(
"image_url", {}
).get("url"):
try:
parts.append(
_convert_image(content_item["image_url"]["url"])
)
except Exception as e:
logger.error(
f"Failed to convert image URL {content_item['image_url']['url']}: {e}"
)
# Decide how to handle: skip part, add error text, etc.
parts.append(
{
"text": f"[Error processing image: {content_item['image_url']['url']}]"
}
)
# --- Add handling for input_audio ---
elif content_type == "input_audio" and content_item.get(
"input_audio"
):
audio_info = content_item["input_audio"]
audio_data = audio_info.get("data")
audio_format = audio_info.get("format", "").lower()
if not audio_data or not audio_format:
logger.warning(
"Skipping audio part due to missing data or format."
)
continue
try:
# Validate size and format
validated_data = self._validate_media_data(
audio_format,
audio_data,
SUPPORTED_AUDIO_FORMATS,
MAX_AUDIO_SIZE_BYTES,
)
# Get MIME type
mime_type = AUDIO_FORMAT_TO_MIMETYPE.get(audio_format)
if not mime_type:
# Should not happen if format validation passed, but double-check
logger.error(
f"Could not find MIME type for supported format: {audio_format}"
)
raise ValueError(
f"Internal error: MIME type mapping missing for {audio_format}"
)
parts.append(
{
"inline_data": {
"mimeType": mime_type,
"data": validated_data, # Use the validated Base64 data
}
}
)
logger.debug(
f"Successfully added audio part (format: {audio_format})"
)
except ValueError as e:
logger.error(
f"Skipping audio part due to validation error: {e}"
)
parts.append({"text": f"[Error processing audio: {e}]"})
except Exception:
logger.exception("Unexpected error processing audio part.")
parts.append(
{"text": "[Unexpected error processing audio]"}
)
elif content_type == "input_video" and content_item.get(
"input_video"
):
video_info = content_item["input_video"]
video_data = video_info.get("data")
video_format = video_info.get("format", "").lower()
if not video_data or not video_format:
logger.warning(
"Skipping video part due to missing data or format."
)
continue
try:
validated_data = self._validate_media_data(
video_format,
video_data,
SUPPORTED_VIDEO_FORMATS,
MAX_VIDEO_SIZE_BYTES,
)
mime_type = VIDEO_FORMAT_TO_MIMETYPE.get(video_format)
if not mime_type:
raise ValueError(
f"Internal error: MIME type mapping missing for {video_format}"
)
parts.append(
{
"inline_data": {
"mimeType": mime_type,
"data": validated_data,
}
}
)
logger.debug(
f"Successfully added video part (format: {video_format})"
)
except ValueError as e:
logger.error(
f"Skipping video part due to validation error: {e}"
)
parts.append({"text": f"[Error processing video: {e}]"})
except Exception:
logger.exception("Unexpected error processing video part.")
parts.append(
{"text": "[Unexpected error processing video]"}
)
else:
# Log unrecognized but present types
if content_type:
logger.warning(
f"Unsupported content type or missing data in structured content: {content_type}"
)
elif (
"content" in msg and isinstance(msg["content"], str) and msg["content"]
):
parts.extend(_process_text_with_image(msg["content"]))
elif "tool_calls" in msg and isinstance(msg["tool_calls"], list):
# Keep existing tool call processing
for tool_call in msg["tool_calls"]:
function_call = tool_call.get("function", {})
# Sanitize arguments loading
arguments_str = function_call.get("arguments", "{}")
try:
function_call["args"] = json.loads(arguments_str)
except json.JSONDecodeError:
logger.warning(
f"Failed to decode tool call arguments: {arguments_str}"
)
function_call["args"] = {}
if "arguments" in function_call:
if "arguments" in function_call:
del function_call["arguments"]
parts.append({"functionCall": function_call})
if role not in SUPPORTED_ROLES:
if role == "tool":
role = "user"
else:
# 如果是最后一条消息,则认为是用户消息
if idx == len(messages) - 1:
role = "user"
else:
role = "model"
if parts:
if role == "system":
text_only_parts = [p for p in parts if "text" in p]
if len(text_only_parts) != len(parts):
logger.warning(
"Non-text parts found in system message; discarding them."
)
if text_only_parts:
system_instruction_parts.extend(text_only_parts)
else:
converted_messages.append({"role": role, "parts": parts})
system_instruction = (
None
if not system_instruction_parts
else {
"role": "system",
"parts": system_instruction_parts,
}
)
return converted_messages, system_instruction

View File

@@ -1,20 +1,23 @@
# app/services/chat/response_handler.py
import base64
import json
import random
import string
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional
import time
import uuid
from app.core.config import settings
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from app.config.config import settings
from app.utils.uploader import ImageUploaderFactory
class ResponseHandler(ABC):
"""响应处理器基类"""
@abstractmethod
def handle_response(self, response: Dict[str, Any], model: str, stream: bool = False) -> Dict[str, Any]:
def handle_response(
self, response: Dict[str, Any], model: str, stream: bool = False
) -> Dict[str, Any]:
pass
@@ -25,14 +28,20 @@ class GeminiResponseHandler(ResponseHandler):
self.thinking_first = True
self.thinking_status = False
def handle_response(self, response: Dict[str, Any], model: str, stream: bool = False) -> Dict[str, Any]:
def handle_response(
self, response: Dict[str, Any], model: str, stream: bool = False
) -> Dict[str, Any]:
if stream:
return _handle_gemini_stream_response(response, model, stream)
return _handle_gemini_normal_response(response, model, stream)
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
def _handle_openai_stream_response(
response: Dict[str, Any], model: str, finish_reason: str
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=True, gemini_format=False
)
if not text and not tool_calls:
delta = {}
else:
@@ -49,8 +58,12 @@ def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_
}
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
def _handle_openai_normal_response(
response: Dict[str, Any], model: str, finish_reason: str
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=False, gemini_format=False
)
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
@@ -59,7 +72,11 @@ def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
"message": {
"role": "assistant",
"content": text,
"tool_calls": tool_calls,
},
"finish_reason": finish_reason,
}
],
@@ -76,126 +93,75 @@ class OpenAIResponseHandler(ResponseHandler):
self.thinking_status = False
def handle_response(
self,
response: Dict[str, Any],
model: str,
stream: bool = False,
finish_reason: str = None
self,
response: Dict[str, Any],
model: str,
stream: bool = False,
finish_reason: str = None,
) -> Optional[Dict[str, Any]]:
if stream:
return _handle_openai_stream_response(response, model, finish_reason)
return _handle_openai_normal_response(response, model, finish_reason)
def handle_image_chat_response(self, image_str: str, model: str, stream=False, finish_reason="stop"):
def handle_image_chat_response(
self, image_str: str, model: str, stream=False, finish_reason="stop"
):
if stream:
return _handle_openai_stream_image_response(image_str,model,finish_reason)
return _handle_openai_normal_image_response(image_str,model,finish_reason)
def _handle_openai_stream_image_response(image_str: str,model: str,finish_reason: str) -> Dict[str, Any]:
return _handle_openai_stream_image_response(image_str, model, finish_reason)
return _handle_openai_normal_image_response(image_str, model, finish_reason)
def _handle_openai_stream_image_response(
image_str: str, model: str, finish_reason: str
) -> Dict[str, Any]:
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"delta": {"content": image_str} if image_str else {},
"finish_reason": finish_reason
}]
"choices": [
{
"index": 0,
"delta": {"content": image_str} if image_str else {},
"finish_reason": finish_reason,
}
],
}
def _handle_openai_normal_image_response(image_str: str,model: str,finish_reason: str) -> Dict[str, Any]:
def _handle_openai_normal_image_response(
image_str: str, model: str, finish_reason: str
) -> Dict[str, Any]:
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": image_str
},
"finish_reason": finish_reason
}],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": image_str},
"finish_reason": finish_reason,
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
def _extract_result(response: Dict[str, Any], model: str, stream: bool = False, gemini_format: bool = False) -> tuple[str, List[Dict[str, Any]]]:
def _extract_result(
response: Dict[str, Any],
model: str,
stream: bool = False,
gemini_format: bool = False,
) -> tuple[str, List[Dict[str, Any]]]:
text, tool_calls = "", []
if stream:
if response.get("candidates"):
candidate = response["candidates"][0]
content = candidate.get("content", {})
parts = content.get("parts", [])
# if "thinking" in model:
# if settings.SHOW_THINKING_PROCESS:
# if len(parts) == 1:
# if self.thinking_first:
# self.thinking_first = False
# self.thinking_status = True
# text = "> thinking\n\n" + parts[0].get("text")
# else:
# text = parts[0].get("text")
# if len(parts) == 2:
# self.thinking_status = False
# if self.thinking_first:
# self.thinking_first = False
# text = (
# "> thinking\n\n"
# + parts[0].get("text")
# + "\n\n---\n> output\n\n"
# + parts[1].get("text")
# )
# else:
# text = (
# parts[0].get("text")
# + "\n\n---\n> output\n\n"
# + parts[1].get("text")
# )
# else:
# if len(parts) == 1:
# if self.thinking_first:
# self.thinking_first = False
# self.thinking_status = True
# text = ""
# elif self.thinking_status:
# text = ""
# else:
# text = parts[0].get("text")
# if len(parts) == 2:
# self.thinking_status = False
# if self.thinking_first:
# self.thinking_first = False
# text = parts[1].get("text")
# else:
# text = parts[1].get("text")
# else:
# if "text" in parts[0]:
# text = parts[0].get("text")
# elif "executableCode" in parts[0]:
# text = _format_code_block(parts[0]["executableCode"])
# elif "codeExecution" in parts[0]:
# text = _format_code_block(parts[0]["codeExecution"])
# elif "executableCodeResult" in parts[0]:
# text = _format_execution_result(
# parts[0]["executableCodeResult"]
# )
# elif "codeExecutionResult" in parts[0]:
# text = _format_execution_result(
# parts[0]["codeExecutionResult"]
# )
# else:
# text = ""
if not parts:
return "", []
if "text" in parts[0]:
text = parts[0].get("text")
elif "executableCode" in parts[0]:
@@ -203,13 +169,11 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
elif "codeExecution" in parts[0]:
text = _format_code_block(parts[0]["codeExecution"])
elif "executableCodeResult" in parts[0]:
text = _format_execution_result(
parts[0]["executableCodeResult"]
)
text = _format_execution_result(parts[0]["executableCodeResult"])
elif "codeExecutionResult" in parts[0]:
text = _format_execution_result(
parts[0]["codeExecutionResult"]
)
text = _format_execution_result(parts[0]["codeExecutionResult"])
elif "inlineData" in parts[0]:
text = _extract_image_data(parts[0])
else:
text = ""
text = _add_search_link_text(model, candidate, text)
@@ -221,10 +185,10 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
if settings.SHOW_THINKING_PROCESS:
if len(candidate["content"]["parts"]) == 2:
text = (
"> thinking\n\n"
+ candidate["content"]["parts"][0]["text"]
+ "\n\n---\n> output\n\n"
+ candidate["content"]["parts"][1]["text"]
"> thinking\n\n"
+ candidate["content"]["parts"][0]["text"]
+ "\n\n---\n> output\n\n"
+ candidate["content"]["parts"][1]["text"]
)
else:
text = candidate["content"]["parts"][0]["text"]
@@ -235,15 +199,54 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
text = candidate["content"]["parts"][0]["text"]
else:
text = ""
for part in candidate["content"]["parts"]:
text += part.get("text", "")
if "parts" in candidate["content"]:
for part in candidate["content"]["parts"]:
if "text" in part:
text += part["text"]
elif "inlineData" in part:
text += _extract_image_data(part)
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(candidate["content"]["parts"], gemini_format)
tool_calls = _extract_tool_calls(
candidate["content"]["parts"], gemini_format
)
else:
text = "暂无返回"
return text, tool_calls
def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> List[Dict[str, Any]]:
def _extract_image_data(part: dict) -> str:
image_uploader = None
if settings.UPLOAD_PROVIDER == "smms":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER, api_key=settings.SMMS_SECRET_TOKEN
)
elif settings.UPLOAD_PROVIDER == "picgo":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER, api_key=settings.PICGO_API_KEY
)
elif settings.UPLOAD_PROVIDER == "cloudflare_imgbed":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER,
base_url=settings.CLOUDFLARE_IMGBED_URL,
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
)
current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
base64_data = part["inlineData"]["data"]
# 将base64_data转成bytes数组
bytes_data = base64.b64decode(base64_data)
upload_response = image_uploader.upload(bytes_data, filename)
if upload_response.success:
text = f"\n\n![image]({upload_response.data.url})\n\n"
else:
text = ""
return text
def _extract_tool_calls(
parts: List[Dict[str, Any]], gemini_format: bool
) -> List[Dict[str, Any]]:
"""提取工具调用信息"""
if not parts or not isinstance(parts, list):
return []
@@ -279,8 +282,12 @@ def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> Lis
return tool_calls
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
def _handle_gemini_stream_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=stream, gemini_format=True
)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
@@ -289,8 +296,12 @@ def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream:
return response
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
def _handle_gemini_normal_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=stream, gemini_format=True
)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
@@ -308,10 +319,10 @@ def _format_code_block(code_data: dict) -> str:
def _add_search_link_text(model: str, candidate: dict, text: str) -> str:
if (
settings.SHOW_SEARCH_LINK
and model.endswith("-search")
and "groundingMetadata" in candidate
and "groundingChunks" in candidate["groundingMetadata"]
settings.SHOW_SEARCH_LINK
and model.endswith("-search")
and "groundingMetadata" in candidate
and "groundingChunks" in candidate["groundingMetadata"]
):
grounding_chunks = candidate["groundingMetadata"]["groundingChunks"]
text += "\n\n---\n\n"

View File

@@ -0,0 +1,51 @@
from functools import wraps
from typing import Callable, TypeVar
from app.core.constants import MAX_RETRIES
from app.log.logger import get_retry_logger
T = TypeVar("T")
logger = get_retry_logger()
class RetryHandler:
"""重试处理装饰器"""
def __init__(self, max_retries: int = MAX_RETRIES, key_arg: str = "api_key"):
self.max_retries = max_retries
self.key_arg = key_arg
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
async def wrapper(*args, **kwargs) -> T:
last_exception = None
for attempt in range(self.max_retries):
retries = attempt + 1
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(
f"API call failed with error: {str(e)}. Attempt {retries} of {self.max_retries}"
)
# 从函数参数中获取 key_manager
key_manager = kwargs.get("key_manager")
if key_manager:
old_key = kwargs.get(self.key_arg)
new_key = await key_manager.handle_api_failure(old_key, retries)
if new_key:
kwargs[self.key_arg] = new_key
logger.info(f"Switched to new API key: {new_key}")
else:
logger.error(f"No valid API key available after {retries} retries.")
break
logger.error(
f"All retry attempts failed, raising final exception: {str(last_exception)}"
)
raise last_exception
return wrapper

View File

@@ -0,0 +1,143 @@
import asyncio
import math
from typing import Any, AsyncGenerator, Callable, List
from app.config.config import settings
from app.core.constants import (
DEFAULT_STREAM_CHUNK_SIZE,
DEFAULT_STREAM_LONG_TEXT_THRESHOLD,
DEFAULT_STREAM_MAX_DELAY,
DEFAULT_STREAM_MIN_DELAY,
DEFAULT_STREAM_SHORT_TEXT_THRESHOLD,
)
from app.log.logger import get_gemini_logger, get_openai_logger
logger_openai = get_openai_logger()
logger_gemini = get_gemini_logger()
class StreamOptimizer:
"""流式输出优化器
提供流式输出优化功能,包括智能延迟调整和长文本分块输出。
"""
def __init__(
self,
logger=None,
min_delay: float = DEFAULT_STREAM_MIN_DELAY,
max_delay: float = DEFAULT_STREAM_MAX_DELAY,
short_text_threshold: int = DEFAULT_STREAM_SHORT_TEXT_THRESHOLD,
long_text_threshold: int = DEFAULT_STREAM_LONG_TEXT_THRESHOLD,
chunk_size: int = DEFAULT_STREAM_CHUNK_SIZE,
):
"""初始化流式输出优化器
参数:
logger: 日志记录器
min_delay: 最小延迟时间(秒)
max_delay: 最大延迟时间(秒)
short_text_threshold: 短文本阈值(字符数)
long_text_threshold: 长文本阈值(字符数)
chunk_size: 长文本分块大小(字符数)
"""
self.logger = logger
self.min_delay = min_delay
self.max_delay = max_delay
self.short_text_threshold = short_text_threshold
self.long_text_threshold = long_text_threshold
self.chunk_size = chunk_size
def calculate_delay(self, text_length: int) -> float:
"""根据文本长度计算延迟时间
参数:
text_length: 文本长度
返回:
延迟时间(秒)
"""
if text_length <= self.short_text_threshold:
# 短文本使用较大延迟
return self.max_delay
elif text_length >= self.long_text_threshold:
# 长文本使用较小延迟
return self.min_delay
else:
# 中等长度文本使用线性插值计算延迟
# 使用对数函数使延迟变化更平滑
ratio = math.log(text_length / self.short_text_threshold) / math.log(
self.long_text_threshold / self.short_text_threshold
)
return self.max_delay - ratio * (self.max_delay - self.min_delay)
def split_text_into_chunks(self, text: str) -> List[str]:
"""将文本分割成小块
参数:
text: 要分割的文本
返回:
文本块列表
"""
return [
text[i : i + self.chunk_size] for i in range(0, len(text), self.chunk_size)
]
async def optimize_stream_output(
self,
text: str,
create_response_chunk: Callable[[str], Any],
format_chunk: Callable[[Any], str],
) -> AsyncGenerator[str, None]:
"""优化流式输出
参数:
text: 要输出的文本
create_response_chunk: 创建响应块的函数,接收文本,返回响应块
format_chunk: 格式化响应块的函数,接收响应块,返回格式化后的字符串
返回:
异步生成器,生成格式化后的响应块
"""
if not text:
return
# 计算智能延迟时间
delay = self.calculate_delay(len(text))
# 根据文本长度决定输出方式
if len(text) >= self.long_text_threshold:
# 长文本:分块输出
chunks = self.split_text_into_chunks(text)
for chunk_text in chunks:
chunk_response = create_response_chunk(chunk_text)
yield format_chunk(chunk_response)
await asyncio.sleep(delay)
else:
# 短文本:逐字符输出
for char in text:
char_chunk = create_response_chunk(char)
yield format_chunk(char_chunk)
await asyncio.sleep(delay)
# 创建默认的优化器实例,可以直接导入使用
openai_optimizer = StreamOptimizer(
logger=logger_openai,
min_delay=settings.STREAM_MIN_DELAY,
max_delay=settings.STREAM_MAX_DELAY,
short_text_threshold=settings.STREAM_SHORT_TEXT_THRESHOLD,
long_text_threshold=settings.STREAM_LONG_TEXT_THRESHOLD,
chunk_size=settings.STREAM_CHUNK_SIZE,
)
gemini_optimizer = StreamOptimizer(
logger=logger_gemini,
min_delay=settings.STREAM_MIN_DELAY,
max_delay=settings.STREAM_MAX_DELAY,
short_text_threshold=settings.STREAM_SHORT_TEXT_THRESHOLD,
long_text_threshold=settings.STREAM_LONG_TEXT_THRESHOLD,
chunk_size=settings.STREAM_CHUNK_SIZE,
)

220
app/log/logger.py Normal file
View File

@@ -0,0 +1,220 @@
import logging
import platform
import sys
from typing import Dict, Optional
# ANSI转义序列颜色代码
COLORS = {
"DEBUG": "\033[34m", # 蓝色
"INFO": "\033[32m", # 绿色
"WARNING": "\033[33m", # 黄色
"ERROR": "\033[31m", # 红色
"CRITICAL": "\033[1;31m", # 红色加粗
}
# Windows系统启用ANSI支持
if platform.system() == "Windows":
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
class ColoredFormatter(logging.Formatter):
"""
自定义的日志格式化器,添加颜色支持
"""
def format(self, record):
# 获取对应级别的颜色代码
color = COLORS.get(record.levelname, "")
# 添加颜色代码和重置代码
record.levelname = f"{color}{record.levelname}\033[0m"
# 创建包含文件名和行号的固定宽度字符串
record.fileloc = f"[{record.filename}:{record.lineno}]"
return super().format(record)
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
FORMATTER = ColoredFormatter(
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
)
# 日志级别映射
LOG_LEVELS = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
class Logger:
def __init__(self):
pass
_loggers: Dict[str, logging.Logger] = {}
@staticmethod
def setup_logger(name: str) -> logging.Logger:
"""
设置并获取logger
:param name: logger名称
:return: logger实例
"""
# 导入 settings 对象
from app.config.config import settings
# 从全局配置获取日志级别
log_level_str = settings.LOG_LEVEL.lower()
level = LOG_LEVELS.get(log_level_str, logging.INFO)
if name in Logger._loggers:
# 如果 logger 已存在,检查并更新其级别(如果需要)
existing_logger = Logger._loggers[name]
if existing_logger.level != level:
existing_logger.setLevel(level)
return existing_logger
logger = logging.getLogger(name)
logger.setLevel(level)
logger.propagate = False
# 添加控制台输出
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(FORMATTER)
logger.addHandler(console_handler)
Logger._loggers[name] = logger
return logger
@staticmethod
def get_logger(name: str) -> Optional[logging.Logger]:
"""
获取已存在的logger
:param name: logger名称
:return: logger实例或None
"""
return Logger._loggers.get(name)
@staticmethod
def update_log_levels(log_level: str):
"""
根据当前的全局配置更新所有已创建 logger 的日志级别。
"""
log_level_str = log_level.lower()
new_level = LOG_LEVELS.get(log_level_str, logging.INFO)
updated_count = 0
for logger_name, logger_instance in Logger._loggers.items():
if logger_instance.level != new_level:
logger_instance.setLevel(new_level)
# 可选:记录级别变更日志,但注意避免在日志模块内部产生过多日志
# print(f"Updated log level for logger '{logger_name}' to {log_level_str.upper()}")
updated_count += 1
# 预定义的loggers
def get_openai_logger():
return Logger.setup_logger("openai")
def get_gemini_logger():
return Logger.setup_logger("gemini")
def get_chat_logger():
return Logger.setup_logger("chat")
def get_model_logger():
return Logger.setup_logger("model")
def get_security_logger():
return Logger.setup_logger("security")
def get_key_manager_logger():
return Logger.setup_logger("key_manager")
def get_main_logger():
return Logger.setup_logger("main")
def get_embeddings_logger():
return Logger.setup_logger("embeddings")
def get_request_logger():
return Logger.setup_logger("request")
def get_retry_logger():
return Logger.setup_logger("retry")
def get_image_create_logger():
return Logger.setup_logger("image_create")
def get_exceptions_logger():
return Logger.setup_logger("exceptions")
def get_application_logger():
return Logger.setup_logger("application")
def get_initialization_logger():
return Logger.setup_logger("initialization")
def get_middleware_logger():
return Logger.setup_logger("middleware")
def get_routes_logger():
return Logger.setup_logger("routes")
def get_config_routes_logger():
return Logger.setup_logger("config_routes")
def get_config_logger():
return Logger.setup_logger("config")
def get_database_logger():
return Logger.setup_logger("database")
def get_log_routes_logger():
return Logger.setup_logger("log_routes")
def get_stats_logger():
return Logger.setup_logger("stats")
def get_update_logger():
return Logger.setup_logger("update_service")
def get_scheduler_routes():
return Logger.setup_logger("scheduler_routes")
def get_message_converter_logger():
return Logger.setup_logger("message_converter")
def get_api_client_logger():
return Logger.setup_logger("api_client")
def get_openai_compatible_logger():
return Logger.setup_logger("openai_compatible")

View File

@@ -1,134 +1,11 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from app.core.logger import get_main_logger
from app.core.security import verify_auth_token
from app.services.key_manager import get_key_manager_instance
from app.core.config import settings
from app.api import gemini_routes, openai_routes
import uvicorn
from app.core.application import create_app
from app.log.logger import get_main_logger
# 配置日志
logger = get_main_logger()
app = create_app()
app = FastAPI()
# 配置Jinja2模板
templates = Jinja2Templates(directory="app/templates")
# 配置静态文件
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# 创建 KeyManager 实例
key_manager = None
@app.on_event("startup")
async def startup_event():
global key_manager
logger.info("Application starting up...")
try:
key_manager = await get_key_manager_instance(settings.API_KEYS)
logger.info("KeyManager initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize KeyManager: {str(e)}")
raise
# 添加中间件来处理未经身份验证的请求
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
# 允许 gemini_routes 和 openai_routes 中的端点绕过身份验证
if (request.url.path not in ["/", "/auth"] and
not request.url.path.startswith("/static") and
not request.url.path.startswith("/gemini") and
not request.url.path.startswith("/v1") and
not request.url.path.startswith("/v1beta") and
not request.url.path.startswith("/health") and
not request.url.path.startswith("/hf")):
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 {request.url.path}")
return RedirectResponse(url="/")
logger.debug("Request authenticated successfully")
response = await call_next(request)
return response
# 添加请求日志中间件
# app.add_middleware(RequestLoggingMiddleware)
# 配置CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境建议配置具体的域名
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], # 明确指定允许的HTTP方法
allow_headers=["*"], # 生产环境建议配置具体的请求头
expose_headers=["*"], # 允许前端访问的响应头
max_age=600, # 预检请求缓存时间(秒)
)
# 包含所有路由
app.include_router(openai_routes.router)
app.include_router(gemini_routes.router)
app.include_router(gemini_routes.router_v1beta)
@app.get("/", response_class=HTMLResponse)
async def auth_page(request: Request):
return templates.TemplateResponse("auth.html", {"request": request})
@app.post("/auth")
async def authenticate(request: Request):
try:
form = await request.form()
auth_token = form.get("auth_token")
if not auth_token:
logger.warning("Authentication attempt with empty token")
return RedirectResponse(url="/", status_code=302)
if verify_auth_token(auth_token):
logger.info("Successful authentication")
response = RedirectResponse(url="/keys", status_code=302)
response.set_cookie(key="auth_token", value=auth_token, httponly=True, max_age=3600)
return response
logger.warning("Failed authentication attempt with invalid token")
return RedirectResponse(url="/", status_code=302)
except Exception as e:
logger.error(f"Authentication error: {str(e)}")
return RedirectResponse(url="/", status_code=302)
@app.get("/keys", response_class=HTMLResponse)
async def keys_page(request: Request):
try:
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to keys page")
return RedirectResponse(url="/", status_code=302)
keys_status = await key_manager.get_keys_by_status()
total = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
logger.info(f"Keys status retrieved successfully. Total keys: {total}")
return templates.TemplateResponse("keys_status.html", {
"request": request,
"valid_keys": keys_status["valid_keys"],
"invalid_keys": keys_status["invalid_keys"],
"total": total
})
except Exception as e:
logger.error(f"Error retrieving keys status: {str(e)}")
raise
@app.get("/health")
async def health_check(request: Request):
logger.info("Health check endpoint called")
return {"status": "healthy"}
if __name__ == "__main__":
logger = get_main_logger()
logger.info("Starting application server...")
uvicorn.run(app, host="0.0.0.0", port=8001)

View File

@@ -0,0 +1,75 @@
"""
中间件配置模块,负责设置和配置应用程序的中间件
"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
# from app.middleware.request_logging_middleware import RequestLoggingMiddleware
from app.core.constants import API_VERSION
from app.core.security import verify_auth_token
from app.log.logger import get_middleware_logger
logger = get_middleware_logger()
class AuthMiddleware(BaseHTTPMiddleware):
"""
认证中间件,处理未经身份验证的请求
"""
async def dispatch(self, request: Request, call_next):
# 允许特定路径绕过身份验证
if (
request.url.path not in ["/", "/auth"]
and not request.url.path.startswith("/static")
and not request.url.path.startswith("/gemini")
and not request.url.path.startswith("/v1")
and not request.url.path.startswith(f"/{API_VERSION}")
and not request.url.path.startswith("/health")
and not request.url.path.startswith("/hf")
and not request.url.path.startswith("/openai")
and not request.url.path.startswith("/api/version/check")
):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized access attempt to {request.url.path}")
return RedirectResponse(url="/")
logger.debug("Request authenticated successfully")
response = await call_next(request)
return response
def setup_middlewares(app: FastAPI) -> None:
"""
设置应用程序的中间件
Args:
app: FastAPI应用程序实例
"""
# 添加认证中间件
app.add_middleware(AuthMiddleware)
# 添加请求日志中间件(可选,默认注释掉)
# app.add_middleware(RequestLoggingMiddleware)
# 配置CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境建议配置具体的域名
allow_credentials=True,
allow_methods=[
"GET",
"POST",
"PUT",
"DELETE",
"OPTIONS",
], # 明确指定允许的HTTP方法
allow_headers=["*"], # 生产环境建议配置具体的请求头
expose_headers=["*"], # 允许前端访问的响应头
max_age=600, # 预检请求缓存时间(秒)
)

View File

@@ -1,7 +1,9 @@
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
import json
from app.core.logger import get_request_logger
from app.log.logger import get_request_logger
logger = get_request_logger()
@@ -20,9 +22,11 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
# 尝试格式化JSON
try:
formatted_body = json.loads(body_str)
logger.info(f"Formatted request body:\n{json.dumps(formatted_body, indent=2, ensure_ascii=False)}")
logger.info(
f"Formatted request body:\n{json.dumps(formatted_body, indent=2, ensure_ascii=False)}"
)
except json.JSONDecodeError:
logger.info("Request body is not valid JSON.")
logger.error("Request body is not valid JSON.")
except Exception as e:
logger.error(f"Error reading request body: {str(e)}")

View File

@@ -0,0 +1,53 @@
"""
配置路由模块
"""
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from app.core.security import verify_auth_token
from app.log.logger import get_config_routes_logger, Logger # 导入 Logger 类
from app.service.config.config_service import ConfigService
# 创建路由
router = APIRouter(prefix="/api/config", tags=["config"])
logger = get_config_routes_logger()
@router.get("", response_model=Dict[str, Any])
async def get_config(request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
return await ConfigService.get_config()
@router.put("", response_model=Dict[str, Any])
async def update_config(config_data: Dict[str, Any], request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
try:
result = await ConfigService.update_config(config_data)
# 配置更新成功后,立即更新所有 logger 的级别
Logger.update_log_levels(config_data["LOG_LEVEL"])
logger.info("Log levels updated after configuration change.") # 添加日志记录
return result
except Exception as e:
logger.error(f"Error updating config or log levels: {e}", exc_info=True) # 记录详细错误
raise HTTPException(status_code=400, detail=str(e))
@router.post("/reset", response_model=Dict[str, Any])
async def reset_config(request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
try:
return await ConfigService.reset_config()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -0,0 +1,201 @@
"""
日志路由模块
"""
from typing import List, Optional, Dict
from datetime import datetime
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Request, Query, Path, Body, Response, status
from app.core.security import verify_auth_token
from app.log.logger import get_log_routes_logger
# 假设这些服务函数已更新或添加
from app.database.services import (
get_error_logs,
get_error_logs_count,
get_error_log_details,
delete_error_logs_by_ids, # 新增导入
delete_error_log_by_id # 新增导入
)
# Removed get_db import comment as it's fully removed now
# 创建路由
router = APIRouter(prefix="/api/logs", tags=["logs"])
logger = get_log_routes_logger()
# Define a response model that includes the total count for pagination
# 用于列表响应的模型,假设 get_error_logs 返回包含 error_code 的字典
class ErrorLogListItem(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_code: Optional[int] = None # 列表显示错误码 (应为整数)
model_name: Optional[str] = None
request_time: Optional[datetime] = None
class ErrorLogListResponse(BaseModel):
logs: List[ErrorLogListItem] # 使用定义的模型列表
total: int
@router.get("/errors", response_model=ErrorLogListResponse)
async def get_error_logs_api(
request: Request,
limit: int = Query(10, ge=1, le=1000),
offset: int = Query(0, ge=0),
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
error_code_search: Optional[str] = Query(None, description="Search term for error code"), # Added error code search parameter
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
end_date: Optional[datetime] = Query(None, description="End datetime for filtering"),
sort_by: str = Query('id', description="Field to sort by (e.g., 'id', 'request_time')"), # 新增排序参数
sort_order: str = Query('desc', description="Sort order ('asc' or 'desc')") # 新增排序参数
):
"""
获取错误日志列表 (返回错误码),支持过滤和排序
Args:
request: 请求对象
limit: 限制数量
offset: 偏移量
key_search: 密钥搜索
error_search: 错误搜索 (可能搜索类型或日志内容由DB层决定)
error_code_search: 错误码搜索
start_date: 开始日期
end_date: 结束日期
sort_by: 排序字段
sort_order: 排序顺序
Returns:
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to error logs list")
# API 返回 401 更合适
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# 假设 get_error_logs 现在返回包含 error_code 的字典列表
# 并且可以接受 include_error_code 参数 (如果需要显式指定)
logs_data = await get_error_logs(
limit=limit,
offset=offset,
key_search=key_search,
error_search=error_search, # 数据库查询需要处理这个
error_code_search=error_code_search, # Pass error code search to DB function
start_date=start_date,
end_date=end_date,
sort_by=sort_by, # 传递排序参数
sort_order=sort_order # 传递排序参数
)
# Fetch total count with the same search parameters
total_count = await get_error_logs_count(
key_search=key_search,
error_search=error_search,
error_code_search=error_code_search, # Pass error code search to DB count function
start_date=start_date,
end_date=end_date
)
# 验证并转换数据以匹配 Pydantic 模型
validated_logs = [ErrorLogListItem(**log) for log in logs_data]
return ErrorLogListResponse(logs=validated_logs, total=total_count)
except Exception as e:
logger.exception(f"Failed to get error logs list: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error logs list: {str(e)}")
# 新增:获取错误日志详情的路由
class ErrorLogDetailResponse(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_log: Optional[str] = None # 详情接口返回完整的 error_log
request_msg: Optional[str] = None # 详情接口返回 request_msg
model_name: Optional[str] = None
request_time: Optional[datetime] = None
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=1)):
"""
根据日志 ID 获取错误日志的详细信息 (包括 error_log 和 request_msg)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized access attempt to error log details for ID: {log_id}")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# 假设存在一个函数 get_error_log_details(log_id) 来获取完整信息
log_details = await get_error_log_details(log_id=log_id)
if not log_details:
raise HTTPException(status_code=404, detail="Error log not found")
# 假设 get_error_log_details 返回一个字典或兼容 Pydantic 的对象
return ErrorLogDetailResponse(**log_details)
except HTTPException as http_exc:
# Re-raise HTTPException (like 404)
raise http_exc
except Exception as e:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error log details: {str(e)}")
# 新增:批量删除错误日志
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_logs_bulk_api(
request: Request,
payload: Dict[str, List[int]] = Body(...) # Expects {"ids": [1, 2, 3]}
# Ensure db dependency is fully removed
):
"""
批量删除错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to bulk delete error logs")
raise HTTPException(status_code=401, detail="Not authenticated")
log_ids = payload.get("ids")
if not log_ids:
raise HTTPException(status_code=400, detail="No log IDs provided for deletion.")
try:
# 调用异步服务函数
deleted_count = await delete_error_logs_by_ids(log_ids)
# 注意:异步函数返回的是尝试删除的数量,可能不是精确值
logger.info(f"Attempted bulk deletion for {deleted_count} error logs with IDs: {log_ids}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
except Exception as e:
logger.exception(f"Error bulk deleting error logs with IDs {log_ids}: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error during bulk deletion")
# 新增:删除单个错误日志
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_log_api(
request: Request,
log_id: int = Path(..., ge=1)
# Ensure db dependency is fully removed
):
"""
删除单个错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized access attempt to delete error log ID: {log_id}")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# 调用异步服务函数
success = await delete_error_log_by_id(log_id)
if not success:
# 服务层现在在未找到时返回 False我们在这里转换为 404
raise HTTPException(status_code=404, detail=f"Error log with ID {log_id} not found")
logger.info(f"Successfully deleted error log with ID: {log_id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
except HTTPException as http_exc:
raise http_exc # Re-raise 404 or other HTTP exceptions
except Exception as e:
logger.exception(f"Error deleting error log with ID {log_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error during deletion")

402
app/router/gemini_routes.py Normal file
View File

@@ -0,0 +1,402 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from copy import deepcopy
import asyncio
from app.config.config import settings
from app.log.logger import get_gemini_logger
from app.core.security import SecurityService
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest # 添加导入
from app.service.chat.gemini_chat_service import GeminiChatService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.core.constants import API_VERSION
# 路由设置
router = APIRouter(prefix=f"/gemini/{API_VERSION}")
router_v1beta = APIRouter(prefix=f"/{API_VERSION}")
logger = get_gemini_logger()
# 初始化服务
security_service = SecurityService()
model_service = ModelService()
async def get_key_manager():
"""获取密钥管理器实例"""
return await get_key_manager_instance()
async def get_next_working_key(key_manager: KeyManager = Depends(get_key_manager)):
"""获取下一个可用的API密钥"""
return await key_manager.get_next_working_key()
async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
"""获取Gemini聊天服务实例"""
return GeminiChatService(settings.BASE_URL, key_manager)
@router.get("/models")
@router_v1beta.get("/models")
async def list_models(
_=Depends(security_service.verify_key_or_goog_api_key),
key_manager: KeyManager = Depends(get_key_manager)
):
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
operation_name = "list_gemini_models"
logger.info("-" * 50 + operation_name + "-" * 50)
logger.info("Handling Gemini models list request")
try:
api_key = await key_manager.get_first_valid_key()
if not api_key:
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
logger.info(f"Using API key: {api_key}")
models_data =await model_service.get_gemini_models(api_key)
if not models_data or "models" not in models_data:
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
models_json = deepcopy(models_data) # 操作副本以防修改原始缓存
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
def add_derived_model(base_name, suffix, display_suffix):
model = model_mapping.get(base_name)
if not model:
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
return
item = deepcopy(model)
item["name"] = f"models/{base_name}{suffix}"
display_name = f'{item.get("displayName", base_name)}{display_suffix}'
item["displayName"] = display_name
item["description"] = display_name
models_json["models"].append(item)
# 添加衍生模型
if settings.SEARCH_MODELS:
for name in settings.SEARCH_MODELS:
add_derived_model(name, "-search", " For Search")
if settings.IMAGE_MODELS:
for name in settings.IMAGE_MODELS:
add_derived_model(name, "-image", " For Image")
if settings.THINKING_MODELS:
for name in settings.THINKING_MODELS:
add_derived_model(name, "-non-thinking", " Non Thinking")
logger.info("Gemini models list request successful")
return models_json
except HTTPException as http_exc:
# 重新抛出已知的 HTTP 异常
raise http_exc
except Exception as e:
logger.error(f"Error getting Gemini models list: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error while fetching Gemini models list"
) from e
@router.post("/models/{model_name}:generateContent")
@router_v1beta.post("/models/{model_name}:generateContent")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
async def generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""处理 Gemini 非流式内容生成请求。"""
operation_name = "gemini_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
logger.info(f"Handling Gemini content generation request for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not await model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
response = await chat_service.generate_content(
model=model_name,
request=request,
api_key=api_key
)
return response
@router.post("/models/{model_name}:streamGenerateContent")
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
async def stream_generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""处理 Gemini 流式内容生成请求。"""
operation_name = "gemini_stream_generate_content"
# 流式请求的成功/失败日志在流处理中更复杂,这里仅用上下文管理器处理启动错误
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not await model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
response_stream = chat_service.stream_generate_content(
model=model_name,
request=request,
api_key=api_key
)
# 注意:流本身的错误需要在服务层或流迭代中处理,这里只返回流响应
return StreamingResponse(response_stream, media_type="text/event-stream")
@router.post("/reset-all-fail-counts")
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
"""批量重置Gemini API密钥的失败计数可选择性地仅重置有效或无效密钥"""
logger.info("-" * 50 + "reset_all_gemini_key_fail_counts" + "-" * 50)
logger.info(f"Received reset request with key_type: {key_type}")
try:
# 获取分类后的密钥
keys_by_status = await key_manager.get_keys_by_status()
valid_keys = keys_by_status.get("valid_keys", {})
invalid_keys = keys_by_status.get("invalid_keys", {})
# 根据类型选择要重置的密钥
keys_to_reset = []
if key_type == "valid":
keys_to_reset = list(valid_keys.keys())
logger.info(f"Resetting only valid keys, count: {len(keys_to_reset)}")
elif key_type == "invalid":
keys_to_reset = list(invalid_keys.keys())
logger.info(f"Resetting only invalid keys, count: {len(keys_to_reset)}")
else:
# 重置所有密钥
await key_manager.reset_failure_counts()
return JSONResponse({"success": True, "message": "所有密钥的失败计数已重置"})
# 批量重置指定类型的密钥
for key in keys_to_reset:
await key_manager.reset_key_failure_count(key)
return JSONResponse({
"success": True,
"message": f"{key_type}密钥的失败计数已重置",
"reset_count": len(keys_to_reset)
})
except Exception as e:
logger.error(f"Failed to reset key failure counts: {str(e)}")
return JSONResponse({"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500)
@router.post("/reset-selected-fail-counts")
async def reset_selected_key_fail_counts(
request: ResetSelectedKeysRequest,
key_manager: KeyManager = Depends(get_key_manager)
):
"""批量重置选定Gemini API密钥的失败计数"""
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
keys_to_reset = request.keys
key_type = request.key_type # 获取类型用于日志记录和响应消息
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
if not keys_to_reset:
return JSONResponse({"success": False, "message": "没有提供需要重置的密钥"}, status_code=400)
reset_count = 0
errors = []
try:
for key in keys_to_reset:
try:
result = await key_manager.reset_key_failure_count(key)
if result:
reset_count += 1
else:
# 记录未找到的密钥,但不视为致命错误
logger.warning(f"Key not found during selective reset: {key}")
except Exception as key_error:
# 记录单个密钥重置时的错误
logger.error(f"Error resetting key {key}: {str(key_error)}")
errors.append(f"Key {key}: {str(key_error)}")
if errors:
# 如果有错误,报告部分成功或完全失败
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
# 确定最终状态码和成功标志
final_success = reset_count > 0
status_code = 207 if final_success and errors else 500 # 207 Multi-Status if partially successful, 500 if completely failed
return JSONResponse({
"success": final_success,
"message": error_message,
"reset_count": reset_count
}, status_code=status_code)
# 完全成功的情况
return JSONResponse({
"success": True,
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
"reset_count": reset_count
})
except Exception as e:
# 捕获循环外的意外错误
logger.error(f"Failed to process reset selected key failure counts request: {str(e)}")
return JSONResponse({"success": False, "message": f"批量重置处理失败: {str(e)}"}, status_code=500)
@router.post("/reset-fail-count/{api_key}")
async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(get_key_manager)):
"""重置指定Gemini API密钥的失败计数"""
logger.info("-" * 50 + "reset_gemini_key_fail_count" + "-" * 50)
logger.info(f"Resetting failure count for API key: {api_key}")
try:
result = await key_manager.reset_key_failure_count(api_key)
if result:
return JSONResponse({"success": True, "message": "失败计数已重置"})
return JSONResponse({"success": False, "message": "未找到指定密钥"}, status_code=404)
except Exception as e:
logger.error(f"Failed to reset key failure count: {str(e)}")
return JSONResponse({"success": False, "message": f"重置失败: {str(e)}"}, status_code=500)
@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)):
"""验证Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
logger.info("Verifying API key validity")
try:
# 使用generate_content接口测试key的有效性
gemini_request = GeminiRequest(
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}]
)
]
)
response = await chat_service.generate_content(
settings.TEST_MODEL,
gemini_request,
api_key
)
if response:
return JSONResponse({"status": "valid"})
except Exception as e:
logger.error(f"Key verification failed: {str(e)}")
# 验证出现异常时增加失败计数
async with key_manager.failure_count_lock:
if api_key in key_manager.key_failure_counts:
key_manager.key_failure_counts[api_key] += 1
logger.warning(f"Verification exception for key: {api_key}, incrementing failure count")
return JSONResponse({"status": "invalid", "error": str(e)})
@router.post("/verify-selected-keys")
async def verify_selected_keys(
request: VerifySelectedKeysRequest,
chat_service: GeminiChatService = Depends(get_chat_service),
key_manager: KeyManager = Depends(get_key_manager)
):
"""批量验证选定Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_selected_gemini_keys" + "-" * 50)
keys_to_verify = request.keys
logger.info(f"Received verification request for {len(keys_to_verify)} selected keys.")
if not keys_to_verify:
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
successful_keys = []
failed_keys = {} # 存储失败的 key 和错误信息
async def _verify_single_key(api_key: str):
"""内部函数,用于验证单个密钥并处理异常"""
nonlocal successful_keys, failed_keys # 允许修改外部列表和字典
try:
# 重用单密钥验证逻辑的核心部分
gemini_request = GeminiRequest(
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])]
)
# 注意:这里直接调用 chat_service.generate_content不依赖于 key_manager 获取密钥
await chat_service.generate_content(
settings.TEST_MODEL,
gemini_request,
api_key
)
# 如果上面没有抛出异常,则认为密钥有效
successful_keys.append(api_key)
return api_key, "valid", None
except Exception as e:
error_message = str(e)
logger.warning(f"Key verification failed for {api_key}: {error_message}")
# 验证失败时增加失败计数 (使用与 /verify-key 一致的逻辑)
async with key_manager.failure_count_lock:
if api_key in key_manager.key_failure_counts:
key_manager.key_failure_counts[api_key] += 1
logger.warning(f"Bulk verification exception for key: {api_key}, incrementing failure count")
else:
# 如果密钥不在计数中可能刚添加或从未失败初始化为1
key_manager.key_failure_counts[api_key] = 1
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
failed_keys[api_key] = error_message # 记录失败的 key 和错误信息
return api_key, "invalid", error_message
# 并发执行所有密钥的验证
tasks = [_verify_single_key(key) for key in keys_to_verify]
results = await asyncio.gather(*tasks, return_exceptions=True) # return_exceptions=True 捕获任务本身的异常
# 处理并发执行的结果
for result in results:
if isinstance(result, Exception):
# 捕获 asyncio.gather 可能遇到的异常(例如任务被取消)
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
# 可以选择如何处理这种任务级别的错误,这里我们简单记录
# 也可以将其计入 invalid_count 或单独记录
elif result:
# result 可能是 (key, status, error) 或 Exception
if not isinstance(result, Exception) and result:
key, status, error = result
# 失败信息已在 _verify_single_key 中记录到 failed_keys
elif isinstance(result, Exception):
# 记录任务本身的异常,可以关联到一个特定的 key 如果可能的话
# 这里简化处理,只记录日志
logger.error(f"Task execution error during bulk verification: {result}")
valid_count = len(successful_keys)
invalid_count = len(failed_keys)
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
# 根据是否有失败的 key 决定最终消息和状态
if failed_keys:
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}"
# 即使有失败也认为是部分成功,返回 200 OK让前端处理详细结果
return JSONResponse({
"success": True, # 表示请求处理完成,具体结果看内容
"message": message,
"successful_keys": successful_keys,
"failed_keys": failed_keys,
"valid_count": valid_count, # 保留计数方便前端快速展示
"invalid_count": invalid_count
})
else:
# 完全成功
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
return JSONResponse({
"success": True,
"message": message,
"successful_keys": successful_keys,
"failed_keys": {},
"valid_count": valid_count,
"invalid_count": 0
})

View File

@@ -0,0 +1,121 @@
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from app.config.config import settings
from app.core.security import SecurityService
from app.domain.openai_models import (
ChatRequest,
EmbeddingRequest,
ImageGenerationRequest,
)
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.log.logger import get_openai_compatible_logger
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.openai_compatiable.openai_compatiable_service import OpenAICompatiableService
router = APIRouter()
logger = get_openai_compatible_logger()
# 初始化服务
security_service = SecurityService()
async def get_key_manager():
return await get_key_manager_instance()
async def get_next_working_key_wrapper(
key_manager: KeyManager = Depends(get_key_manager),
):
return await key_manager.get_next_working_key()
async def get_openai_service(key_manager: KeyManager = Depends(get_key_manager)):
"""获取OpenAI聊天服务实例"""
return OpenAICompatiableService(settings.BASE_URL, key_manager)
@router.get("/openai/v1/models")
async def list_models(
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""获取可用模型列表。"""
operation_name = "list_models"
async with handle_route_errors(logger, operation_name):
logger.info("Handling models list request")
api_key = await key_manager.get_first_valid_key()
logger.info(f"Using API key: {api_key}")
return await openai_service.get_models(api_key)
@router.post("/openai/v1/chat/completions")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),
api_key: str = Depends(get_next_working_key_wrapper),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""处理聊天补全请求,支持流式响应和特定模型切换。"""
operation_name = "chat_completion"
# 检查是否为图像生成相关的聊天模型,如果是,则使用付费密钥
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
current_api_key = api_key # 保存原始key可能是普通key
if is_image_chat:
current_api_key = await key_manager.get_paid_key() # 获取付费密钥
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling chat completion request for model: {request.model}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {current_api_key}") # 使用 current_api_key
if is_image_chat:
# 图像生成聊天,调用特定服务,不处理流式
response = await openai_service.create_image_chat_completion(request, current_api_key)
return response # 直接返回结果
else:
# 普通聊天补全
response = await openai_service.create_chat_completion(request, current_api_key)
# 处理流式响应
if request.stream:
# 假设 openai_service.create_chat_completion 在流式时返回异步生成器
return StreamingResponse(response, media_type="text/event-stream")
# 非流式直接返回结果
return response
@router.post("/openai/v1/images/generations")
async def generate_image(
request: ImageGenerationRequest,
_=Depends(security_service.verify_authorization),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""处理图像生成请求。"""
operation_name = "generate_image"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling image generation request for prompt: {request.prompt}")
# 强制使用配置的模型,确保请求中包含正确的模型信息
request.model = settings.CREATE_IMAGE_MODEL
return await openai_service.generate_images(request)
@router.post("/openai/v1/embeddings")
async def embedding(
request: EmbeddingRequest,
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""处理文本嵌入请求。"""
operation_name = "embedding"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling embedding request for model: {request.model}")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
return await openai_service.create_embeddings(
input_text=request.input, model=request.model, api_key=api_key
)

157
app/router/openai_routes.py Normal file
View File

@@ -0,0 +1,157 @@
from fastapi import APIRouter, Depends, HTTPException
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_logger
from app.service.chat.openai_chat_service import OpenAIChatService
from app.service.embedding.embedding_service import EmbeddingService
from app.service.image.image_create_service import ImageCreateService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
router = APIRouter()
logger = get_openai_logger()
# 初始化服务
security_service = SecurityService()
model_service = ModelService()
embedding_service = EmbeddingService()
image_create_service = ImageCreateService()
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_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
"""获取OpenAI聊天服务实例"""
return OpenAIChatService(settings.BASE_URL, key_manager)
@router.get("/v1/models")
@router.get("/hf/v1/models")
async def list_models(
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
):
"""获取可用的 OpenAI 模型列表 (兼容 Gemini 和 OpenAI)。"""
operation_name = "list_models"
async with handle_route_errors(logger, operation_name):
logger.info("Handling models list request")
api_key = await key_manager.get_first_valid_key()
logger.info(f"Using API key: {api_key}")
return await model_service.get_gemini_openai_models(api_key)
@router.post("/v1/chat/completions")
@router.post("/hf/v1/chat/completions")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),
api_key: str = Depends(get_next_working_key_wrapper),
key_manager: KeyManager = Depends(get_key_manager), # 保留 key_manager 用于获取 paid_key
chat_service: OpenAIChatService = Depends(get_openai_chat_service),
):
"""处理 OpenAI 聊天补全请求,支持流式响应和特定模型切换。"""
operation_name = "chat_completion"
# 检查是否为图像生成相关的聊天模型
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
current_api_key = api_key # 保存原始 key
if is_image_chat:
current_api_key = await key_manager.get_paid_key() # 获取付费密钥
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 not await model_service.check_model_support(request.model):
# 使用 HTTPException会被 handle_route_errors 捕获并记录
raise HTTPException(
status_code=400, detail=f"Model {request.model} is not supported"
)
if is_image_chat:
# 图像生成聊天
response = await chat_service.create_image_chat_completion(request, current_api_key)
return response # 直接返回,不处理流式
else:
# 普通聊天补全
response = await chat_service.create_chat_completion(request, current_api_key)
# 处理流式响应
if request.stream:
return StreamingResponse(response, media_type="text/event-stream")
# 非流式直接返回结果
return response
@router.post("/v1/images/generations")
@router.post("/hf/v1/images/generations")
async def generate_image(
request: ImageGenerationRequest,
_=Depends(security_service.verify_authorization),
):
"""处理 OpenAI 图像生成请求。"""
operation_name = "generate_image"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling image generation request for prompt: {request.prompt}")
# 注意:这里假设 image_create_service.generate_images 是同步函数
# 如果它是异步的,需要 await
response = image_create_service.generate_images(request)
return response
@router.post("/v1/embeddings")
@router.post("/hf/v1/embeddings")
async def embedding(
request: EmbeddingRequest,
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
):
"""处理 OpenAI 文本嵌入请求。"""
operation_name = "embedding"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling embedding request for model: {request.model}")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
response = await embedding_service.create_embedding(
input_text=request.input, model=request.model, api_key=api_key
)
return response
@router.get("/v1/keys/list")
@router.get("/hf/v1/keys/list")
async def get_keys_list(
_=Depends(security_service.verify_auth_token),
key_manager: KeyManager = Depends(get_key_manager),
):
"""获取有效和无效的API key列表 (需要管理 Token 认证)。"""
operation_name = "get_keys_list"
async with handle_route_errors(logger, operation_name):
logger.info("Handling keys list request")
keys_status = await key_manager.get_keys_by_status()
return {
"status": "success",
"data": {
"valid_keys": keys_status["valid_keys"],
"invalid_keys": keys_status["invalid_keys"],
},
"total": len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]),
}

195
app/router/routes.py Normal file
View File

@@ -0,0 +1,195 @@
"""
路由配置模块,负责设置和配置应用程序的路由
"""
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.core.security import verify_auth_token
from app.log.logger import get_routes_logger
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes
from app.service.key.key_manager import get_key_manager_instance
from app.service.stats.stats_service import StatsService
logger = get_routes_logger()
# 配置Jinja2模板
templates = Jinja2Templates(directory="app/templates")
def setup_routers(app: FastAPI) -> None:
"""
设置应用程序的路由
Args:
app: FastAPI应用程序实例
"""
# 包含API路由
app.include_router(openai_routes.router)
app.include_router(gemini_routes.router)
app.include_router(gemini_routes.router_v1beta)
app.include_router(config_routes.router)
app.include_router(error_log_routes.router)
app.include_router(scheduler_routes.router)
app.include_router(stats_routes.router)
app.include_router(version_routes.router)
app.include_router(openai_compatiable_routes.router)
# 添加页面路由
setup_page_routes(app)
# 添加健康检查路由
setup_health_routes(app)
setup_api_stats_routes(app) # Add API stats routes
def setup_page_routes(app: FastAPI) -> None:
"""
设置页面相关的路由
Args:
app: FastAPI应用程序实例
"""
@app.get("/", response_class=HTMLResponse)
async def auth_page(request: Request):
"""认证页面"""
return templates.TemplateResponse("auth.html", {"request": request})
@app.post("/auth")
async def authenticate(request: Request):
"""处理认证请求"""
try:
form = await request.form()
auth_token = form.get("auth_token")
if not auth_token:
logger.warning("Authentication attempt with empty token")
return RedirectResponse(url="/", status_code=302)
if verify_auth_token(auth_token):
logger.info("Successful authentication")
response = RedirectResponse(url="/config", status_code=302)
response.set_cookie(
key="auth_token", value=auth_token, httponly=True, max_age=3600
)
return response
logger.warning("Failed authentication attempt with invalid token")
return RedirectResponse(url="/", status_code=302)
except Exception as e:
logger.error(f"Authentication error: {str(e)}")
return RedirectResponse(url="/", status_code=302)
@app.get("/keys", response_class=HTMLResponse)
async def keys_page(request: Request):
"""密钥管理页面"""
try:
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to keys page")
return RedirectResponse(url="/", status_code=302)
key_manager = await get_key_manager_instance()
keys_status = await key_manager.get_keys_by_status()
total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
valid_key_count = len(keys_status["valid_keys"])
invalid_key_count = len(keys_status["invalid_keys"])
stats_service = StatsService()
api_stats = await stats_service.get_api_usage_stats()
logger.info(f"API stats retrieved: {api_stats}")
logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}")
return templates.TemplateResponse(
"keys_status.html",
{
"request": request,
"valid_keys": keys_status["valid_keys"],
"invalid_keys": keys_status["invalid_keys"],
"total_keys": total_keys, # Renamed for clarity
"valid_key_count": valid_key_count, # Added count
"invalid_key_count": invalid_key_count, # Added count
"api_stats": api_stats, # <-- Pass stats to template
},
)
except Exception as 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
@app.get("/config", response_class=HTMLResponse)
async def config_page(request: Request):
"""配置编辑页面"""
try:
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
logger.info("Config page accessed successfully")
return templates.TemplateResponse("config_editor.html", {"request": request})
except Exception as e:
logger.error(f"Error accessing config page: {str(e)}")
raise
@app.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
"""错误日志页面"""
try:
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to logs page")
return RedirectResponse(url="/", status_code=302)
logger.info("Logs page accessed successfully")
return templates.TemplateResponse("error_logs.html", {"request": request})
except Exception as e:
logger.error(f"Error accessing logs page: {str(e)}")
raise
def setup_health_routes(app: FastAPI) -> None:
"""
设置健康检查相关的路由
Args:
app: FastAPI应用程序实例
"""
@app.get("/health")
async def health_check(request: Request):
"""健康检查端点"""
logger.info("Health check endpoint called")
return {"status": "healthy"}
def setup_api_stats_routes(app: FastAPI) -> None:
"""
设置 API 统计相关的路由
Args:
app: FastAPI应用程序实例
"""
@app.get("/api/stats/details")
async def api_stats_details(request: Request, period: str):
"""获取指定时间段内的 API 调用详情"""
try:
# 验证认证
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to API stats details")
# Returning JSON error instead of redirect for API endpoint
return {"error": "Unauthorized"}, 401
logger.info(f"Fetching API call details for period: {period}")
# Use the service instance here as well
stats_service = StatsService() # Create an instance
details = await stats_service.get_api_call_details(period)
return details
except ValueError as e:
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
return {"error": str(e)}, 400
except Exception as e:
logger.error(f"Error fetching API stats details for period {period}: {str(e)}")
return {"error": "Internal server error"}, 500

View File

@@ -0,0 +1,63 @@
"""
定时任务控制路由模块
"""
from fastapi import APIRouter, Request, HTTPException, status # 移除 Depends, 添加 Request
from fastapi.responses import JSONResponse
from app.core.security import verify_auth_token # 导入 verify_auth_token
from app.scheduler.key_checker import start_scheduler, stop_scheduler
from app.log.logger import get_scheduler_routes # 使用路由日志记录器
logger = get_scheduler_routes()
router = APIRouter(
prefix="/api/scheduler",
tags=["Scheduler"]
# 移除全局依赖
)
# 认证检查的辅助函数
async def verify_token(request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to scheduler API")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post("/start", summary="启动定时任务")
async def start_scheduler_endpoint(request: Request): # 添加 request 参数
"""Start the background scheduler task"""
"""
await verify_token(request) # 在函数开始处进行认证检查
"""
try:
logger.info("Received request to start scheduler.")
start_scheduler() # 调用 key_checker 中的函数
return JSONResponse(content={"message": "Scheduler started successfully."}, status_code=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error starting scheduler: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start scheduler: {str(e)}"
)
@router.post("/stop", summary="停止定时任务")
async def stop_scheduler_endpoint(request: Request): # 添加 request 参数
"""Stop the background scheduler task"""
"""
await verify_token(request) # 在函数开始处进行认证检查
"""
try:
logger.info("Received request to stop scheduler.")
stop_scheduler() # 调用 key_checker 中的函数
return JSONResponse(content={"message": "Scheduler stopped successfully."}, status_code=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error stopping scheduler: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to stop scheduler: {str(e)}"
)

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from starlette import status
from app.core.security import verify_auth_token
from app.service.stats.stats_service import StatsService
from app.log.logger import get_stats_logger
logger = get_stats_logger()
async def verify_token(request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to scheduler API")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
router = APIRouter(
prefix="/api",
tags=["stats"],
dependencies=[Depends(verify_token)]
)
stats_service = StatsService()
@router.get("/key-usage-details/{key}",
summary="获取指定密钥最近24小时的模型调用次数",
description="根据提供的 API 密钥返回过去24小时内每个模型被调用的次数统计。")
async def get_key_usage_details(key: str):
"""
Retrieves the model usage count for a specific API key within the last 24 hours.
Args:
key: The API key to get usage details for.
Returns:
A dictionary with model names as keys and their call counts as values.
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
Raises:
HTTPException: If an error occurs during data retrieval.
"""
try:
usage_details = await stats_service.get_key_usage_details_last_24h(key)
if usage_details is None:
# Handle case where key might be valid but has no recent usage,
# or if the service layer explicitly returns None for other reasons.
# Returning an empty dict is usually fine for the frontend.
return {}
return usage_details
except Exception as e:
logger.error(f"Error fetching key usage details for key {key[:4]}...: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取密钥使用详情时出错: {e}"
)

View File

@@ -0,0 +1,38 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from app.service.update.update_service import check_for_updates
from app.utils.helpers import get_current_version
from app.log.logger import get_update_logger
router = APIRouter(prefix="/api/version", tags=["Version"])
logger = get_update_logger()
class VersionInfo(BaseModel):
current_version: str = Field(..., description="当前应用程序版本")
latest_version: Optional[str] = Field(None, description="可用的最新版本")
update_available: bool = Field(False, description="是否有可用更新")
error_message: Optional[str] = Field(None, description="检查更新时发生的错误信息")
@router.get("/check", response_model=VersionInfo, summary="检查应用程序更新")
async def get_version_info():
"""
检查当前应用程序版本与最新的 GitHub release 版本。
"""
try:
current_version = get_current_version() # Use imported function
update_available, latest_version, error_message = await check_for_updates()
# Log the result for debugging
logger.info(f"Version check API result: current={current_version}, latest={latest_version}, available={update_available}, error='{error_message}'")
return VersionInfo(
current_version=current_version,
latest_version=latest_version,
update_available=update_available,
error_message=error_message
)
except Exception as e:
logger.error(f"Error in /api/version/check endpoint: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="检查版本信息时发生内部错误")

View File

@@ -0,0 +1,102 @@
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 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.")

View File

@@ -1,40 +0,0 @@
from typing import List, Optional, Dict, Any, Literal
from pydantic import BaseModel
class SafetySetting(BaseModel):
category: Optional[Literal["HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_CIVIC_INTEGRITY"]] = None
threshold: Optional[Literal["HARM_BLOCK_THRESHOLD_UNSPECIFIED", "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH", "BLOCK_NONE", "OFF"]] = None
class GenerationConfig(BaseModel):
stopSequences: Optional[List[str]] = None
responseMimeType: Optional[str] = None
responseSchema: Optional[Dict[str, Any]] = None
candidateCount: Optional[int] = 1
maxOutputTokens: Optional[int] = None
temperature: Optional[float] = None
topP: Optional[float] = None
topK: Optional[int] = None
presencePenalty: Optional[float] = None
frequencyPenalty: Optional[float] = None
responseLogprobs: Optional[bool] = None
logprobs: Optional[int] = None
class SystemInstruction(BaseModel):
role: str = "system"
parts: List[Dict[str, Any]]
class GeminiContent(BaseModel):
role: str
parts: List[Dict[str, Any]]
class GeminiRequest(BaseModel):
contents: List[GeminiContent]
tools: Optional[List[Dict[str, Any]]] = []
safetySettings: Optional[List[SafetySetting]] = None
generationConfig: Optional[GenerationConfig] = None
systemInstruction: Optional[SystemInstruction] = None

View File

@@ -1,30 +0,0 @@
from pydantic import BaseModel
from typing import List, Optional, Union
class ChatRequest(BaseModel):
messages: List[dict]
model: str = "gemini-1.5-flash-002"
temperature: Optional[float] = 0.7
stream: Optional[bool] = False
tools: Optional[List[dict]] = []
max_tokens: Optional[int] = 8192
stop: Optional[List[str]] = []
top_p: Optional[float] = 0.9
top_k: Optional[int] = 40
class EmbeddingRequest(BaseModel):
input: Union[str, List[str]]
model: str = "text-embedding-004"
encoding_format: Optional[str] = "float"
class ImageGenerationRequest(BaseModel):
model: str = "DALL-E-3"
prompt: str = ""
n: int = 1
size: Optional[str] = "1024x1024"
quality: Optional[str] = ""
style: Optional[str] = ""
response_format: Optional[str] = "url"

View File

@@ -0,0 +1,295 @@
# app/services/chat_service.py
import json
import re
import datetime # Add datetime import
import time # Add time import
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.domain.gemini_models import GeminiRequest
from app.handler.response_handler import GeminiResponseHandler
from app.handler.stream_optimizer import gemini_optimizer
from app.log.logger import get_gemini_logger
from app.service.client.api_client import GeminiApiClient
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_gemini_logger()
def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片部分"""
for content in contents:
if "parts" in content:
for part in content["parts"]:
if "image_url" in part or "inline_data" in part:
return True
return False
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
record = dict()
for item in tools:
if not item or not isinstance(item, dict):
continue
for k, v in item.items():
if k == "functionDeclarations" and v and isinstance(v, list):
functions = record.get("functionDeclarations", [])
functions.extend(v)
record["functionDeclarations"] = functions
else:
record[k] = v
return record
tool = dict()
if payload and isinstance(payload, dict) and "tools" in payload:
if payload.get("tools") and isinstance(payload.get("tools"), dict):
payload["tools"] = [payload.get("tools")]
items = payload.get("tools", [])
if items and isinstance(items, list):
tool.update(_merge_tools(items))
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (model.endswith("-search") or "-thinking" in model)
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
# 解决 "Tool use with function calling is unsupported" 问题
if tool.get("functionDeclarations"):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
return [tool] if tool else []
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
"""获取安全设置"""
if model == "gemini-2.0-flash-exp":
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
]
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "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"},
]
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
"""构建请求payload"""
request_dict = request.model_dump()
if request.generationConfig:
if request.generationConfig.maxOutputTokens is None:
# 如果未指定最大输出长度,则不传递该字段,解决截断的问题
request_dict["generationConfig"].pop("maxOutputTokens")
payload = {
"contents": request_dict.get("contents", []),
"tools": _build_tools(model, request_dict),
"safetySettings": _get_safety_settings(model),
"generationConfig": request_dict.get("generationConfig", {}),
"systemInstruction": request_dict.get("systemInstruction", ""),
}
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
if model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
return payload
class GeminiChatService:
"""聊天服务"""
def __init__(self, base_url: str, key_manager: KeyManager):
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
self.key_manager = key_manager
self.response_handler = GeminiResponseHandler()
def _extract_text_from_response(self, response: Dict[str, Any]) -> str:
"""从响应中提取文本内容"""
if not response.get("candidates"):
return ""
candidate = response["candidates"][0]
content = candidate.get("content", {})
parts = content.get("parts", [])
if parts and "text" in parts[0]:
return parts[0].get("text", "")
return ""
def _create_char_response(
self, original_response: Dict[str, Any], text: str
) -> Dict[str, Any]:
"""创建包含指定文本的响应"""
response_copy = json.loads(json.dumps(original_response)) # 深拷贝
if response_copy.get("candidates") and response_copy["candidates"][0].get(
"content", {}
).get("parts"):
response_copy["candidates"][0]["content"]["parts"][0]["text"] = text
return response_copy
async def generate_content(
self, model: str, request: GeminiRequest, api_key: str
) -> Dict[str, Any]:
"""生成内容"""
payload = _build_payload(model, request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Record request time
is_success = False
status_code = None
response = None
try:
response = await self.api_client.generate_content(payload, model, api_key)
is_success = True
status_code = 200 # Assume 200 on success
return self.response_handler.handle_response(response, model, stream=False)
except Exception as e:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default to 500 if parsing fails
# Log error to error log table
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="gemini-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
raise e # Re-throw exception for upstream handling
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
# Log request to request log table
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)
async def stream_generate_content(
self, model: str, request: GeminiRequest, api_key: str
) -> AsyncGenerator[str, None]:
"""流式生成内容"""
retries = 0
max_retries = settings.MAX_RETRIES
payload = _build_payload(model, request)
is_success = False
status_code = None
final_api_key = api_key
while retries < max_retries:
request_datetime = datetime.datetime.now()
start_time = time.perf_counter()
current_attempt_key = api_key
final_api_key = current_attempt_key # Update final key used
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
line = line[6:]
response_data = self.response_handler.handle_response(
json.loads(line), model, stream=True
)
text = self._extract_text_from_response(response_data)
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in gemini_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_response(response_data, t),
lambda c: "data: " + json.dumps(c) + "\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
yield "data: " + json.dumps(response_data) + "\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200
break
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Log key used for this failed attempt
model_name=model,
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
# Attempt to switch API Key
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else: # No more keys or retries exceeded by handle_api_failure logic
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
)

View File

@@ -0,0 +1,497 @@
# app/services/chat_service.py
import datetime
import json
import re
import time
from copy import deepcopy
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
from app.config.config import settings
from app.database.services import (
add_error_log,
add_request_log,
)
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
from app.handler.message_converter import OpenAIMessageConverter
from app.handler.response_handler import OpenAIResponseHandler
from app.handler.stream_optimizer import openai_optimizer
from app.log.logger import get_openai_logger
from app.service.client.api_client import GeminiApiClient
from app.service.image.image_create_service import ImageCreateService
from app.service.key.key_manager import KeyManager
logger = get_openai_logger()
def _has_media_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片、音频或视频部分 (inline_data)"""
for content in contents:
if content and "parts" in content and isinstance(content["parts"], list):
for part in content["parts"]:
if isinstance(part, dict) and "inline_data" in part:
return True
return False
def _build_tools(
request: ChatRequest, messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""构建工具"""
tool = dict()
model = request.model
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (
model.endswith("-search")
or "-thinking" in model
or model.endswith("-image")
or model.endswith("-image-generation")
)
and not _has_media_parts(messages) # Use the updated check
):
tool["codeExecution"] = {}
logger.debug("Code execution tool enabled.")
elif _has_media_parts(messages):
logger.debug("Code execution tool disabled due to media parts presence.")
if model.endswith("-search"):
tool["googleSearch"] = {}
# 将 request 中的 tools 合并到 tools 中
if request.tools:
function_declarations = []
for item in request.tools:
if not item or not isinstance(item, dict):
continue
if item.get("type", "") == "function" and item.get("function"):
function = deepcopy(item.get("function"))
parameters = function.get("parameters", {})
if parameters.get("type") == "object" and not parameters.get(
"properties", {}
):
function.pop("parameters", None)
function_declarations.append(function)
if function_declarations:
# 按照 function 的 name 去重
names, functions = set(), []
for fc in function_declarations:
if fc.get("name") not in names:
names.add(fc.get("name"))
functions.append(fc)
tool["functionDeclarations"] = functions
# 解决 "Tool use with function calling is unsupported" 问题
if tool.get("functionDeclarations"):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
return [tool] if tool else []
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
"""获取安全设置"""
# if (
# "2.0" in model
# and "gemini-2.0-flash-thinking-exp" not in model
# and "gemini-2.0-pro-exp" not in model
# ):
if model == "gemini-2.0-flash-exp":
return settings.GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return settings.SAFETY_SETTINGS
def _build_payload(
request: ChatRequest,
messages: List[Dict[str, Any]],
instruction: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""构建请求payload"""
payload = {
"contents": messages,
"generationConfig": {
"temperature": request.temperature,
"stopSequences": request.stop,
"topP": request.top_p,
"topK": request.top_k,
},
"tools": _build_tools(request, messages),
"safetySettings": _get_safety_settings(request.model),
}
if request.max_tokens is not None:
payload["generationConfig"]["maxOutputTokens"] = request.max_tokens
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if request.model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
if request.model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
}
if (
instruction
and isinstance(instruction, dict)
and instruction.get("role") == "system"
and instruction.get("parts")
and not request.model.endswith("-image")
and not request.model.endswith("-image-generation")
):
payload["systemInstruction"] = instruction
return payload
class OpenAIChatService:
"""聊天服务"""
def __init__(self, base_url: str, key_manager: KeyManager = None):
self.message_converter = OpenAIMessageConverter()
self.response_handler = OpenAIResponseHandler(config=None)
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
self.key_manager = key_manager
self.image_create_service = ImageCreateService()
def _extract_text_from_openai_chunk(self, chunk: Dict[str, Any]) -> str:
"""从OpenAI响应块中提取文本内容"""
if not chunk.get("choices"):
return ""
choice = chunk["choices"][0]
if "delta" in choice and "content" in choice["delta"]:
return choice["delta"]["content"]
return ""
def _create_char_openai_chunk(
self, original_chunk: Dict[str, Any], text: str
) -> Dict[str, Any]:
"""创建包含指定文本的OpenAI响应块"""
chunk_copy = json.loads(json.dumps(original_chunk)) # 深拷贝
if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]:
chunk_copy["choices"][0]["delta"]["content"] = text
return chunk_copy
async def create_chat_completion(
self,
request: ChatRequest,
api_key: str,
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
# 转换消息格式
messages, instruction = self.message_converter.convert(request.messages)
# 构建请求payload
payload = _build_payload(request, messages, instruction)
if request.stream:
return self._handle_stream_completion(request.model, payload, api_key)
return await self._handle_normal_completion(request.model, payload, api_key)
async def _handle_normal_completion(
self, model: str, payload: Dict[str, Any], 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(payload, model, api_key)
is_success = True
status_code = 200
return self.response_handler.handle_response(
response, model, stream=False, finish_reason="stop"
)
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-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
)
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[str, Any], 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:
tool_call_flag = False
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
chunk = json.loads(line[6:])
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None
)
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(
openai_chunk, t
),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
if "tool_calls" in json.dumps(openai_chunk):
tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n"
if tool_call_flag:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200
break # 成功后退出循环
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
error_type="openai-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else:
logger.error(
f"No valid API key available after {retries} retries."
)
break
else:
logger.error("KeyManager not available for retry logic.")
break
if retries >= max_retries:
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime,
)
# If the loop finished due to failure, yield error and DONE
if not is_success and retries >= max_retries:
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"
async def create_image_chat_completion(
self, request: ChatRequest, api_key: str
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
image_generate_request = ImageGenerationRequest()
image_generate_request.prompt = request.messages[-1]["content"]
image_res = self.image_create_service.generate_images_chat(
image_generate_request
)
if request.stream:
return self._handle_stream_image_completion(
request.model, image_res, api_key
)
else:
return await self._handle_normal_image_completion(
request.model, image_res, api_key
)
async def _handle_stream_image_completion(
self, model: str, image_data: str, api_key: str
) -> AsyncGenerator[str, None]:
logger.info(f"Starting stream image completion for model: {model}")
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
try:
if image_data:
openai_chunk = self.response_handler.handle_image_chat_response(
image_data, model, stream=True, finish_reason=None
)
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(openai_chunk, t),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容如图片URL等整块输出
yield f"data: {json.dumps(openai_chunk)}\n\n"
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
logger.info(
f"Stream image completion finished successfully for model: {model}"
)
is_success = True
status_code = 200
yield "data: [DONE]\n\n"
except Exception as e:
is_success = False
error_log_msg = f"Stream image completion failed for model {model}: {e}"
logger.error(error_log_msg)
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-image-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg={
"image_data_truncated": image_data[:1000]
},
)
yield f"data: {json.dumps({'error': error_log_msg})}\n\n"
yield "data: [DONE]\n\n"
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
logger.info(
f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}"
)
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime,
)
async def _handle_normal_image_completion(
self, model: str, image_data: str, api_key: str
) -> Dict[str, Any]:
logger.info(f"Starting normal image completion for model: {model}")
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
result = None
try:
result = self.response_handler.handle_image_chat_response(
image_data, model, stream=False, finish_reason="stop"
)
logger.info(
f"Normal image completion finished successfully for model: {model}"
)
is_success = True
status_code = 200
return result
except Exception as e:
is_success = False
error_log_msg = f"Normal image completion failed for model {model}: {e}"
logger.error(error_log_msg)
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-image-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg={
"image_data_truncated": image_data[:1000]
},
)
# Re-raise the exception so the caller knows about the failure
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
logger.info(
f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}"
)
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime,
)

View File

@@ -0,0 +1,195 @@
# app/services/chat/api_client.py
from typing import Dict, Any, AsyncGenerator, Optional
import httpx
import random
from abc import ABC, abstractmethod
from app.config.config import settings
from app.log.logger import get_api_client_logger
from app.core.constants import DEFAULT_TIMEOUT
logger = get_api_client_logger()
class ApiClient(ABC):
"""API客户端基类"""
@abstractmethod
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
pass
@abstractmethod
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
pass
class GeminiApiClient(ApiClient):
"""Gemini API客户端"""
def __init__(self, base_url: str, timeout: int = DEFAULT_TIMEOUT):
self.base_url = base_url
self.timeout = timeout
def _get_real_model(self, model: str) -> str:
if model.endswith("-search"):
model = model[:-7]
if model.endswith("-image"):
model = model[:-6]
if model.endswith("-non-thinking"):
model = model[:-13]
if "-search" in model and "-non-thinking" in model:
model = model[:-20]
return model
async def get_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""获取可用的 Gemini 模型列表"""
timeout = httpx.Timeout(timeout=5)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models?key={api_key}"
try:
response = await client.get(url)
response.raise_for_status() # 如果状态码不是 2xx则引发 HTTPStatusError
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"获取模型列表失败: {e.response.status_code}")
logger.error(e.response.text)
# 返回 None 而不是抛出异常,以便上层处理
return None
except httpx.RequestError as e:
logger.error(f"请求模型列表失败: {e}")
# 返回 None 而不是抛出异常
return None
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
response = await client.post(url, json=payload)
if response.status_code != 200:
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], model: str, api_key: str) -> AsyncGenerator[str, None]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
async with client.stream(method="POST", url=url, json=payload) as response:
if response.status_code != 200:
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
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()

View File

@@ -0,0 +1,150 @@
"""
配置服务模块
"""
import datetime
import json
from typing import Any, Dict, List
from dotenv import find_dotenv, load_dotenv
from sqlalchemy import insert, update
from app.config.config import settings
from app.database.connection import database
from app.database.models import Settings
from app.config.config import Settings as ConfigSettings
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
logger = get_config_routes_logger()
class ConfigService:
"""配置服务类,用于管理应用程序配置"""
@staticmethod
async def get_config() -> Dict[str, Any]:
return settings.model_dump()
@staticmethod
async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
for key, value in config_data.items():
if hasattr(settings, key):
setattr(settings, key, value)
logger.debug(f"Updated setting in memory: {key}")
# 获取现有设置
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_keys = set(existing_settings_map.keys())
settings_to_update: List[Dict[str, Any]] = []
settings_to_insert: List[Dict[str, Any]] = []
now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8)))
# 准备要更新或插入的数据
for key, value in config_data.items():
# 处理不同类型的值
if isinstance(value, list):
db_value = json.dumps(value)
elif isinstance(value, dict): # 新增对 dict 类型的处理
db_value = json.dumps(value)
elif isinstance(value, bool):
db_value = str(value).lower()
else:
db_value = str(value)
# 仅当值发生变化时才更新
if key in existing_keys and existing_settings_map[key]['value'] == db_value:
continue
description = f"{key}配置项"
data = {
'key': key,
'value': db_value,
'description': description,
'updated_at': now
}
if key in existing_keys:
# Preserve original description if not explicitly provided
data['description'] = existing_settings_map[key].get('description', description)
settings_to_update.append(data)
else:
data['created_at'] = now
settings_to_insert.append(data)
# 在事务中执行批量插入和更新
if settings_to_insert or settings_to_update:
try:
async with database.transaction():
if settings_to_insert:
query_insert = insert(Settings).values(settings_to_insert)
await database.execute(query=query_insert)
logger.info(f"Bulk inserted {len(settings_to_insert)} settings.")
if settings_to_update:
for setting_data in settings_to_update:
query_update = (
update(Settings)
.where(Settings.key == setting_data['key'])
.values(
value=setting_data['value'],
description=setting_data['description'],
updated_at=setting_data['updated_at']
)
)
await database.execute(query=query_update)
logger.info(f"Updated {len(settings_to_update)} settings.")
except Exception as e:
logger.error(f"Failed to bulk update/insert settings: {str(e)}")
raise # Re-raise the exception after logging
# 重置并重新初始化 KeyManager
try:
await reset_key_manager_instance()
await get_key_manager_instance(settings.API_KEYS)
logger.info("KeyManager instance re-initialized with updated settings.")
except Exception as e:
logger.error(f"Failed to re-initialize KeyManager: {str(e)}")
# Decide if this error should prevent returning the updated config
# For now, we log the error and continue
return await ConfigService.get_config()
@staticmethod
async def reset_config() -> Dict[str, Any]:
"""
重置配置:优先从系统环境变量加载,然后从 .env 文件加载,
更新内存中的 settings 对象,并刷新 KeyManager。
Returns:
Dict[str, Any]: 重置后的配置字典
"""
# 1. 重新加载配置对象,它应该处理环境变量和 .env 的优先级
_reload_settings()
logger.info("Settings object reloaded, prioritizing system environment variables then .env file.")
# 2. 重置并重新初始化 KeyManager
try:
await reset_key_manager_instance()
# 确保使用更新后的 settings 中的 API_KEYS
await get_key_manager_instance(settings.API_KEYS)
logger.info("KeyManager instance re-initialized with reloaded settings.")
except Exception as e:
logger.error(f"Failed to re-initialize KeyManager during reset: {str(e)}")
# 根据需要决定是否抛出异常或继续
# 这里选择记录错误并继续
# 3. 返回更新后的配置
return await ConfigService.get_config()
# 重新加载配置的函数
def _reload_settings():
"""重新加载环境变量并更新配置"""
# 显式加载 .env 文件,覆盖现有环境变量
load_dotenv(find_dotenv(), override=True)
# 更新现有 settings 对象的属性,而不是新建实例
for key, value in ConfigSettings().model_dump().items():
setattr(settings, key, value)

View File

@@ -0,0 +1,82 @@
import datetime
import time
import re # For potential status code parsing from generic errors
from typing import List, Union
import openai
from openai import APIStatusError # Import specific error type
from openai.types import CreateEmbeddingResponse
from app.config.config import settings
from app.log.logger import get_embeddings_logger
from app.database.services import add_error_log, add_request_log # Import DB logging functions
logger = get_embeddings_logger()
class EmbeddingService:
async def create_embedding(
self, input_text: Union[str, List[str]], model: str, api_key: str
) -> CreateEmbeddingResponse:
"""Create embeddings using OpenAI API with database logging"""
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
error_log_msg = ""
# Prepare request message for logging (truncate if list or long string)
if isinstance(input_text, list):
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
if len(input_text) > 5:
request_msg_log["input_truncated"].append("...")
else:
request_msg_log = {"input_truncated": input_text[:1000] + "..." if len(input_text) > 1000 else input_text}
try:
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
response = client.embeddings.create(input=input_text, model=model)
is_success = True
status_code = 200 # Assume 200 OK on success
return response
except APIStatusError as e:
is_success = False
status_code = e.status_code
error_log_msg = f"OpenAI API error: {e}"
logger.error(f"Error creating embedding (APIStatusError): {error_log_msg}")
raise e # Re-raise the specific error
except Exception as e:
is_success = False
error_log_msg = f"Generic error: {e}"
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
# Try to parse status code from generic error (less reliable)
match = re.search(r"status code (\d+)", str(e))
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
raise e # Re-raise the generic error
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
if not is_success:
# Log error to database if it failed
await add_error_log(
gemini_key=api_key, # Using gemini_key parameter name for consistency
model_name=model,
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
)
# Log request outcome to database regardless of success/failure
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)

View File

@@ -1,14 +1,15 @@
import base64
import time
import uuid
from google import genai
from google.genai import types
import base64
from app.core.config import settings
from app.core.logger import get_image_create_logger
from app.core.uploader import ImageUploaderFactory
from app.schemas.openai_models import ImageGenerationRequest
from app.config.config import settings
from app.core.constants import VALID_IMAGE_RATIOS
from app.domain.openai_models import ImageGenerationRequest
from app.log.logger import get_image_create_logger
from app.utils.uploader import ImageUploaderFactory
logger = get_image_create_logger()
@@ -16,7 +17,6 @@ logger = get_image_create_logger()
class ImageCreateService:
def __init__(self, aspect_ratio="1:1"):
self.image_model = settings.CREATE_IMAGE_MODEL
self.paid_key = settings.PAID_KEY
self.aspect_ratio = aspect_ratio
def parse_prompt_parameters(self, prompt: str) -> tuple:
@@ -26,35 +26,34 @@ class ImageCreateService:
- {ratio:比例} 例如: {ratio:16:9} 使用16:9比例
"""
import re
# 默认值
n = 1
aspect_ratio = self.aspect_ratio
# 解析n参数
n_match = re.search(r'{n:(\d+)}', prompt)
n_match = re.search(r"{n:(\d+)}", prompt)
if n_match:
n = int(n_match.group(1))
if n < 1 or n > 4:
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
prompt = prompt.replace(n_match.group(0), '').strip()
# 解析ratio参数
ratio_match = re.search(r'{ratio:(\d+:\d+)}', prompt)
prompt = prompt.replace(n_match.group(0), "").strip()
# 解析ratio参数
ratio_match = re.search(r"{ratio:(\d+:\d+)}", prompt)
if ratio_match:
aspect_ratio = ratio_match.group(1)
valid_ratios = ["1:1", "3:4", "4:3", "9:16", "16:9"]
if aspect_ratio not in valid_ratios:
if aspect_ratio not in VALID_IMAGE_RATIOS:
raise ValueError(
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(valid_ratios)}"
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(VALID_IMAGE_RATIOS)}"
)
prompt = prompt.replace(ratio_match.group(0), '').strip()
prompt = prompt.replace(ratio_match.group(0), "").strip()
return prompt, n, aspect_ratio
def generate_images(self, request: ImageGenerationRequest):
client = genai.Client(api_key=self.paid_key)
client = genai.Client(api_key=settings.PAID_KEY)
if request.size == "1024x1024":
self.aspect_ratio = "1:1"
elif request.size == "1792x1024":
@@ -67,13 +66,15 @@ class ImageCreateService:
)
# 解析prompt中的参数
cleaned_prompt, prompt_n, prompt_ratio = self.parse_prompt_parameters(request.prompt)
cleaned_prompt, prompt_n, prompt_ratio = self.parse_prompt_parameters(
request.prompt
)
request.prompt = cleaned_prompt
# 如果prompt中指定了n则覆盖请求中的n
if prompt_n > 1:
request.n = prompt_n
# 如果prompt中指定了ratio则覆盖默认的aspect_ratio
if prompt_ratio != self.aspect_ratio:
self.aspect_ratio = prompt_ratio
@@ -87,7 +88,6 @@ class ImageCreateService:
aspect_ratio=self.aspect_ratio,
safety_filter_level="BLOCK_LOW_AND_ABOVE",
person_generation="ALLOW_ADULT",
# language="auto"
),
)
@@ -96,27 +96,49 @@ class ImageCreateService:
for index, generated_image in enumerate(response.generated_images):
image_data = generated_image.image.image_bytes
image_uploader = None
if settings.UPLOAD_PROVIDER == "smms":
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,api_key=settings.SMMS_SECRET_TOKEN)
if request.response_format == "b64_json":
base64_image = base64.b64encode(image_data).decode("utf-8")
images_data.append(
{"b64_json": base64_image, "revised_prompt": request.prompt}
)
else:
current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
upload_response = image_uploader.upload(image_data,filename)
if request.response_format == "b64_json":
base64_image = base64.b64encode(image_data).decode('utf-8')
images_data.append({
"b64_json": base64_image,
"revised_prompt": request.prompt
})
else:
images_data.append({
"url": f"{upload_response.data.url}",
"revised_prompt": request.prompt
})
if settings.UPLOAD_PROVIDER == "smms":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER,
api_key=settings.SMMS_SECRET_TOKEN,
)
elif settings.UPLOAD_PROVIDER == "picgo":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER,
api_key=settings.PICGO_API_KEY,
)
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,
)
else:
raise ValueError(
f"Unsupported upload provider: {settings.UPLOAD_PROVIDER}"
)
upload_response = image_uploader.upload(image_data, filename)
images_data.append(
{
"url": f"{upload_response.data.url}",
"revised_prompt": request.prompt,
}
)
response_data = {
"created": int(time.time()), # Current timestamp
"data": images_data
"data": images_data,
}
return response_data
else:
@@ -128,9 +150,13 @@ class ImageCreateService:
if image_datas:
markdown_images = []
for index, image_data in enumerate(image_datas):
if 'url' in image_data:
markdown_images.append(f"![Generated Image {index+1}]({image_data['url']})")
if "url" in image_data:
markdown_images.append(
f"![Generated Image {index+1}]({image_data['url']})"
)
else:
# 如果是base64格式创建data URL
markdown_images.append(f"![Generated Image {index+1}](data:image/png;base64,{image_data['b64_json']})")
markdown_images.append(
f"![Generated Image {index+1}](data:image/png;base64,{image_data['b64_json']})"
)
return "\n".join(markdown_images)

View File

@@ -1,10 +1,11 @@
import asyncio
from itertools import cycle
from typing import Dict
from app.core.logger import get_key_manager_logger
from app.core.config import settings
from app.config.config import settings
from app.log.logger import get_key_manager_logger
logger = get_key_manager_logger()
@@ -20,7 +21,7 @@ class KeyManager:
async def get_paid_key(self) -> str:
return self.paid_key
async def get_next_key(self) -> str:
"""获取下一个API key"""
async with self.key_cycle_lock:
@@ -36,6 +37,16 @@ class KeyManager:
async with self.failure_count_lock:
for key in self.key_failure_counts:
self.key_failure_counts[key] = 0
async def reset_key_failure_count(self, key: str) -> bool:
"""重置指定key的失败计数"""
async with self.failure_count_lock:
if key in self.key_failure_counts:
self.key_failure_counts[key] = 0
logger.info(f"Reset failure count for key: {key}")
return True
logger.warning(f"Attempt to reset failure count for non-existent key: {key}")
return False
async def get_next_working_key(self) -> str:
"""获取下一可用的API key"""
@@ -51,7 +62,7 @@ class KeyManager:
# await self.reset_failure_counts() 取消重置
return current_key
async def handle_api_failure(self, api_key: str) -> str:
async def handle_api_failure(self, api_key: str,retries: int) -> str:
"""处理API调用失败"""
async with self.failure_count_lock:
self.key_failure_counts[api_key] += 1
@@ -59,8 +70,10 @@ class KeyManager:
logger.warning(
f"API key {api_key} has failed {self.MAX_FAILURES} times"
)
return await self.get_next_working_key()
if retries < settings.MAX_RETRIES:
return await self.get_next_working_key()
else:
return ""
def get_fail_count(self, key: str) -> int:
"""获取指定密钥的失败次数"""
@@ -70,7 +83,7 @@ class KeyManager:
"""获取分类后的API key列表包括失败次数"""
valid_keys = {}
invalid_keys = {}
async with self.failure_count_lock:
for key in self.api_keys:
fail_count = self.key_failure_counts[key]
@@ -78,16 +91,21 @@ class KeyManager:
valid_keys[key] = fail_count
else:
invalid_keys[key] = fail_count
return {
"valid_keys": valid_keys,
"invalid_keys": invalid_keys
}
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys}
async def get_first_valid_key(self) -> str:
"""获取第一个有效的API key"""
async with self.failure_count_lock:
for key in self.key_failure_counts:
if self.key_failure_counts[key] < self.MAX_FAILURES:
return key
return self.api_keys[0]
_singleton_instance = None
_singleton_lock = asyncio.Lock()
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
"""
获取 KeyManager 单例实例
@@ -102,4 +120,14 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
if api_keys is None:
raise ValueError("API keys are required to initialize the KeyManager")
_singleton_instance = KeyManager(api_keys)
logger.info("KeyManager instance created.")
return _singleton_instance
async def reset_key_manager_instance():
"""重置 KeyManager 单例实例"""
global _singleton_instance
async with _singleton_lock:
if _singleton_instance:
_singleton_instance = None
logger.info("KeyManager instance reset.")

View File

@@ -0,0 +1,93 @@
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from app.config.config import settings
from app.log.logger import get_model_logger
from app.service.client.api_client import GeminiApiClient
logger = get_model_logger()
class ModelService:
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""使用 GeminiApiClient 获取并过滤模型列表"""
api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端
gemini_models = await api_client.get_models(api_key)
if gemini_models is None:
logger.error("从 API 客户端获取模型列表失败。")
return None
try:
filtered_models_list = []
for model in gemini_models.get("models", []):
model_id = model["name"].split("/")[-1]
if model_id not in settings.FILTERED_MODELS:
filtered_models_list.append(model)
else:
logger.debug(f"Filtered out model: {model_id}")
gemini_models["models"] = filtered_models_list
return gemini_models
except Exception as e:
logger.error(f"处理模型列表时出错: {e}")
return None
async def get_gemini_openai_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""获取 Gemini 模型并转换为 OpenAI 格式"""
gemini_models = await self.get_gemini_models(api_key)
if gemini_models is None:
return None
return await self.convert_to_openai_models_format(gemini_models)
async def convert_to_openai_models_format(
self, gemini_models: Dict[str, Any]
) -> Dict[str, Any]:
openai_format = {"object": "list", "data": [], "success": True}
for model in gemini_models.get("models", []):
model_id = model["name"].split("/")[-1]
openai_model = {
"id": model_id,
"object": "model",
"created": int(datetime.now(timezone.utc).timestamp()),
"owned_by": "google",
"permission": [],
"root": model["name"],
"parent": None,
}
openai_format["data"].append(openai_model)
if model_id in settings.SEARCH_MODELS:
search_model = openai_model.copy()
search_model["id"] = f"{model_id}-search"
openai_format["data"].append(search_model)
if model_id in settings.IMAGE_MODELS:
image_model = openai_model.copy()
image_model["id"] = f"{model_id}-image"
openai_format["data"].append(image_model)
if model_id in settings.THINKING_MODELS:
non_thinking_model = openai_model.copy()
non_thinking_model["id"] = f"{model_id}-non-thinking"
openai_format["data"].append(non_thinking_model)
if settings.CREATE_IMAGE_MODEL:
image_model = openai_model.copy()
image_model["id"] = f"{settings.CREATE_IMAGE_MODEL}-chat"
openai_format["data"].append(image_model)
return openai_format
async def check_model_support(self, model: str) -> bool:
if not model or not isinstance(model, str):
return False
model = model.strip()
if model.endswith("-search"):
model = model[:-7]
return model in settings.SEARCH_MODELS
if model.endswith("-image"):
model = model[:-6]
return model in settings.IMAGE_MODELS
return model not in settings.FILTERED_MODELS

View 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"

View File

@@ -0,0 +1,174 @@
# app/service/stats_service.py
import datetime
from sqlalchemy import select, func
from app.database.connection import database
from app.database.models import RequestLog
from app.log.logger import get_stats_logger
logger = get_stats_logger()
class StatsService:
"""Service class for handling statistics related operations."""
async def get_calls_in_last_seconds(self, seconds: int) -> int:
"""获取过去 N 秒内的调用次数 (包括成功和失败)"""
try:
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= cutoff_time
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
return 0 # Return 0 on error
async def get_calls_in_last_minutes(self, minutes: int) -> int:
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
return await self.get_calls_in_last_seconds(minutes * 60)
async def get_calls_in_last_hours(self, hours: int) -> int:
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
return await self.get_calls_in_last_seconds(hours * 3600)
async def get_calls_in_current_month(self) -> int:
"""获取当前自然月内的调用次数 (包括成功和失败)"""
try:
now = datetime.datetime.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= start_of_month
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in current month: {e}")
return 0 # Return 0 on error
async def get_api_usage_stats(self) -> dict:
"""获取所有需要的 API 使用统计数据"""
try:
calls_1m = await self.get_calls_in_last_minutes(1)
calls_1h = await self.get_calls_in_last_hours(1)
calls_24h = await self.get_calls_in_last_hours(24)
calls_month = await self.get_calls_in_current_month()
return {
"calls_1m": calls_1m,
"calls_1h": calls_1h,
"calls_24h": calls_24h,
"calls_month": calls_month,
}
except Exception as e:
logger.error(f"Failed to get API usage stats: {e}")
# Return default values on error
return {
"calls_1m": 0,
"calls_1h": 0,
"calls_24h": 0,
"calls_month": 0,
}
async def get_api_call_details(self, period: str) -> list[dict]:
"""
获取指定时间段内的 API 调用详情
Args:
period: 时间段标识 ('1m', '1h', '24h')
Returns:
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
Raises:
ValueError: 如果 period 无效
"""
now = datetime.datetime.now()
if period == '1m':
start_time = now - datetime.timedelta(minutes=1)
elif period == '1h':
start_time = now - datetime.timedelta(hours=1)
elif period == '24h':
start_time = now - datetime.timedelta(hours=24)
else:
raise ValueError(f"无效的时间段标识: {period}")
try:
query = select(
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code # We might need to map this to 'success'/'failure' later
).where(
RequestLog.request_time >= start_time
).order_by(RequestLog.request_time.desc()) # Order by most recent first
results = await database.fetch_all(query)
# Convert results to list of dicts and map status_code
details = []
for row in results:
status = 'failure' # 默认状态为 failure如果 status_code 有效且在 200-299 范围内则更新为 success
if row['status_code'] is not None: # 检查 status_code 是否为空
status = 'success' if 200 <= row['status_code'] < 300 else 'failure'
details.append({
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
"key": row['key'],
"model": row['model'],
"status": status
})
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
return details
except Exception as e:
logger.error(f"Failed to get API call details for period '{period}': {e}")
# Re-raise the exception to be handled by the route
raise
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
"""
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
Args:
key: 要查询的 API 密钥。
Returns:
一个字典,其中键是模型名称,值是调用次数。
如果查询出错或没有找到记录,可能返回 None 或空字典。
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
"""
logger.info(f"Fetching usage details for key ending in ...{key[-4:]} for the last 24h.")
cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=24)
try:
query = select(
RequestLog.model_name,
func.count(RequestLog.id).label("call_count")
).where(
RequestLog.api_key == key,
RequestLog.request_time >= cutoff_time,
RequestLog.model_name.isnot(None) # Ensure model_name is not null
).group_by(
RequestLog.model_name
).order_by(
func.count(RequestLog.id).desc() # Order by count descending
)
results = await database.fetch_all(query)
if not results:
logger.info(f"No usage details found for key ending in ...{key[-4:]} in the last 24h.")
return {} # Return empty dict if no records found
usage_details = {row['model_name']: row['call_count'] for row in results}
logger.info(f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}")
return usage_details
except Exception as e:
logger.error(f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}", exc_info=True)
# Depending on requirements, you might return None or raise the exception
# Raising allows the route handler to return a 500 error.
raise # Re-raise the exception

View File

@@ -0,0 +1,108 @@
import httpx
from packaging import version
from typing import Optional, Tuple
from app.config.config import settings
from app.log.logger import get_update_logger
logger = get_update_logger()
# GitHub repository details are read from settings (defined in app/config/config.py or environment variables)
# GITHUB_API_URL will be constructed inside the function to ensure settings are loaded
VERSION_FILE_PATH = "VERSION" # Path relative to project root
async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
"""
通过比较当前版本与最新的 GitHub release 来检查应用程序更新。
Returns:
Tuple[bool, Optional[str], Optional[str]]: 一个元组,包含:
- bool: 如果有可用更新则为 True否则为 False。
- Optional[str]: 如果有可用更新,则为最新的版本字符串,否则为 None。
- Optional[str]: 如果检查失败,则为错误消息,否则为 None。
"""
try:
# Read current version from VERSION file
# Ensure the path is correct relative to the execution context or use absolute path if needed
# Assuming execution from project root d:/develop/pythonProjects/gemini-balance
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
current_v = f.read().strip()
if not current_v:
logger.error(f"VERSION file ('{VERSION_FILE_PATH}') is empty.")
return False, None, f"VERSION file ('{VERSION_FILE_PATH}') is empty."
except FileNotFoundError:
logger.error(f"VERSION file not found at '{VERSION_FILE_PATH}'. Make sure it exists in the project root.")
return False, None, f"VERSION file not found at '{VERSION_FILE_PATH}'."
except IOError as e:
logger.error(f"Error reading VERSION file ('{VERSION_FILE_PATH}'): {e}")
return False, None, f"Error reading VERSION file ('{VERSION_FILE_PATH}')."
logger.info(f"当前应用程序版本 (from {VERSION_FILE_PATH}): {current_v}")
# Check if repository details are configured in settings
if not settings.GITHUB_REPO_OWNER or not settings.GITHUB_REPO_NAME or \
settings.GITHUB_REPO_OWNER == "your_owner" or settings.GITHUB_REPO_NAME == "your_repo":
logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.")
return False, None, "Update check skipped: Repository not configured in settings."
# Construct the API URL inside the function to ensure settings are loaded
github_api_url = f"https://api.github.com/repos/{settings.GITHUB_REPO_OWNER}/{settings.GITHUB_REPO_NAME}/releases/latest"
logger.debug(f"Checking for updates at URL: {github_api_url}") # Log the URL for debugging
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 添加 User-Agent 头GitHub API 可能需要
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0" # Use repo name from settings for User-Agent
}
response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL
response.raise_for_status() # 对错误的 HTTP 状态码4xx 或 5xx抛出异常
latest_release = response.json()
latest_v_str = latest_release.get("tag_name")
if not latest_v_str:
logger.warning("在最新的 GitHub release 响应中找不到 'tag_name'")
return False, None, "无法从 GitHub 解析最新版本。"
# 移除 tag 名称中可能存在的 'v' 前缀
if latest_v_str.startswith('v'):
latest_v_str = latest_v_str[1:]
logger.info(f"在 GitHub 上找到的最新版本: {latest_v_str}")
# 比较版本
current_version = version.parse(current_v)
latest_version = version.parse(latest_v_str)
if latest_version > current_version:
logger.info(f"有可用更新: {current_v} -> {latest_v_str}")
return True, latest_v_str, None
else:
logger.info("应用程序已是最新版本。")
return False, None, None
except httpx.HTTPStatusError as e:
logger.error(f"检查更新时发生 HTTP 错误: {e.response.status_code} - {e.response.text}")
# 避免向用户显示详细的错误文本
error_msg = f"获取更新信息失败 (HTTP {e.response.status_code})。"
if e.response.status_code == 404:
error_msg += " 请检查仓库名称是否正确或仓库是否有发布版本。"
elif e.response.status_code == 403:
error_msg += " API 速率限制或权限问题。"
return False, None, error_msg
except httpx.RequestError as e:
logger.error(f"检查更新时发生网络错误: {e}")
return False, None, "更新检查期间发生网络错误。"
except version.InvalidVersion:
# Note: latest_v_str might not be defined if the error occurs before fetching it.
# Consider adding a check or default value for logging.
latest_v_str_for_log = latest_v_str if 'latest_v_str' in locals() else 'N/A'
logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'")
return False, None, "遇到无效的版本格式。"
except Exception as e:
logger.error(f"更新检查期间发生意外错误: {e}", exc_info=True)
return False, None, "发生意外错误。"

View File

@@ -1,51 +0,0 @@
# app/services/chat/api_client.py
from typing import Dict, Any, AsyncGenerator
import httpx
from abc import ABC, abstractmethod
class ApiClient(ABC):
"""API客户端基类"""
@abstractmethod
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
pass
@abstractmethod
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
pass
class GeminiApiClient(ApiClient):
"""Gemini API客户端"""
def __init__(self, base_url: str, timeout: int = 300):
self.base_url = base_url
self.timeout = timeout
def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
if model.endswith("-search"):
model = model[:-7]
with httpx.Client(timeout=timeout) as client:
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
response = client.post(url, json=payload)
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], model: str, api_key: str) -> AsyncGenerator[str, None]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
if model.endswith("-search"):
model = model[:-7]
async with httpx.AsyncClient(timeout=timeout) as client:
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
async with client.stream(method="POST", url=url, json=payload) as response:
if response.status_code != 200:
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

View File

@@ -1,55 +0,0 @@
# app/services/chat/message_converter.py
from abc import ABC, abstractmethod
from typing import List, Dict, Any
class MessageConverter(ABC):
"""消息转换器基类"""
@abstractmethod
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
pass
def _convert_image(image_url: str) -> Dict[str, Any]:
if image_url.startswith("data:image"):
return {
"inline_data": {
"mime_type": "image/jpeg",
"data": image_url.split(",")[1]
}
}
return {
"image_url": {
"url": image_url
}
}
class OpenAIMessageConverter(MessageConverter):
"""OpenAI消息格式转换器"""
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
converted_messages = []
for msg in messages:
role = "user" if msg["role"] == "user" else "model"
parts = []
if isinstance(msg["content"], str):
# 请求 gemini 接口时如果包含 content 字段但内容为空时会返回 400 错误,所以需要判断是否为空
if msg["content"]:
parts.append({"text": msg["content"]})
elif 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"]))
converted_messages.append({"role": role, "parts": parts})
return converted_messages

View File

@@ -1,41 +0,0 @@
# app/services/chat/retry_handler.py
from typing import TypeVar, Callable
from functools import wraps
from app.core.logger import get_retry_logger
T = TypeVar('T')
logger = get_retry_logger()
class RetryHandler:
"""重试处理装饰器"""
def __init__(self, max_retries: int = 3, key_arg: str = "api_key"):
self.max_retries = max_retries
self.key_arg = key_arg
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
async def wrapper(*args, **kwargs) -> T:
last_exception = None
for attempt in range(self.max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}")
# 从函数参数中获取 key_manager
key_manager = kwargs.get('key_manager')
if key_manager:
old_key = kwargs.get(self.key_arg)
new_key = await key_manager.handle_api_failure(old_key)
kwargs[self.key_arg] = new_key
logger.info(f"Switched to new API key: {new_key}")
logger.error(f"All retry attempts failed, raising final exception: {str(last_exception)}")
raise last_exception
return wrapper

View File

@@ -1,25 +0,0 @@
from typing import Union, List
import openai
from openai.types import CreateEmbeddingResponse
from app.core.logger import get_embeddings_logger
logger = get_embeddings_logger()
class EmbeddingService:
def __init__(self, base_url: str):
self.base_url = base_url
async def create_embedding(
self, input_text: Union[str, List[str]], model: str, api_key: str
) -> CreateEmbeddingResponse:
"""Create embeddings using OpenAI API"""
try:
client = openai.OpenAI(api_key=api_key, base_url=self.base_url)
response = client.embeddings.create(input=input_text, model=model)
return response
except Exception as e:
logger.error(f"Error creating embedding: {str(e)}")
raise

View File

@@ -1,110 +0,0 @@
# app/services/chat_service.py
import json
from typing import Dict, Any, AsyncGenerator, List
from app.core.logger import get_gemini_logger
from app.services.chat.api_client import GeminiApiClient
from app.schemas.gemini_models import GeminiRequest
from app.core.config import settings
from app.services.chat.response_handler import GeminiResponseHandler
from app.services.key_manager import KeyManager
logger = get_gemini_logger()
def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片部分"""
for content in contents:
if "parts" in content:
for part in content["parts"]:
if "image_url" in part or "inline_data" in part:
return True
return False
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
tools = []
if settings.TOOLS_CODE_EXECUTION_ENABLED and not (
model.endswith("-search") or "-thinking" in model
) and not _has_image_parts(payload.get("contents", [])):
tools.append({"code_execution": {}})
if model.endswith("-search"):
tools.append({"googleSearch": {}})
if payload and isinstance(payload, dict) and "tools" in payload:
items = payload.get("tools", [])
if items and isinstance(items, list):
tools.extend(items)
return tools
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
"""获取安全设置"""
if model == "gemini-2.0-flash-exp":
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"}
]
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}
]
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
"""构建请求payload"""
payload = request.model_dump()
return {
"contents": payload.get("contents", []),
"tools": _build_tools(model, payload),
"safetySettings": _get_safety_settings(model),
"generationConfig": payload.get("generationConfig", {}),
"systemInstruction": payload.get("systemInstruction", [])
}
class GeminiChatService:
"""聊天服务"""
def __init__(self, base_url: str, key_manager: KeyManager):
self.api_client = GeminiApiClient(base_url)
self.key_manager = key_manager
self.response_handler = GeminiResponseHandler()
def generate_content(self, model: str, request: GeminiRequest, api_key: str) -> Dict[str, Any]:
"""生成内容"""
payload = _build_payload(model, request)
response = self.api_client.generate_content(payload, model, api_key)
return self.response_handler.handle_response(response, model, stream=False)
async def stream_generate_content(self, model: str, request: GeminiRequest, api_key: str) -> AsyncGenerator[str, None]:
"""流式生成内容"""
retries = 0
max_retries = 3
payload = _build_payload(model, request)
while retries < max_retries:
try:
async for line in self.api_client.stream_generate_content(payload, model, api_key):
# print(line)
if line.startswith("data:"):
line = line[6:]
line = json.dumps(self.response_handler.handle_response(json.loads(line), model, stream=True))
yield "data: " + line + "\n\n"
logger.info("Streaming completed successfully")
break
except Exception as e:
retries += 1
logger.warning(f"Streaming API call failed with error: {str(e)}. Attempt {retries} of {max_retries}")
api_key = await self.key_manager.handle_api_failure(api_key)
logger.info(f"Switched to new API key: {api_key}")
if retries >= max_retries:
logger.error(f"Max retries ({max_retries}) reached for streaming. Raising error")
break

View File

@@ -1,65 +0,0 @@
import requests
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from app.core.logger import get_model_logger
from app.core.config import settings
logger = get_model_logger()
class ModelService:
def __init__(self, model_search: list):
self.model_search = model_search
self.base_url = "https://generativelanguage.googleapis.com/v1beta"
def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
url = f"{self.base_url}/models?key={api_key}"
try:
response = requests.get(url)
if response.status_code == 200:
gemini_models = response.json()
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
def get_gemini_openai_models(self, api_key: str) -> Optional[Dict[str, Any]]:
try:
gemini_models = self.get_gemini_models(api_key)
return self.convert_to_openai_models_format(gemini_models)
except requests.RequestException as e:
logger.error(f"Request failed: {e}")
return None
def convert_to_openai_models_format(
self, gemini_models: Dict[str, Any]
) -> Dict[str, Any]:
openai_format = {"object": "list", "data": [], "success": True}
for model in gemini_models.get("models", []):
model_id = model["name"].split("/")[-1]
openai_model = {
"id": model_id,
"object": "model",
"created": int(datetime.now(timezone.utc).timestamp()),
"owned_by": "google",
"permission": [],
"root": model["name"],
"parent": None,
}
openai_format["data"].append(openai_model)
if model_id in self.model_search:
search_model = openai_model.copy()
search_model["id"] = f"{model_id}-search"
openai_format["data"].append(search_model)
if settings.CREATE_IMAGE_MODEL:
image_model = openai_model.copy()
image_model["id"] = f"{settings.CREATE_IMAGE_MODEL}-chat"
openai_format["data"].append(image_model)
return openai_format

View File

@@ -1,212 +0,0 @@
# app/services/chat_service.py
from copy import deepcopy
import json
from typing import Dict, Any, AsyncGenerator, List, Union
from app.core.logger import get_openai_logger
from app.services.chat.message_converter import OpenAIMessageConverter
from app.services.chat.response_handler import OpenAIResponseHandler
from app.services.chat.api_client import GeminiApiClient
from app.schemas.openai_models import ChatRequest, ImageGenerationRequest
from app.core.config import settings
from app.services.image_create_service import ImageCreateService
from app.services.key_manager import KeyManager
logger = get_openai_logger()
def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片部分"""
for content in contents:
if "parts" in content:
for part in content["parts"]:
if "image_url" in part or "inline_data" in part:
return True
return False
def _build_tools(
request: ChatRequest, messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""构建工具"""
tools = []
model = request.model
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (model.endswith("-search") or "-thinking" in model)
and not _has_image_parts(messages)
):
tools.append({"code_execution": {}})
if model.endswith("-search"):
tools.append({"googleSearch": {}})
# 将 request 中的 tools 合并到 tools 中
if request.tools:
function_declarations = []
for tool in request.tools:
if not tool or not isinstance(tool, dict):
continue
if tool.get("type", "") == "function" and tool.get("function"):
function = deepcopy(tool.get("function"))
parameters = function.get("parameters", {})
if parameters.get("type") == "object" and not parameters.get("properties", {}):
function.pop("parameters", None)
function_declarations.append(function)
if function_declarations:
tools.append({"functionDeclarations": function_declarations})
return tools
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
"""获取安全设置"""
# if (
# "2.0" in model
# and "gemini-2.0-flash-thinking-exp" not in model
# and "gemini-2.0-pro-exp" not in model
# ):
if model == "gemini-2.0-flash-exp":
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
]
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
]
def _build_payload(
request: ChatRequest, messages: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""构建请求payload"""
return {
"contents": messages,
"generationConfig": {
"temperature": request.temperature,
"maxOutputTokens": request.max_tokens,
"stopSequences": request.stop,
"topP": request.top_p,
"topK": request.top_k,
},
"tools": _build_tools(request, messages),
"safetySettings": _get_safety_settings(request.model),
}
class OpenAIChatService:
"""聊天服务"""
def __init__(self, base_url: str, key_manager: KeyManager = None):
self.message_converter = OpenAIMessageConverter()
self.response_handler = OpenAIResponseHandler(config=None)
self.api_client = GeminiApiClient(base_url)
self.key_manager = key_manager
self.image_create_service = ImageCreateService()
async def create_chat_completion(
self,
request: ChatRequest,
api_key: str,
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
# 转换消息格式
messages = self.message_converter.convert(request.messages)
# 构建请求payload
payload = _build_payload(request, messages)
if request.stream:
return self._handle_stream_completion(request.model, payload, api_key)
return self._handle_normal_completion(request.model, payload, api_key)
def _handle_normal_completion(
self, model: str, payload: Dict[str, Any], api_key: str
) -> Dict[str, Any]:
"""处理普通聊天完成"""
response = self.api_client.generate_content(payload, model, api_key)
return self.response_handler.handle_response(
response, model, stream=False, finish_reason="stop"
)
async def _handle_stream_completion(
self, model: str, payload: Dict[str, Any], api_key: str
) -> AsyncGenerator[str, None]:
"""处理流式聊天完成,添加重试逻辑"""
retries = 0
max_retries = 3
while retries < max_retries:
try:
async for line in self.api_client.stream_generate_content(
payload, model, api_key
):
# print(line)
if line.startswith("data:"):
chunk = json.loads(line[6:])
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None
)
if openai_chunk:
yield f"data: {json.dumps(openai_chunk)}\n\n"
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
logger.info("Streaming completed successfully")
break # 成功后退出循环
except Exception as e:
retries += 1
logger.warning(
f"Streaming API call failed with error: {str(e)}. Attempt {retries} of {max_retries}"
)
api_key = await self.key_manager.handle_api_failure(api_key)
logger.info(f"Switched to new API key: {api_key}")
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming. Raising error"
)
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"
break
async def create_image_chat_completion(
self,
request: ChatRequest,
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
image_generate_request = ImageGenerationRequest()
image_generate_request.prompt = request.messages[-1]["content"]
image_res = self.image_create_service.generate_images_chat(image_generate_request)
if request.stream:
return self._handle_stream_image_completion(request.model,image_res)
else:
return self._handle_normal_image_completion(request.model,image_res)
async def _handle_stream_image_completion(
self, model: str, image_data: str
) -> AsyncGenerator[str, None]:
if image_data:
openai_chunk = self.response_handler.handle_image_chat_response(
image_data, model, stream=True, finish_reason=None
)
if openai_chunk:
yield f"data: {json.dumps(openai_chunk)}\n\n"
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
logger.info("Image chat streaming completed successfully")
def _handle_normal_image_completion(
self, model: str, image_data: str
) -> Dict[str, Any]:
return self.response_handler.handle_image_chat_response(
image_data, model, stream=False, finish_reason="stop"
)

View File

@@ -1,249 +0,0 @@
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
max-width: 400px;
width: 90%;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
backdrop-filter: blur(10px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.container:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
}
.logo {
text-align: center;
margin-bottom: 30px;
animation: fadeIn 1s ease;
}
.logo i {
font-size: 48px;
color: #764ba2;
margin-bottom: 15px;
}
h2 {
color: #2c3e50;
text-align: center;
margin-bottom: 30px;
font-weight: 700;
font-size: 24px;
animation: slideDown 0.5s ease;
}
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.input-group {
position: relative;
animation: slideUp 0.5s ease;
}
.input-group i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #764ba2;
font-size: 18px;
}
input {
width: 100%;
padding: 12px 12px 12px 40px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 16px;
box-sizing: border-box;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.9);
}
input:focus {
border-color: #764ba2;
box-shadow: 0 0 10px rgba(118, 75, 162, 0.2);
outline: none;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 14px;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
}
button:active {
transform: translateY(0);
}
button::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
button:active::after {
width: 200px;
height: 200px;
opacity: 0;
}
.error-message {
color: #e74c3c;
margin-top: 15px;
text-align: center;
font-weight: bold;
padding: 10px;
border-radius: 5px;
background: rgba(231, 76, 60, 0.1);
animation: shake 0.5s ease;
}
.copyright {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255, 255, 255, 0.9);
padding: 10px 0;
text-align: center;
font-size: 14px;
color: #2c3e50;
backdrop-filter: blur(5px);
border-top: 1px solid rgba(0,0,0,0.1);
}
.copyright a {
color: #764ba2;
text-decoration: none;
transition: color 0.3s ease;
}
.copyright a:hover {
color: #667eea;
}
.copyright img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
margin-right: 5px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
@media (max-width: 768px) {
.container {
width: 85%;
padding: 30px;
}
.logo i {
font-size: 40px;
}
h2 {
font-size: 22px;
}
input {
padding: 10px 10px 10px 35px;
font-size: 15px;
}
.input-group i {
font-size: 16px;
}
button {
padding: 12px;
font-size: 15px;
}
}
@media (max-width: 480px) {
.container {
width: 90%;
padding: 25px;
}
.logo i {
font-size: 36px;
}
h2 {
font-size: 20px;
margin-bottom: 25px;
}
form {
gap: 15px;
}
input {
padding: 10px 10px 10px 32px;
font-size: 14px;
}
.input-group i {
font-size: 15px;
left: 10px;
}
button {
padding: 10px;
font-size: 14px;
}
.error-message {
font-size: 14px;
padding: 8px;
margin-top: 12px;
}
}

View File

@@ -1,461 +0,0 @@
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 900px;
width: 95%;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
backdrop-filter: blur(10px);
position: relative;
margin: 20px auto;
overflow-y: auto;
max-height: calc(100vh - 40px);
scrollbar-width: none;
-ms-overflow-style: none;
}
.container::-webkit-scrollbar {
display: none;
}
h1 {
color: #2c3e50;
text-align: center;
margin-bottom: 30px;
font-weight: 700;
font-size: 32px;
position: relative;
padding-bottom: 15px;
}
h1::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
.key-list {
margin-bottom: 30px;
background: rgba(248, 249, 250, 0.9);
padding: 25px;
border-radius: 15px;
transition: all 0.3s ease;
border: 1px solid rgba(0,0,0,0.1);
animation: fadeIn 0.5s ease forwards;
}
.key-list:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.key-list:nth-child(2) {
animation-delay: 0.2s;
}
.key-list h2 {
color: #2c3e50;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.5em;
padding-bottom: 10px;
border-bottom: 2px solid rgba(0,0,0,0.1);
cursor: pointer;
}
.key-list h2 .toggle-icon {
margin-right: 10px;
transition: transform 0.3s ease;
}
.key-list h2 .toggle-icon.collapsed {
transform: rotate(-90deg);
}
.key-list .key-content {
transition: all 0.3s ease-out;
overflow: hidden;
height: auto;
opacity: 1;
}
.key-list .key-content.collapsed {
height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
li {
background: white;
border: 1px solid rgba(0,0,0,0.1);
margin-bottom: 12px;
padding: 15px;
border-radius: 10px;
transition: all 0.3s ease;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
li:hover {
transform: translateX(5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.key-info {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.key-text {
font-family: 'Roboto Mono', monospace;
color: #2c3e50;
}
.fail-count {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
padding: 4px 10px;
border-radius: 15px;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 5px;
}
.fail-count i {
font-size: 12px;
}
.key-actions {
display: flex;
gap: 10px;
align-items: center;
}
.verify-btn, .copy-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 8px 15px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 5px;
}
.verify-btn {
background: linear-gradient(135deg, #2ecc71, #27ae60);
}
.verify-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.3);
}
.verify-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.verify-btn i {
font-size: 14px;
}
.copy-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
}
.copy-btn:active {
transform: translateY(0);
}
.copy-btn i {
font-size: 14px;
}
.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 25px;
border-radius: 10px;
font-weight: bold;
text-align: center;
font-size: 1.2em;
margin-top: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
#copyStatus {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 15px 30px;
border-radius: 25px;
font-weight: bold;
opacity: 0;
transition: all 0.3s ease;
backdrop-filter: blur(5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
z-index: 1000;
text-align: center;
min-width: 200px;
color: white;
}
#copyStatus.success {
background: rgba(39, 174, 96, 0.95);
}
#copyStatus.error {
background: rgba(231, 76, 60, 0.95);
}
.status-badge {
padding: 4px 12px;
border-radius: 15px;
font-size: 0.9em;
font-weight: bold;
margin-right: 10px;
}
.status-valid {
background: rgba(39, 174, 96, 0.1);
color: #27ae60;
}
.status-invalid {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
.scroll-buttons {
position: fixed;
right: 20px;
bottom: 20px;
display: none;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
.scroll-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.3s ease;
backdrop-filter: blur(5px);
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.scroll-btn:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: scale(1.1);
}
.scroll-btn:active {
transform: scale(0.95);
}
.refresh-btn {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 25px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.refresh-btn:hover {
transform: scale(1.05);
box-shadow: 0 8px 20px rgba(118, 75, 162, 0.3);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
.refresh-btn:active {
transform: scale(0.95);
}
.refresh-btn i {
transition: transform 0.5s ease;
}
.refresh-btn.loading i {
animation: spin 1s linear infinite;
}
.copyright {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255, 255, 255, 0.9);
padding: 10px 0;
text-align: center;
font-size: 14px;
color: #2c3e50;
backdrop-filter: blur(5px);
border-top: 1px solid rgba(0,0,0,0.1);
}
.copyright a {
color: #764ba2;
text-decoration: none;
transition: color 0.3s ease;
}
.copyright a:hover {
color: #667eea;
}
.copyright img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
margin-right: 5px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.container {
width: 100%;
padding: 20px;
margin: 10px auto;
}
body {
padding: 10px;
}
h1 {
font-size: 24px;
}
.key-list h2 {
font-size: 1.2em;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.key-info {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
li {
flex-direction: column;
gap: 10px;
}
.key-actions {
width: 100%;
flex-direction: column;
}
.verify-btn, .copy-btn {
width: 100%;
justify-content: center;
}
.key-text {
word-break: break-all;
}
.scroll-buttons {
right: 10px;
bottom: 10px;
}
.scroll-btn {
width: 35px;
height: 35px;
font-size: 16px;
}
.refresh-btn {
top: 10px;
right: 10px;
padding: 8px 16px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.container {
padding: 15px;
}
h1 {
font-size: 20px;
}
.key-list {
padding: 15px;
}
.status-badge {
padding: 3px 8px;
font-size: 0.8em;
}
.fail-count {
font-size: 0.8em;
}
.total {
font-size: 1em;
padding: 12px 20px;
}
}

BIN
app/static/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
app/static/icons/logo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,18 +0,0 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('ServiceWorker注册成功:', registration.scope);
})
.catch(error => {
console.log('ServiceWorker注册失败:', error);
});
});
}
document.addEventListener('DOMContentLoaded', () => {
const copyrightYear = document.querySelector('.copyright script');
if (copyrightYear) {
copyrightYear.textContent = new Date().getFullYear();
}
});

File diff suppressed because it is too large Load Diff

975
app/static/js/error_logs.js Normal file
View File

@@ -0,0 +1,975 @@
// 错误日志页面JavaScript (Updated for new structure, no Bootstrap)
// 页面滚动功能
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function scrollToBottom() {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
// Refresh function removed as the buttons are gone.
// If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs().
// 全局变量
let currentPage = 1;
let pageSize = 10;
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
let errorLogs = []; // Store fetched logs for details view
let currentSort = { // 新增:存储当前排序状态
field: 'id', // 默认按 ID 排序
order: 'desc' // 默认降序
};
let currentSearch = { // Store current search parameters
key: '',
error: '',
errorCode: '', // Added error code search
startDate: '',
endDate: ''
};
// DOM Elements Cache
let pageSizeSelector;
// let refreshBtn; // Removed, as the button is deleted
let tableBody;
let paginationElement;
let loadingIndicator;
let noDataMessage;
let errorMessage;
let logDetailModal;
let modalCloseBtns; // Collection of close buttons for the modal
let keySearchInput;
let errorSearchInput;
let errorCodeSearchInput; // Added error code input
let startDateInput;
let endDateInput;
let searchBtn;
let pageInput;
let goToPageBtn;
let selectAllCheckbox; // 新增:全选复选框
let copySelectedKeysBtn; // 新增:复制选中按钮
let deleteSelectedBtn; // 新增:批量删除按钮
let sortByIdHeader; // 新增ID 排序表头
let sortIcon; // 新增:排序图标
let selectedCountSpan; // 新增:选中计数显示
let deleteConfirmModal; // 新增:删除确认模态框
let closeDeleteConfirmModalBtn; // 新增:关闭删除模态框按钮
let cancelDeleteBtn; // 新增:取消删除按钮
let confirmDeleteBtn; // 新增:确认删除按钮
let deleteConfirmMessage; // 新增:删除确认消息元素
let idsToDeleteGlobally = []; // 新增存储待删除的ID
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
// Cache DOM elements
pageSizeSelector = document.getElementById('pageSize');
// refreshBtn = document.getElementById('refreshBtn'); // Removed
tableBody = document.getElementById('errorLogsTable');
paginationElement = document.getElementById('pagination');
loadingIndicator = document.getElementById('loadingIndicator');
noDataMessage = document.getElementById('noDataMessage');
errorMessage = document.getElementById('errorMessage');
logDetailModal = document.getElementById('logDetailModal');
// Get all elements that should close the modal
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
keySearchInput = document.getElementById('keySearch');
errorSearchInput = document.getElementById('errorSearch');
errorCodeSearchInput = document.getElementById('errorCodeSearch'); // Get error code input
startDateInput = document.getElementById('startDate');
endDateInput = document.getElementById('endDate');
searchBtn = document.getElementById('searchBtn');
pageInput = document.getElementById('pageInput');
goToPageBtn = document.getElementById('goToPageBtn');
selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增
copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增
deleteSelectedBtn = document.getElementById('deleteSelectedBtn'); // 新增
sortByIdHeader = document.getElementById('sortById'); // 新增
if (sortByIdHeader) {
sortIcon = sortByIdHeader.querySelector('i'); // 新增
}
selectedCountSpan = document.getElementById('selectedCount'); // 新增
deleteConfirmModal = document.getElementById('deleteConfirmModal'); // 新增
closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn'); // 新增
cancelDeleteBtn = document.getElementById('cancelDeleteBtn'); // 新增
confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); // 新增
deleteConfirmMessage = document.getElementById('deleteConfirmMessage'); // 新增
// Initialize page size selector
if (pageSizeSelector) {
pageSizeSelector.value = pageSize;
pageSizeSelector.addEventListener('change', function() {
pageSize = parseInt(this.value);
currentPage = 1; // Reset to first page
loadErrorLogs();
});
}
// Refresh button event listener removed
// Initialize search button
if (searchBtn) {
searchBtn.addEventListener('click', function() {
// Update search parameters from input fields
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
currentSearch.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; // Get error code value
currentSearch.startDate = startDateInput ? startDateInput.value : '';
currentSearch.endDate = endDateInput ? endDateInput.value : '';
currentPage = 1; // Reset to first page on new search
loadErrorLogs();
});
}
// Initialize modal close buttons
if (logDetailModal && modalCloseBtns) {
modalCloseBtns.forEach(btn => {
btn.addEventListener('click', closeLogDetailModal);
});
// Optional: Close modal if clicking outside the content
logDetailModal.addEventListener('click', function(event) {
if (event.target === logDetailModal) {
closeLogDetailModal();
}
});
}
// Initial load of error logs
loadErrorLogs();
// Add event listeners for copy buttons inside the modal and table
setupCopyButtons(); // This will now also handle table copy buttons if called after render
// Add event listeners for bulk selection
setupBulkSelectionListeners(); // 新增:设置批量选择监听器
// 新增:为页码跳转按钮添加事件监听器
if (goToPageBtn && pageInput) {
goToPageBtn.addEventListener('click', function() {
const targetPage = parseInt(pageInput.value);
// 需要获取总页数来验证输入
// 暂时无法直接获取 totalPages需要在 updatePagination 中存储或重新计算
// 简单的验证:必须是正整数
if (!isNaN(targetPage) && targetPage >= 1) {
// 理想情况下,应检查 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(); // 触发按钮点击
}
});
}
// 新增:为批量删除按钮添加事件监听器
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
}
// 新增:为 ID 排序表头添加事件监听器
if (sortByIdHeader) {
sortByIdHeader.addEventListener('click', handleSortById);
}
// 新增:为删除确认模态框按钮添加事件监听器
if (closeDeleteConfirmModalBtn) {
closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal);
}
if (cancelDeleteBtn) {
cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal);
}
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', handleConfirmDelete);
}
// Optional: Close modal if clicking outside the content
if (deleteConfirmModal) {
deleteConfirmModal.addEventListener('click', function(event) {
if (event.target === deleteConfirmModal) {
hideDeleteConfirmModal();
}
});
}
});
// 新增:显示删除确认模态框
function showDeleteConfirmModal(message) {
if (deleteConfirmModal && deleteConfirmMessage) {
deleteConfirmMessage.textContent = message;
deleteConfirmModal.classList.add('show');
document.body.style.overflow = 'hidden'; // Prevent body scrolling
}
}
// 新增:隐藏删除确认模态框
function hideDeleteConfirmModal() {
if (deleteConfirmModal) {
deleteConfirmModal.classList.remove('show');
document.body.style.overflow = ''; // Restore body scrolling
idsToDeleteGlobally = []; // 清空待删除ID
}
}
// 新增:处理确认删除按钮点击
function handleConfirmDelete() {
if (idsToDeleteGlobally.length > 0) {
performActualDelete(idsToDeleteGlobally);
}
hideDeleteConfirmModal(); // 关闭模态框
}
// Fallback copy function using document.execCommand
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
let successful = false;
try {
successful = document.execCommand('copy');
} catch (err) {
console.error('Fallback copy failed:', err);
successful = false;
}
document.body.removeChild(textArea);
return successful;
}
// Helper function to handle feedback after copy attempt (both modern and fallback)
function handleCopyResult(buttonElement, success) {
const originalIcon = buttonElement.querySelector('i').className; // Store original icon class
const iconElement = buttonElement.querySelector('i');
if (success) {
iconElement.className = 'fas fa-check text-success-500'; // Use checkmark icon class
showNotification('已复制到剪贴板', 'success', 2000);
} else {
iconElement.className = 'fas fa-times text-danger-500'; // Use error icon class
showNotification('复制失败', 'error', 3000);
}
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
}
// 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) {
let copySuccess = false;
// Try modern clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(textToCopy).then(() => {
handleCopyResult(button, true); // Use helper for feedback
}).catch(err => {
console.error('Clipboard API failed, attempting fallback:', err);
// Attempt fallback if modern API fails
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(button, copySuccess); // Use helper for feedback
});
} else {
// Use fallback if modern API is not available or context is insecure
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(button, copySuccess); // Use helper for feedback
}
} else {
console.warn('No text found to copy for target:', targetId || 'direct text');
showNotification('没有内容可复制', 'warning');
}
} // End of handleCopyButtonClick function
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
function setupCopyButtons(containerSelector = 'body') {
// Find buttons within the specified container (defaults to body)
const container = document.querySelector(containerSelector);
if (!container) return;
const copyButtons = container.querySelectorAll('.copy-btn');
copyButtons.forEach(button => {
// Remove existing listener to prevent duplicates if called multiple times
button.removeEventListener('click', handleCopyButtonClick);
// Add the listener
button.addEventListener('click', handleCopyButtonClick);
});
}
// 新增:设置批量选择相关的事件监听器
function setupBulkSelectionListeners() {
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', handleSelectAllChange);
}
if (tableBody) {
// 使用事件委托处理行复选框的点击
tableBody.addEventListener('change', handleRowCheckboxChange);
}
if (copySelectedKeysBtn) {
copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys);
}
// 新增:为批量删除按钮添加事件监听器 (如果尚未添加)
// 通常在 DOMContentLoaded 中添加一次即可
// if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) {
// deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
// deleteSelectedBtn.hasListener = true; // 标记已添加
// }
}
// 新增:处理“全选”复选框变化的函数
function handleSelectAllChange() {
const isChecked = selectAllCheckbox.checked;
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
rowCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
updateSelectedState();
}
// 新增:处理行复选框变化的函数 (事件委托)
function handleRowCheckboxChange(event) {
if (event.target.classList.contains('row-checkbox')) {
updateSelectedState();
}
}
// 新增:更新选中状态(计数、按钮状态、全选框状态)
function updateSelectedState() {
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
// 移除了数字显示不再更新selectedCountSpan
// 仍然更新复制按钮的禁用状态
if (copySelectedKeysBtn) {
copySelectedKeysBtn.disabled = selectedCount === 0;
// 可选:根据选中项数量更新按钮标题属性
copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`);
}
// 新增:更新批量删除按钮的禁用状态
if (deleteSelectedBtn) {
deleteSelectedBtn.disabled = selectedCount === 0;
deleteSelectedBtn.setAttribute('title', `删除${selectedCount}项选中日志`);
}
// 更新“全选”复选框的状态
if (selectAllCheckbox) {
if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else if (selectedCount > 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true; // 部分选中状态
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
}
}
// 新增:处理“复制选中密钥”按钮点击的函数
function handleCopySelectedKeys() {
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const keysToCopy = [];
selectedCheckboxes.forEach(checkbox => {
const key = checkbox.getAttribute('data-key');
if (key) {
keysToCopy.push(key);
}
});
if (keysToCopy.length > 0) {
const textToCopy = keysToCopy.join('\n'); // 每行一个密钥
copyTextToClipboard(textToCopy, copySelectedKeysBtn); // 使用通用复制函数
} else {
showNotification('没有选中的密钥可复制', 'warning');
}
}
// 新增:通用的文本复制函数(结合现有逻辑)
function copyTextToClipboard(text, buttonElement = null) {
let copySuccess = false;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
if (buttonElement) handleCopyResult(buttonElement, true);
else showNotification('已复制到剪贴板', 'success');
}).catch(err => {
console.error('Clipboard API failed, attempting fallback:', err);
copySuccess = fallbackCopyTextToClipboard(text);
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
});
} else {
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
copySuccess = fallbackCopyTextToClipboard(text);
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
}
}
// 修改:处理批量删除按钮点击的函数 - 改为显示模态框
function handleDeleteSelected() {
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const logIdsToDelete = [];
selectedCheckboxes.forEach(checkbox => {
const logId = checkbox.getAttribute('data-log-id'); // 需要在渲染时添加 data-log-id
if (logId) {
logIdsToDelete.push(parseInt(logId));
}
});
if (logIdsToDelete.length === 0) {
showNotification('没有选中的日志可删除', 'warning');
return;
}
if (logIdsToDelete.length === 0) {
showNotification('没有选中的日志可删除', 'warning');
return;
}
// 存储待删除ID并显示模态框
idsToDeleteGlobally = logIdsToDelete;
const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`;
showDeleteConfirmModal(message);
}
// 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow
async function performActualDelete(logIds) {
if (!logIds || logIds.length === 0) return;
const isSingleDelete = logIds.length === 1;
const url = isSingleDelete ? `/api/logs/errors/${logIds[0]}` : '/api/logs/errors';
const method = 'DELETE';
const body = isSingleDelete ? null : JSON.stringify({ ids: logIds });
const headers = isSingleDelete ? {} : { 'Content-Type': 'application/json' };
try {
// Rename 'response' to 'deleteResponse' and remove duplicate fetch
const deleteResponse = await fetch(url, {
method: method,
headers: headers,
body: body,
});
// Removed duplicate fetch call below
if (!deleteResponse.ok) {
let errorData;
try { errorData = await deleteResponse.json(); } catch (e) { /* ignore */ }
const actionText = isSingleDelete ? `删除该条日志` : `批量删除 ${logIds.length} 条日志`;
throw new Error(errorData?.detail || `${actionText}失败: ${deleteResponse.statusText}`);
}
const successMessage = isSingleDelete ? `成功删除该日志` : `成功删除 ${logIds.length} 条日志`;
showNotification(successMessage, 'success');
// 取消全选
if (selectAllCheckbox) selectAllCheckbox.checked = false;
// 重新加载当前页数据
loadErrorLogs();
} catch (error) {
console.error('批量删除错误日志失败:', error);
showNotification(`批量删除失败: ${error.message}`, 'error', 5000);
}
}
// 修改:处理单行删除按钮点击的函数 - 改为显示模态框
function handleDeleteLogRow(logId) {
if (!logId) return;
// 存储待删除ID并显示模态框
idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组
// 使用通用确认消息不显示具体ID
const message = `确定要删除这条日志吗?此操作不可恢复!`;
showDeleteConfirmModal(message);
}
// 新增:处理 ID 排序点击的函数
function handleSortById() {
if (currentSort.field === 'id') {
// 如果当前是按 ID 排序,切换顺序
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
} else {
// 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序
currentSort.field = 'id';
currentSort.order = 'desc';
}
// 更新图标
updateSortIcon();
// 重新加载第一页数据
currentPage = 1;
loadErrorLogs();
}
// 新增:更新排序图标的函数
function updateSortIcon() {
if (!sortIcon) return;
// 移除所有可能的排序类
sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600');
if (currentSort.field === 'id') {
sortIcon.classList.add(currentSort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
sortIcon.classList.add('text-primary-600'); // 高亮显示
} else {
// 如果不是按 ID 排序,显示默认图标
sortIcon.classList.add('fa-sort', 'text-gray-400');
}
}
// 加载错误日志数据
async function loadErrorLogs() {
// 重置选择状态
if (selectAllCheckbox) selectAllCheckbox.checked = false;
if (selectAllCheckbox) selectAllCheckbox.indeterminate = false;
updateSelectedState(); // 更新按钮状态和计数
showLoading(true);
showError(false);
showNoData(false);
const offset = (currentPage - 1) * pageSize;
try {
// Construct the API URL with search and sort parameters
let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`;
// 添加排序参数
apiUrl += `&sort_by=${currentSort.field}&sort_order=${currentSort.order}`;
// 添加搜索参数
if (currentSearch.key) {
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
}
if (currentSearch.error) {
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
}
if (currentSearch.errorCode) { // Add error code to API request
apiUrl += `&error_code_search=${encodeURIComponent(currentSearch.errorCode)}`;
}
if (currentSearch.startDate) {
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
}
if (currentSearch.endDate) {
apiUrl += `&end_date=${encodeURIComponent(currentSearch.endDate)}`;
}
const response = await fetch(apiUrl);
if (!response.ok) {
// 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 }
if (data && Array.isArray(data.logs)) {
errorLogs = data.logs; // Store the list data (contains error_code)
renderErrorLogs(errorLogs);
updatePagination(errorLogs.length, data.total || -1);
} else {
throw new Error('无法识别的API响应格式');
}
showLoading(false);
if (errorLogs.length === 0) {
showNoData(true);
}
} catch (error) {
console.error('获取错误日志失败:', error);
showLoading(false);
showError(true, error.message); // Show specific error message
}
}
// 渲染错误日志表格
function renderErrorLogs(logs) {
if (!tableBody) return;
tableBody.innerHTML = ''; // Clear previous entries
// 重置全选复选框状态(在清空表格后)
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
if (!logs || logs.length === 0) {
// Handled by showNoData
return;
}
const startIndex = (currentPage - 1) * pageSize; // Calculate starting index for the current page
logs.forEach((log, index) => { // Add index parameter to forEach
const row = document.createElement('tr');
const sequentialId = startIndex + index + 1; // Calculate sequential ID for the current page
// 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);
const fullKey = log.gemini_key || ''; // Store the full key
row.innerHTML = `
<td class="text-center px-3 py-3"> <!-- Checkbox column -->
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}" data-log-id="${log.id}"> <!-- 添加 data-log-id -->
</td>
<td>${sequentialId}</td> <!-- 显示从1开始的序号 -->
<td class="relative group" title="${fullKey}"> <!-- Added relative/group for button positioning -->
${maskedKey}
<!-- Added copy button for the key in the table row -->
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${log.gemini_key || ''}" title="复制完整密钥">
<i class="far fa-copy"></i>
</button>
</td>
<td>${log.error_type || '未知'}</td>
<td class="error-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}"> <!-- 添加 mr-2 -->
<i class="fas fa-eye mr-1"></i>详情
</button>
<button class="btn-delete-row text-danger-600 hover:text-danger-800" data-log-id="${log.id}" title="删除此日志">
<i class="fas fa-trash-alt"></i>
</button>
</td>
`;
tableBody.appendChild(row);
});
// Add event listeners to new 'View Details' buttons
document.querySelectorAll('.btn-view-details').forEach(button => {
button.addEventListener('click', function() {
const logId = parseInt(this.getAttribute('data-log-id'));
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 获取)
async function showLogDetails(logId) {
if (!logDetailModal) return;
// Show loading state in modal (optional)
// Clear previous content and show a spinner or message
document.getElementById('modalGeminiKey').textContent = '加载中...';
document.getElementById('modalErrorType').textContent = '加载中...';
document.getElementById('modalErrorLog').textContent = '加载中...';
document.getElementById('modalRequestMsg').textContent = '加载中...';
document.getElementById('modalModelName').textContent = '加载中...';
document.getElementById('modalRequestTime').textContent = '加载中...';
logDetailModal.classList.add('show');
document.body.style.overflow = 'hidden'; // Prevent body scrolling
try {
const response = await fetch(`/api/logs/errors/${logId}/details`);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) { /* ignore */ }
throw new Error(errorData?.detail || `获取日志详情失败: ${response.statusText}`);
}
const logDetails = await response.json();
// Format date
let formattedTime = 'N/A';
try {
const requestTime = new Date(logDetails.request_time);
if (!isNaN(requestTime)) {
formattedTime = requestTime.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
}
} catch (e) { console.error("Error formatting date:", e); }
// Format request message (handle potential JSON)
let formattedRequestMsg = '无';
if (logDetails.request_msg) {
try {
if (typeof logDetails.request_msg === 'object' && logDetails.request_msg !== null) {
formattedRequestMsg = JSON.stringify(logDetails.request_msg, null, 2);
} else if (typeof logDetails.request_msg === 'string') {
// Try parsing if it looks like JSON, otherwise display as string
const trimmedMsg = logDetails.request_msg.trim();
if (trimmedMsg.startsWith('{') || trimmedMsg.startsWith('[')) {
formattedRequestMsg = JSON.stringify(JSON.parse(logDetails.request_msg), null, 2);
} else {
formattedRequestMsg = logDetails.request_msg;
}
} else {
formattedRequestMsg = String(logDetails.request_msg);
}
} catch (e) {
formattedRequestMsg = String(logDetails.request_msg); // Fallback
console.warn("Could not parse request_msg as JSON:", e);
}
}
// Populate modal content with fetched details
document.getElementById('modalGeminiKey').textContent = logDetails.gemini_key || '无';
document.getElementById('modalErrorType').textContent = logDetails.error_type || '未知';
document.getElementById('modalErrorLog').textContent = logDetails.error_log || '无'; // Full error log
document.getElementById('modalRequestMsg').textContent = formattedRequestMsg; // Full request message
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
document.getElementById('modalRequestTime').textContent = formattedTime;
// Re-initialize copy buttons specifically for the modal after content is loaded
setupCopyButtons('#logDetailModal');
} catch (error) {
console.error('获取日志详情失败:', error);
// Show error in modal
document.getElementById('modalGeminiKey').textContent = '错误';
document.getElementById('modalErrorType').textContent = '错误';
document.getElementById('modalErrorLog').textContent = `加载失败: ${error.message}`;
document.getElementById('modalRequestMsg').textContent = '错误';
document.getElementById('modalModelName').textContent = '错误';
document.getElementById('modalRequestTime').textContent = '错误';
// Optionally show a notification
showNotification(`加载日志详情失败: ${error.message}`, 'error', 5000);
}
}
// Close Log Detail Modal
function closeLogDetailModal() {
if (logDetailModal) {
logDetailModal.classList.remove('show');
// Optional: Restore body scrolling
document.body.style.overflow = '';
}
}
// 更新分页控件
function updatePagination(currentItemCount, totalItems) {
if (!paginationElement) return;
paginationElement.innerHTML = ''; // Clear existing pagination
// Calculate total pages only if totalItems is known and valid
let totalPages = 1;
if (totalItems >= 0) {
totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
} else if (currentItemCount < pageSize && currentPage === 1) {
// If less items than page size fetched on page 1, assume it's the only page
totalPages = 1;
} else {
// If total is unknown and more items might exist, we can't build full pagination
// We can show Prev/Next based on current page and if items were returned
console.warn("Total item count unknown, pagination will be limited.");
// Basic Prev/Next for unknown total
addPaginationLink(paginationElement, '&laquo;', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
addPaginationLink(paginationElement, currentPage.toString(), true, null, true); // Current page number (non-clickable)
addPaginationLink(paginationElement, '&raquo;', currentItemCount === pageSize, () => { currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned
return; // Exit here for limited pagination
}
const maxPagesToShow = 5; // Max number of page links to show
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
// Adjust startPage if endPage reaches the limit first
if (endPage === totalPages) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
// Previous Button
addPaginationLink(paginationElement, '&laquo;', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
// First Page Button
if (startPage > 1) {
addPaginationLink(paginationElement, '1', true, () => { currentPage = 1; loadErrorLogs(); });
if (startPage > 2) {
addPaginationLink(paginationElement, '...', false); // Ellipsis
}
}
// Page Number Buttons
for (let i = startPage; i <= endPage; i++) {
addPaginationLink(paginationElement, i.toString(), true, () => { currentPage = i; loadErrorLogs(); }, i === currentPage);
}
// Last Page Button
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
addPaginationLink(paginationElement, '...', false); // Ellipsis
}
addPaginationLink(paginationElement, totalPages.toString(), true, () => { currentPage = totalPages; loadErrorLogs(); });
}
// Next Button
addPaginationLink(paginationElement, '&raquo;', currentPage < totalPages, () => { currentPage++; loadErrorLogs(); });
}
// Helper function to add pagination links
function addPaginationLink(parentElement, text, enabled, clickHandler, isActive = false) {
const pageItem = document.createElement('li');
// 移除 'page-item' 和 'active' 类,使用 Tailwind 类进行样式化
// pageItem.className = `page-item ${!enabled ? 'disabled' : ''} ${isActive ? 'active' : ''}`;
const pageLink = document.createElement('a');
// 使用 Tailwind 类进行样式化
pageLink.className = `px-3 py-1 rounded-md text-sm transition duration-150 ease-in-out ${
isActive
? 'bg-primary-600 text-white font-semibold shadow-md cursor-default' // 突出当前页样式
: enabled
? 'bg-white text-gray-700 hover:bg-primary-50 hover:text-primary-600 border border-gray-300' // 可点击页码样式
: 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200' // 禁用状态样式 (如 '...')
}`;
pageLink.href = '#'; // Prevent page jump
pageLink.innerHTML = text;
if (enabled && clickHandler) {
pageLink.addEventListener('click', function(e) {
e.preventDefault();
clickHandler();
});
} else if (!enabled) {
pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on disabled or active
} else if (isActive) {
pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on active page
}
// 不再需要 li 元素,直接将 a 元素添加到父元素
// pageItem.appendChild(pageLink);
parentElement.appendChild(pageLink);
}
// 显示/隐藏状态指示器 (using 'active' class)
function showLoading(show) {
if (loadingIndicator) loadingIndicator.style.display = show ? 'block' : 'none';
}
function showNoData(show) {
if (noDataMessage) noDataMessage.style.display = show ? 'block' : 'none';
}
function showError(show, message = '加载错误日志失败,请稍后重试。') {
if (errorMessage) {
errorMessage.style.display = show ? 'block' : 'none';
if (show) {
// Update the error message content
const p = errorMessage.querySelector('p');
if (p) p.textContent = message;
}
}
}
// Function to show temporary status notifications (like copy success)
function showNotification(message, type = 'success', duration = 3000) {
const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html
if (!notificationElement) {
console.error("Notification element with ID 'notification' not found.");
return;
}
// Set message and type class
notificationElement.textContent = message;
// Remove previous type classes before adding the new one
notificationElement.classList.remove('success', 'error', 'warning', 'info');
notificationElement.classList.add(type); // Add the type class for styling
notificationElement.className = `notification ${type} show`; // Add 'show' class
// Hide after duration
setTimeout(() => {
notificationElement.classList.remove('show');
}, duration);
}
// Example Usage (if copy functionality is added later):
// showNotification('密钥已复制!', 'success');
// showNotification('复制失败!', 'error');

File diff suppressed because it is too large Load Diff

View File

@@ -17,13 +17,27 @@ self.addEventListener('install', event => {
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
})
caches.open(CACHE_NAME).then(cache => {
// 1. 尝试从缓存获取
return cache.match(event.request).then(responseFromCache => {
// 2. 同时从网络获取 (后台进行)
const fetchPromise = fetch(event.request).then(responseFromNetwork => {
// 3. 网络请求成功,更新缓存
cache.put(event.request, responseFromNetwork.clone());
return responseFromNetwork;
}).catch(err => {
// 网络请求失败时,可以选择记录错误或不执行任何操作
console.error('Network fetch failed:', err);
// 确保即使网络失败,如果缓存存在,我们仍然返回缓存
// 如果缓存也不存在,则此 Promise 会 reject
throw err;
});
// 4. 如果缓存存在,立即返回缓存;否则等待网络响应
// 后台的网络请求仍在进行,用于更新缓存
return responseFromCache || fetchPromise;
});
})
);
});

View File

@@ -1,42 +1,124 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证页面</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#764ba2">
<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-title" content="GBalance">
<link rel="icon" href="/static/icons/icon-192x192.png">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/auth.css">
</head>
<body>
<div class="container">
<div class="logo">
<i class="fas fa-shield-alt"></i>
</div>
<h2>安全验证</h2>
<form id="auth-form" action="/auth" method="post">
<div class="input-group">
<i class="fas fa-key"></i>
<input type="password" id="auth-token" name="auth_token" required placeholder="请输入验证令牌">
{% extends "base.html" %}
{% block title %}验证页面 - Gemini Balance{% endblock %}
{% block head_extra_styles %}
<style>
/* auth.html specific styles */
.auth-glass-card { /* Renamed to avoid conflict if base.html has .glass-card */
background: rgba(255, 255, 255, 0.85); /* Increased opacity */
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.auth-bg-gradient { /* Renamed to avoid conflict if base.html has .bg-gradient */
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
}
/* .input-icon class removed, using direct Tailwind classes now */
/* Keep button ripple effect if needed, or remove if base provides similar */
.auth-button { /* Renamed to avoid conflict */
position: relative;
overflow: hidden;
}
.auth-button:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.auth-button:active:after {
width: 300px;
height: 300px;
opacity: 0;
}
</style>
{% endblock %}
{% block content %}
<div class="auth-bg-gradient min-h-screen flex flex-col justify-center items-center p-4">
<div class="glass-card rounded-2xl shadow-2xl p-10 max-w-md w-full mx-auto transform transition duration-500 hover:-translate-y-1 hover:shadow-3xl animate-fade-in">
<div class="flex justify-center mb-8 animate-slide-down">
<div class="rounded-full bg-primary-100 p-4 text-primary-600">
<i class="fas fa-shield-alt text-4xl"></i>
</div>
<button type="submit">
验证访问
</div>
<h2 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-8 animate-slide-down">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance
</h2>
<form id="auth-form" action="/auth" method="post" class="space-y-6 animate-slide-up">
<div class="relative">
<i class="fas fa-key absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500"></i>
<input
type="password"
id="auth-token"
name="auth_token"
required
placeholder="请输入验证令牌"
class="w-full pl-10 pr-4 py-4 rounded-xl border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 transition duration-300 bg-white bg-opacity-90 text-gray-700"
>
</div>
<button
type="submit"
class="w-full py-4 rounded-xl bg-gradient-to-r from-primary-600 to-primary-700 text-white font-semibold transition duration-300 transform hover:-translate-y-1 hover:shadow-lg"
>
登录
</button>
</form>
{% if error %}
<p class="error-message">{{ error }}</p>
<p class="mt-4 text-red-500 text-center font-medium p-3 bg-red-50 rounded-lg border border-red-200 animate-shake">
{{ error }}
</p>
{% endif %}
</div>
<div class="copyright">
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
</div>
<script src="/static/js/auth.js"></script>
</body>
</html>
</div> <!-- Close auth-bg-gradient div -->
<!-- Notification placeholder for base.html's showNotification -->
<div id="notification" class="notification"></div>
{% endblock %}
{% block body_scripts %}
<script>
// auth.html specific JavaScript
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('auth-form');
if (form) {
form.addEventListener('submit', function(e) {
const token = document.getElementById('auth-token').value.trim();
if (!token) {
e.preventDefault();
// Use the base notification system
showNotification('请输入验证令牌', 'error');
}
});
}
// Apply renamed classes
document.querySelectorAll('button[type="submit"]').forEach(button => {
button.classList.add('auth-button');
});
const card = document.querySelector('.auth-glass-card'); // Find the renamed card
if (card) {
// If the base template also defines .glass-card, remove it first
// card.classList.remove('glass-card');
} else {
// If the card wasn't found by the new name, try the old name and rename
const oldCard = document.querySelector('.glass-card');
if (oldCard) {
oldCard.classList.remove('glass-card');
oldCard.classList.add('auth-glass-card');
}
}
});
</script>
{% endblock %}

316
app/templates/base.html Normal file
View File

@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Gemini Balance{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#4F46E5">
<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-title" content="GBalance">
<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 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>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
},
success: {
50: '#ecfdf5',
500: '#10b981',
600: '#059669'
},
danger: {
50: '#fef2f2',
500: '#ef4444',
600: '#dc2626'
}
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.5s ease-out',
'slide-down': 'slideDown 0.5s ease-out',
'shake': 'shake 0.5s ease-in-out',
'spin': 'spin 1s linear infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-5px)' },
'75%': { transform: 'translateX(5px)' },
},
spin: {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
},
}
}
}
</script>
<style>
.glass-card {
background: rgba(255, 255, 255, 0.85); /* Slightly increased opacity for better readability */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.18); /* Subtle border */
}
.bg-gradient {
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(243, 244, 246, 0.8); /* bg-gray-100 with opacity */
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(79, 70, 229, 0.4); /* primary-600 with opacity */
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(79, 70, 229, 0.6); /* primary-600 with more opacity */
}
/* Basic modal styles */
.modal {
display: none;
position: fixed;
z-index: 50;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
backdrop-filter: blur(4px);
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
}
/* Loading spinner */
.loading-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Notification */
.notification {
position: fixed;
bottom: 5rem; /* Adjusted from bottom-20 */
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.25rem; /* px-5 py-3 */
border-radius: 0.5rem; /* rounded-lg */
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-weight: 500; /* font-medium */
z-index: 1000; /* Increased z-index */
opacity: 0;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}
.notification.show {
opacity: 1;
transform: translate(-50%, 0);
}
.notification.error {
background-color: rgba(220, 38, 38, 0.8); /* danger-600 with opacity */
}
/* Scroll buttons */
.scroll-buttons {
position: fixed;
right: 1.25rem; /* right-5 */
bottom: 5rem; /* bottom-20 */
display: flex;
flex-direction: column;
gap: 0.5rem; /* gap-2 */
z-index: 10;
}
.scroll-button {
width: 2.5rem; /* w-10 */
height: 2.5rem; /* h-10 */
background-color: #4f46e5; /* bg-primary-600 */
color: white;
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 */
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease-in-out;
}
.scroll-button:hover {
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 */
}
{% block head_extra_styles %}
{% endblock %}
</style>
{% block head_extra_scripts %}{% endblock %}
</head>
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
{% 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">
© <span id="copyright-year"></span> by
<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> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fab fa-github"></i> GitHub
</a> |
<a href="https://afdian.com/a/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fas fa-drumstick-bite text-yellow-600"></i> 给作者加鸡腿
</a>
<span class="mx-1">|</span>
<span class="text-xs text-yellow-600 font-semibold">
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
</span>
<span id="version-info-container" class="inline-block">
<!-- Version info will be loaded here by JavaScript -->
</span>
</div>
<!-- 通用JS -->
<script>
// 设置版权年份
document.getElementById('copyright-year').textContent = new Date().getFullYear();
// 滚动到顶部/底部函数 (如果页面需要)
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function scrollToBottom() {
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
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
function createNotificationElement() {
let notification = document.getElementById('notification');
if (!notification) {
notification = document.createElement('div');
notification.id = 'notification';
notification.className = 'notification';
document.body.appendChild(notification);
}
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-400">检查更新中...</span>'; // Initial loading state
try {
const response = await fetch('/api/version/check');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
let versionHtml = `<span class="mx-1">|</span><span class="text-xs text-gray-500">v${data.current_version}</span>`;
if (data.update_available) {
versionHtml += `
<span class="mx-1">|</span>
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
<i class="fas fa-arrow-up"></i> 新版本: v${data.latest_version}
</a>`;
} else if (data.error_message) {
versionHtml += `
<span class="mx-1">|</span>
<span class="text-xs text-red-500" title="${data.error_message}">更新检查失败</span>`;
} else {
versionHtml += `<span class="mx-1">|</span><span class="text-xs text-green-500">已是最新</span>`; // Indicate up-to-date
}
versionInfoContainer.innerHTML = versionHtml;
} catch (error) {
console.error('Error fetching version info:', error);
versionInfoContainer.innerHTML = `<span class="mx-1">|</span><span class="text-xs text-red-500" title="无法连接到服务器或解析响应">更新检查失败</span>`;
}
}
// Fetch immediately on load
fetchVersionInfo();
// Fetch periodically (e.g., every hour)
setInterval(fetchVersionInfo, 3600000); // 3600000 ms = 1 hour
</script>
{% block body_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,603 @@
{% extends "base.html" %}
{% block title %}配置编辑器 - Gemini Balance{% endblock %}
{% block head_extra_styles %}
<style>
/* config_editor.html specific styles */
/* Animations (already in base.html, but keep fade-in class usage) */
.fade-in {
animation: fadeIn 0.3s ease forwards;
}
/* Modal specific styles (already in base.html) */
.array-container {
max-height: 300px;
overflow-y: auto;
padding-right: 5px; /* Keep specific padding if needed */
}
#API_KEYS_container { /* Keep specific ID styling if needed */
max-height: 300px;
overflow-y: auto;
}
.config-section {
display: none;
}
.config-section.active {
display: block;
animation: fadeIn 0.3s ease forwards; /* Use base animation */
}
.provider-config {
display: none;
}
.provider-config.active {
display: block;
}
/* Tailwind Toggle Switch Helper CSS */
.toggle-checkbox:checked {
@apply: right-0 border-primary-600;
right: 0;
border-color: #4F46E5;
}
.toggle-checkbox:checked + .toggle-label {
@apply: bg-primary-600;
background-color: #4F46E5;
}
/* 统一通知样式为黑色半透明,确保与 keys_status 一致 */
.notification {
background: rgba(0,0,0,0.8) !important;
color: #fff !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container max-w-4xl mx-auto px-4">
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
<button class="absolute top-6 right-6 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)">
<i class="fas fa-sync-alt"></i>
</button>
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance - 配置编辑
</h1>
<!-- Navigation Tabs -->
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
<a href="/config" 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">
<i class="fas fa-cog"></i> 配置编辑
</a>
<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">
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<a href="/logs" 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">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<!-- Config Tabs -->
<div class="flex justify-center mb-6 flex-wrap gap-2">
<button class="tab-btn bg-primary-600 text-white px-5 py-2 rounded-full shadow-md font-medium text-sm" data-tab="api">
API配置
</button>
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="model">
模型配置
</button>
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="image">
图像生成
</button>
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="stream">
流式输出
</button>
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="scheduler">
定时任务
</button>
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="logging">
日志配置
</button>
</div>
<!-- Save Status Banner (Removed - using notification component now) -->
<!-- Configuration Form -->
<form id="configForm" class="mt-6">
<!-- API 相关配置 -->
<div class="config-section active bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="api-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-key text-primary-600"></i> API相关配置
</h2>
<!-- API密钥列表 -->
<div class="mb-6">
<label for="API_KEYS" class="block font-semibold mb-2 text-gray-700">API密钥列表</label>
<div class="mb-2">
<input type="search" id="apiKeySearchInput" placeholder="搜索密钥..." class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
</div>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="API_KEYS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end gap-2">
<button type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="bulkDeleteApiKeyBtn">
<i class="fas fa-trash-alt"></i> 删除密钥
</button>
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addApiKeyBtn">
<i class="fas fa-plus"></i> 添加密钥
</button>
</div>
<small class="text-gray-500 mt-1 block">Gemini API密钥列表每行一个</small>
</div>
<!-- 允许的令牌列表 -->
<div class="mb-6">
<label for="ALLOWED_TOKENS" class="block font-semibold mb-2 text-gray-700">允许的令牌列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="ALLOWED_TOKENS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('ALLOWED_TOKENS')">
<i class="fas fa-plus"></i> 添加令牌
</button>
</div>
<small class="text-gray-500 mt-1 block">允许访问API的令牌列表</small>
</div>
<!-- 认证令牌 -->
<div class="mb-6">
<label for="AUTH_TOKEN" class="block font-semibold mb-2 text-gray-700">认证令牌</label>
<div class="flex items-center">
<div class="flex items-center flex-grow border border-gray-300 rounded-md focus-within:border-primary-500 focus-within:ring focus-within:ring-primary-200 focus-within:ring-opacity-50">
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none">
<button type="button" id="generateAuthTokenBtn" class="generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors" title="生成随机令牌">
<i class="fas fa-dice"></i>
</button>
</div>
</div>
<small class="text-gray-500 mt-1 block">用于API认证的令牌</small>
</div>
<!-- API基础URL -->
<div class="mb-6">
<label for="BASE_URL" class="block font-semibold mb-2 text-gray-700">API基础URL</label>
<input type="text" id="BASE_URL" name="BASE_URL" placeholder="https://generativelanguage.googleapis.com/v1beta" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">Gemini API的基础URL</small>
</div>
<!-- 最大失败次数 -->
<div class="mb-6">
<label for="MAX_FAILURES" class="block font-semibold mb-2 text-gray-700">最大失败次数</label>
<input type="number" id="MAX_FAILURES" name="MAX_FAILURES" min="1" max="100" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">API密钥失败后标记为无效的次数</small>
</div>
<!-- 请求超时时间 -->
<div class="mb-6">
<label for="TIME_OUT" class="block font-semibold mb-2 text-gray-700">请求超时时间(秒)</label>
<input type="number" id="TIME_OUT" name="TIME_OUT" min="1" max="600" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">API请求的超时时间</small>
</div>
<!-- 最大重试次数 -->
<div class="mb-6">
<label for="MAX_RETRIES" class="block font-semibold mb-2 text-gray-700">最大重试次数</label>
<input type="number" id="MAX_RETRIES" name="MAX_RETRIES" min="0" max="10" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">API请求失败后的最大重试次数</small>
</div>
<!-- 代理服务器列表 -->
<div class="mb-6">
<label for="PROXIES" class="block font-semibold mb-2 text-gray-700">代理服务器列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="PROXIES_container">
<!-- 代理项将在这里动态添加 -->
</div>
<div class="flex justify-end gap-2">
<button type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="bulkDeleteProxyBtn">
<i class="fas fa-trash-alt"></i> 删除代理
</button>
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addProxyBtn">
<i class="fas fa-plus"></i> 添加代理
</button>
</div>
<small class="text-gray-500 mt-1 block">代理服务器列表,支持 http 和 socks5 格式,例如: http://user:pass@host:port 或 socks5://host:port。点击按钮可批量添加或删除。</small>
</div>
</div>
<!-- 模型相关配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="model-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-robot text-primary-600"></i> 模型相关配置
</h2>
<!-- 测试模型 -->
<div class="mb-6">
<label for="TEST_MODEL" class="block font-semibold mb-2 text-gray-700">测试模型</label>
<input type="text" id="TEST_MODEL" name="TEST_MODEL" placeholder="gemini-1.5-flash" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">用于测试API密钥的模型</small>
</div>
<!-- 图像模型列表 -->
<div class="mb-6">
<label for="IMAGE_MODELS" class="block font-semibold mb-2 text-gray-700">图像模型列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="IMAGE_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('IMAGE_MODELS')">
<i class="fas fa-plus"></i> 添加模型
</button>
</div>
<small class="text-gray-500 mt-1 block">支持图像处理的模型列表</small>
</div>
<!-- 搜索模型列表 -->
<div class="mb-6">
<label for="SEARCH_MODELS" class="block font-semibold mb-2 text-gray-700">搜索模型列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="SEARCH_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('SEARCH_MODELS')">
<i class="fas fa-plus"></i> 添加模型
</button>
</div>
<small class="text-gray-500 mt-1 block">支持搜索功能的模型列表</small>
</div>
<!-- 过滤模型列表 -->
<div class="mb-6">
<label for="FILTERED_MODELS" class="block font-semibold mb-2 text-gray-700">过滤模型列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="FILTERED_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('FILTERED_MODELS')">
<i class="fas fa-plus"></i> 添加模型
</button>
</div>
<small class="text-gray-500 mt-1 block">需要过滤的模型列表</small>
</div>
<!-- 启用代码执行工具 -->
<div class="mb-6 flex items-center justify-between">
<label for="TOOLS_CODE_EXECUTION_ENABLED" class="font-semibold text-gray-700">启用代码执行工具</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="TOOLS_CODE_EXECUTION_ENABLED" id="TOOLS_CODE_EXECUTION_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
<label for="TOOLS_CODE_EXECUTION_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- 显示搜索链接 -->
<div class="mb-6 flex items-center justify-between">
<label for="SHOW_SEARCH_LINK" class="font-semibold text-gray-700">显示搜索链接</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="SHOW_SEARCH_LINK" id="SHOW_SEARCH_LINK" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
<label for="SHOW_SEARCH_LINK" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- 显示思考过程 -->
<div class="mb-6 flex items-center justify-between">
<label for="SHOW_THINKING_PROCESS" class="font-semibold text-gray-700">显示思考过程</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="SHOW_THINKING_PROCESS" id="SHOW_THINKING_PROCESS" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
<label for="SHOW_THINKING_PROCESS" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- 思考模型列表 -->
<div class="mb-6">
<label for="THINKING_MODELS" class="block font-semibold mb-2 text-gray-700">思考模型列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="THINKING_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('THINKING_MODELS')">
<i class="fas fa-plus"></i> 添加模型
</button>
</div>
<small class="text-gray-500 mt-1 block">用于“思考过程”的模型列表</small>
</div>
<!-- 思考模型预算映射 -->
<div class="mb-6">
<label for="THINKING_BUDGET_MAP" class="block font-semibold mb-2 text-gray-700">思考模型预算映射</label>
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-2 space-y-3" id="THINKING_BUDGET_MAP_container">
<!-- 键值对将在这里动态添加 -->
<div class="text-gray-500 text-sm italic">请先在上方添加思考模型,然后在此处配置预算。</div>
</div>
<!-- 移除添加预算映射按钮 -->
<!-- <div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addBudgetMapItemBtn">
<i class="fas fa-plus"></i> 添加预算映射
</button>
</div> -->
<small class="text-gray-500 mt-1 block">为每个思考模型设置预算(整数,最大值 24576此项与上方模型列表自动关联。</small>
</div>
<!-- 安全设置 -->
<div class="mb-6">
<label for="SAFETY_SETTINGS" class="block font-semibold mb-2 text-gray-700">安全设置 (Safety Settings)</label>
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-2 space-y-3" id="SAFETY_SETTINGS_container">
<!-- 安全设置项将在这里动态添加 -->
<div class="text-gray-500 text-sm italic">定义模型的安全过滤阈值。</div>
</div>
<div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addSafetySettingBtn">
<i class="fas fa-plus"></i> 添加安全设置
</button>
</div>
<small class="text-gray-500 mt-1 block">配置模型的安全过滤级别,例如 HARM_CATEGORY_HARASSMENT: BLOCK_NONE。</small>
</div>
</div>
<!-- 图像生成相关配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="image-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-image text-primary-600"></i> 图像生成配置
</h2>
<!-- 付费API密钥 -->
<div class="mb-6">
<label for="PAID_KEY" class="block font-semibold mb-2 text-gray-700">付费API密钥</label>
<input type="text" id="PAID_KEY" name="PAID_KEY" placeholder="AIzaSyxxxxxxxxxxxxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">用于图像生成的付费API密钥</small>
</div>
<!-- 图像生成模型 -->
<div class="mb-6">
<label for="CREATE_IMAGE_MODEL" class="block font-semibold mb-2 text-gray-700">图像生成模型</label>
<input type="text" id="CREATE_IMAGE_MODEL" name="CREATE_IMAGE_MODEL" placeholder="imagen-3.0-generate-002" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">用于图像生成的模型</small>
</div>
<!-- 上传提供商 -->
<div class="mb-6">
<label for="UPLOAD_PROVIDER" class="block font-semibold mb-2 text-gray-700">上传提供商</label>
<select id="UPLOAD_PROVIDER" name="UPLOAD_PROVIDER" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
<option value="smms" selected>SM.MS</option>
<option value="picgo">PicGo</option>
<option value="cloudflare_imgbed">Cloudflare</option>
</select>
<small class="text-gray-500 mt-1 block">图片上传服务提供商</small>
</div>
<!-- SM.MS密钥 -->
<div class="mb-6 provider-config active" data-provider="smms">
<label for="SMMS_SECRET_TOKEN" class="block font-semibold mb-2 text-gray-700">SM.MS密钥</label>
<input type="text" id="SMMS_SECRET_TOKEN" name="SMMS_SECRET_TOKEN" placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">SM.MS图床的密钥</small>
</div>
<!-- PicGo API密钥 -->
<div class="mb-6 provider-config" data-provider="picgo">
<label for="PICGO_API_KEY" class="block font-semibold mb-2 text-gray-700">PicGo API密钥</label>
<input type="text" id="PICGO_API_KEY" name="PICGO_API_KEY" placeholder="xxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">PicGo的API密钥</small>
</div>
<!-- Cloudflare图床URL -->
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
<label for="CLOUDFLARE_IMGBED_URL" class="block font-semibold mb-2 text-gray-700">Cloudflare图床URL</label>
<input type="text" id="CLOUDFLARE_IMGBED_URL" name="CLOUDFLARE_IMGBED_URL" placeholder="https://xxxxxxx.pages.dev/upload" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">Cloudflare图床的URL</small>
</div>
<!-- Cloudflare认证码 -->
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
<label for="CLOUDFLARE_IMGBED_AUTH_CODE" class="block font-semibold mb-2 text-gray-700">Cloudflare认证码</label>
<input type="text" id="CLOUDFLARE_IMGBED_AUTH_CODE" name="CLOUDFLARE_IMGBED_AUTH_CODE" placeholder="xxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
</div>
</div>
<!-- 流式输出优化器配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="stream-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-stream text-primary-600"></i> 流式输出优化器
</h2>
<!-- 启用流式输出优化 -->
<div class="mb-6 flex items-center justify-between">
<label for="STREAM_OPTIMIZER_ENABLED" class="font-semibold text-gray-700">启用流式输出优化</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="STREAM_OPTIMIZER_ENABLED" id="STREAM_OPTIMIZER_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
<label for="STREAM_OPTIMIZER_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- 最小延迟 -->
<div class="mb-6">
<label for="STREAM_MIN_DELAY" class="block font-semibold mb-2 text-gray-700">最小延迟(秒)</label>
<input type="number" id="STREAM_MIN_DELAY" name="STREAM_MIN_DELAY" min="0" max="1" step="0.001" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">流式输出的最小延迟时间</small>
</div>
<!-- 最大延迟 -->
<div class="mb-6">
<label for="STREAM_MAX_DELAY" class="block font-semibold mb-2 text-gray-700">最大延迟(秒)</label>
<input type="number" id="STREAM_MAX_DELAY" name="STREAM_MAX_DELAY" min="0" max="1" step="0.001" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">流式输出的最大延迟时间</small>
</div>
<!-- 短文本阈值 -->
<div class="mb-6">
<label for="STREAM_SHORT_TEXT_THRESHOLD" class="block font-semibold mb-2 text-gray-700">短文本阈值</label>
<input type="number" id="STREAM_SHORT_TEXT_THRESHOLD" name="STREAM_SHORT_TEXT_THRESHOLD" min="1" max="100" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">短文本的字符阈值</small>
</div>
<!-- 长文本阈值 -->
<div class="mb-6">
<label for="STREAM_LONG_TEXT_THRESHOLD" class="block font-semibold mb-2 text-gray-700">长文本阈值</label>
<input type="number" id="STREAM_LONG_TEXT_THRESHOLD" name="STREAM_LONG_TEXT_THRESHOLD" min="1" max="1000" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">长文本的字符阈值</small>
</div>
<!-- 分块大小 -->
<div class="mb-6">
<label for="STREAM_CHUNK_SIZE" class="block font-semibold mb-2 text-gray-700">分块大小</label>
<input type="number" id="STREAM_CHUNK_SIZE" name="STREAM_CHUNK_SIZE" min="1" max="100" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">流式输出的分块大小</small>
</div>
</div>
<!-- 定时任务配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="scheduler-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-clock text-primary-600"></i> 定时任务配置
</h2>
<!-- 检查间隔 -->
<div class="mb-6">
<label for="CHECK_INTERVAL_HOURS" class="block font-semibold mb-2 text-gray-700">检查间隔(小时)</label>
<input type="number" id="CHECK_INTERVAL_HOURS" name="CHECK_INTERVAL_HOURS" min="1" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">定时检查密钥状态的间隔时间(单位:小时)</small>
</div>
<!-- 时区 -->
<div class="mb-6">
<label for="TIMEZONE" class="block font-semibold mb-2 text-gray-700">时区</label>
<input type="text" id="TIMEZONE" name="TIMEZONE" placeholder="例如: Asia/Shanghai" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">定时任务使用的时区,格式如 "Asia/Shanghai" 或 "UTC"</small>
</div>
</div>
<!-- 日志配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="logging-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-file-alt text-primary-600"></i> 日志配置
</h2>
<!-- 日志级别 -->
<div class="mb-6">
<label for="LOG_LEVEL" class="block font-semibold mb-2 text-gray-700">日志级别</label>
<select id="LOG_LEVEL" name="LOG_LEVEL" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
<small class="text-gray-500 mt-1 block">设置应用程序的日志记录详细程度</small>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col md:flex-row justify-center gap-4 mt-8">
<button type="button" id="saveBtn" class="bg-gradient-to-r from-primary-600 to-primary-700 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-300 transform hover:-translate-y-1 hover:shadow-lg flex items-center justify-center gap-2">
<i class="fas fa-save"></i> 保存配置
</button>
<button type="button" id="resetBtn" class="bg-gradient-to-r from-gray-400 to-gray-500 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-300 transform hover:-translate-y-1 hover:shadow-lg flex items-center justify-center gap-2">
<i class="fas fa-undo"></i> 重置配置
</button>
</div>
</form>
</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 -->
<div id="notification" class="notification"></div>
<!-- Footer is now in base.html -->
<!-- API Key Add Modal -->
<div id="apiKeyModal" class="modal">
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量添加 API 密钥</h2>
<button id="closeApiKeyModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p class="text-gray-600 mb-4">每行粘贴一个或多个密钥,将自动提取有效密钥并去重。</p>
<textarea id="apiKeyBulkInput" rows="10" placeholder="在此处粘贴 API 密钥..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
<div class="flex justify-end gap-3 mt-6">
<button type="button" id="confirmAddApiKeyBtn" class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-2 rounded-lg font-medium transition">确认添加</button>
<button type="button" id="cancelAddApiKeyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
</div>
</div>
</div>
</div>
<!-- Bulk Delete API Key Modal -->
<div id="bulkDeleteApiKeyModal" class="modal">
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量删除 API 密钥</h2>
<button id="closeBulkDeleteModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p class="text-gray-600 mb-4">每行粘贴一个或多个密钥,将自动提取有效密钥并从列表中删除。</p>
<textarea id="bulkDeleteApiKeyInput" rows="10" placeholder="在此处粘贴要删除的 API 密钥..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-danger-500 focus:ring focus:ring-danger-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
<div class="flex justify-end gap-3 mt-6">
<button type="button" id="confirmBulkDeleteApiKeyBtn" class="bg-danger-600 hover:bg-danger-700 text-white px-6 py-2 rounded-lg font-medium transition">确认删除</button>
<button type="button" id="cancelBulkDeleteApiKeyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
</div>
</div>
</div>
</div>
<!-- Proxy Add Modal -->
<div id="proxyModal" class="modal">
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量添加代理服务器</h2>
<button id="closeProxyModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p class="text-gray-600 mb-4">每行粘贴一个或多个代理地址,将自动提取有效地址并去重。</p>
<textarea id="proxyBulkInput" rows="10" placeholder="在此处粘贴代理地址 (例如 http://user:pass@host:port 或 socks5://host:port)..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
<div class="flex justify-end gap-3 mt-6">
<button type="button" id="confirmAddProxyBtn" class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-2 rounded-lg font-medium transition">确认添加</button>
<button type="button" id="cancelAddProxyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
</div>
</div>
</div>
</div>
<!-- Bulk Delete Proxy Modal -->
<div id="bulkDeleteProxyModal" class="modal">
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量删除代理服务器</h2>
<button id="closeBulkDeleteProxyModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p class="text-gray-600 mb-4">每行粘贴一个或多个代理地址,将自动提取有效地址并从列表中删除。</p>
<textarea id="bulkDeleteProxyInput" rows="10" placeholder="在此处粘贴要删除的代理地址..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-danger-500 focus:ring focus:ring-danger-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
<div class="flex justify-end gap-3 mt-6">
<button type="button" id="confirmBulkDeleteProxyBtn" class="bg-danger-600 hover:bg-danger-700 text-white px-6 py-2 rounded-lg font-medium transition">确认删除</button>
<button type="button" id="cancelBulkDeleteProxyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
</div>
</div>
</div>
</div>
<!-- Reset Confirmation Modal -->
<div id="resetConfirmModal" class="modal">
<div class="w-full max-w-md mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">确认重置配置</h2>
<button id="closeResetModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p class="text-gray-600 mb-6">确定要重置所有配置吗?<br>这将恢复到默认值,此操作不可撤销。</p>
<div class="flex justify-end gap-3">
<button type="button" id="confirmResetBtn" class="bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-lg font-medium transition">确认重置</button>
<button type="button" id="cancelResetBtn" class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-2 rounded-lg font-medium transition">取消</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block body_scripts %}
<script src="/static/js/config_editor.js"></script>
<!-- Add any other page-specific JS initialization here if needed -->
{% endblock %}

View File

@@ -0,0 +1,314 @@
{% extends "base.html" %}
{% block title %}错误日志管理 - Gemini Balance{% endblock %}
{% block head_extra_styles %}
<style>
/* error_logs.html specific styles */
/* Table styles */
.styled-table th {
position: sticky;
top: 0;
background: #f3f4f6; /* bg-gray-100 */
z-index: 10;
}
.styled-table tbody tr:hover {
background-color: #f9fafb; /* bg-gray-50 */
}
.styled-table td {
padding: 12px 20px;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
/* Ensure error log column does not wrap and remove max-width */
.styled-table td:nth-child(4) { /* Assuming error log is the 4th column */
/* max-width: none; */
white-space: nowrap;
}
.btn-view-details {
background-color: #eef2ff; /* primary-50 */
color: #4f46e5; /* primary-600 */
padding: 6px 12px;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s ease-in-out;
border: 1px solid #c7d2fe; /* primary-200 */
}
.btn-view-details:hover {
background-color: #c7d2fe; /* primary-200 */
color: #4338ca; /* primary-700 */
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
@media (max-width: 768px) {
.search-container {
grid-template-columns: 1fr;
}
}
/* Modal styles are in base.html */
/* 确保输入框和按钮高度一致 */
input[type="text"], input[type="datetime-local"], select, button {
height: 36px !important;
}
/* 日期选择器样式优化 */
.date-range-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 确保所有输入框在小屏幕上正确显示 */
@media (max-width: 640px) {
input[type="datetime-local"] {
min-width: 0;
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4"> <!-- Removed max-width-7xl for wider content -->
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
<!-- Removed refresh button from top right -->
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance - 错误日志
</h1>
<!-- Navigation Tabs -->
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
<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">
<i class="fas fa-cog"></i> 配置编辑
</a>
<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">
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<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">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<!-- 主内容区域 -->
<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 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"> <!-- 修改为items-center -->
<!-- Left side: Search inputs and date range -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"> <!-- 修改为3列布局 -->
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<input type="text" id="errorCodeSearch" placeholder="搜索错误码" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<!-- 日期选择器单独一行 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 col-span-1 sm:col-span-2 lg:col-span-3 mt-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-700 whitespace-nowrap">开始时间:</label>
<input type="datetime-local" id="startDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-700 whitespace-nowrap">结束时间:</label>
<input type="datetime-local" id="endDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
</div>
</div>
</div>
<!-- Right side: Action buttons -->
<div class="flex items-center gap-3 flex-shrink-0"> <!-- 移除上边距 -->
<button id="searchBtn" class="flex items-center justify-center px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;">
<i class="fas fa-search mr-1.5"></i>搜索
</button>
<button id="copySelectedKeysBtn" class="flex items-center justify-center px-4 py-1.5 bg-success-600 hover:bg-success-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
<i class="far fa-copy mr-1.5"></i>复制
</button>
<button id="deleteSelectedBtn" class="flex items-center justify-center px-4 py-1.5 bg-danger-600 hover:bg-danger-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
<i class="fas fa-trash-alt mr-1.5"></i>删除
</button>
</div>
</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-3 py-3 font-semibold rounded-tl-lg w-12 text-center"> <!-- Adjusted padding and width -->
<input type="checkbox" id="selectAllCheckbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500">
</th>
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
ID <i class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
<th class="px-5 py-3 font-semibold">错误类型</th>
<th class="px-5 py-3 font-semibold">错误码</th>
<th class="px-5 py-3 font-semibold">模型名称</th>
<th class="px-5 py-3 font-semibold">请求时间</th>
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">操作</th> <!-- Adjusted rounding and centered -->
</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>
<!-- 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">&times;</button>
</div>
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalGeminiKey" title="复制密钥">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
<p id="modalErrorType" class="text-danger-600 font-medium pr-8"></p> <!-- Added padding right for button -->
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorType" title="复制错误类型">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group">
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group">
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
<pre id="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 relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
<p id="modalModelName" class="font-medium pr-8"></p> <!-- Added padding right for button -->
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalModelName" title="复制模型名称">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
<p id="modalRequestTime" class="font-medium pr-8"></p> <!-- Added padding right for button -->
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestTime" title="复制请求时间">
<i class="far fa-copy"></i>
</button>
</div>
</div>
<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>
<!-- 删除确认模态框 -->
<div id="deleteConfirmModal" class="modal">
<div class="w-full max-w-md mx-auto bg-white rounded-xl shadow-xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center border-b border-gray-200 pb-3 mb-4">
<h2 class="text-lg font-semibold text-gray-800">确认删除</h2>
<button id="closeDeleteConfirmModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p id="deleteConfirmMessage" class="text-gray-700 mb-6">你确定要删除选中的项目吗?此操作不可恢复!</p>
<div class="flex justify-end gap-3">
<button id="cancelDeleteBtn" type="button" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-5 py-2 rounded-lg font-medium transition">取消</button>
<button id="confirmDeleteBtn" type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-5 py-2 rounded-lg font-medium transition">确认删除</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block body_scripts %}
<script src="/static/js/error_logs.js"></script>
<script>
// 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 %}

View File

@@ -1,128 +1,667 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API密钥状态</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#764ba2">
<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-title" content="GBalance">
<link rel="icon" href="/static/icons/icon-192x192.png">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/keys_status.css">
</head>
<body>
<div class="container">
<button class="refresh-btn" onclick="refreshPage(this)">
<i class="fas fa-sync-alt"></i>
</button>
<h1>API密钥状态</h1>
<div class="key-list">
<h2 onclick="toggleSection(this, 'validKeys')">
<span>
<i class="fas fa-chevron-down toggle-icon"></i>
<i class="fas fa-check-circle" style="color: #27ae60;"></i>
有效密钥
</span>
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('valid')">
<i class="fas fa-copy"></i>
批量复制
</button>
</h2>
<div class="key-content">
<ul id="validKeys">
{% for key, fail_count in valid_keys.items() %}
<li>
<div class="key-info">
<span class="status-badge status-valid">
<i class="fas fa-check"></i> 有效
</span>
<span class="key-text">{{ key }}</span>
<span class="fail-count">
<i class="fas fa-exclamation-triangle"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="key-actions">
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="key-list">
<h2 onclick="toggleSection(this, 'invalidKeys')">
<span>
<i class="fas fa-chevron-down toggle-icon"></i>
<i class="fas fa-times-circle" style="color: #e74c3c;"></i>
无效密钥
</span>
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('invalid')">
<i class="fas fa-copy"></i>
批量复制
</button>
</h2>
<div class="key-content">
<ul id="invalidKeys">
{% for key, fail_count in invalid_keys.items() %}
<li>
<div class="key-info">
<span class="status-badge status-invalid">
<i class="fas fa-times"></i> 无效
</span>
<span class="key-text">{{ key }}</span>
<span class="fail-count">
<i class="fas fa-exclamation-triangle"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="key-actions">
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="total">
<i class="fas fa-key"></i> 总密钥数:{{ total }}
</div>
</div>
{% extends "base.html" %}
<div class="scroll-buttons">
<button class="scroll-btn" onclick="scrollToTop()" title="回到顶部">
<i class="fas fa-chevron-up"></i>
</button>
<button class="scroll-btn" onclick="scrollToBottom()" title="滚动到底部">
<i class="fas fa-chevron-down"></i>
</button>
</div>
{% block title %}API密钥状态 - Gemini Balance{% endblock %}
<div id="copyStatus"></div>
{% block head_extra_styles %}
<style>
/* keys_status.html specific styles */
.key-content {
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out; /* Added padding transition */
overflow: hidden; /* Keep hidden initially and during collapse */
}
.key-content.collapsed {
max-height: 0 !important; /* Use important to override inline style during transition */
opacity: 0;
padding-top: 0 !important; /* Collapse padding */
padding-bottom: 0 !important; /* Collapse padding */
/* overflow: hidden; */ /* Already set above */
}
.toggle-icon {
transition: transform 0.3s ease;
}
.toggle-icon.collapsed {
transform: rotate(-90deg);
}
/* Copy status styling is handled by base.html's notification */
<div class="copyright">
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
</div>
/* 现代数据统计样式 */
.stats-dashboard {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
position: relative;
z-index: 10;
}
@media (min-width: 768px) {
.stats-dashboard {
grid-template-columns: 1fr 1fr;
}
}
/* 统计卡片样式 */
.stats-card {
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(255, 255, 255, 0.4);
overflow: hidden;
transition: all 0.3s ease-in-out;
}
.stats-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.stats-card-header {
background-color: rgba(255, 255, 255, 0.3);
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(243, 244, 246, 0.5);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap; /* Allow wrapping for smaller screens */
gap: 0.5rem; /* Add gap between items */
}
.stats-card-title {
display: flex;
align-items: center;
font-size: 1rem;
font-weight: 500;
color: #374151;
}
.stats-card-title i {
margin-right: 0.5rem;
color: #4F46E5;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding: 0.75rem;
}
/* 统计项样式 */
.stat-item {
padding: 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
transition: all 0.3s ease-in-out;
position: relative;
overflow: hidden;
}
.stat-item::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.05;
background-color: currentColor;
z-index: 0;
transition: opacity 0.3s ease-in-out;
}
.stat-item:hover::before {
opacity: 0.1;
}
.stat-item:hover {
transform: scale(1.05);
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
z-index: 10;
position: relative;
color: #1F2937;
}
.stat-label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
z-index: 10;
position: relative;
color: #6B7280;
}
.stat-icon {
position: absolute;
right: 0.5rem;
bottom: 0.25rem;
opacity: 0.1;
font-size: 1.875rem;
transform: rotate(12deg);
transition: all 0.3s ease-in-out;
}
.stat-item:hover .stat-icon {
opacity: 0.2;
transform: scale(1.1) rotate(0deg);
}
/* 统计类型样式 */
.stat-primary { color: #4F46E5; background-color: rgba(238, 242, 255, 0.5); }
.stat-success { color: #10B981; background-color: rgba(236, 253, 245, 0.5); }
.stat-danger { color: #EF4444; background-color: rgba(254, 242, 242, 0.5); }
.stat-warning { color: #F59E0B; background-color: rgba(255, 251, 235, 0.5); }
.stat-info { color: #3B82F6; background-color: rgba(239, 246, 255, 0.5); }
/* 响应式调整 */
@media (max-width: 640px) {
.stats-dashboard { gap: 1rem; }
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; padding: 0.5rem; }
.stat-item { padding: 0.5rem; }
.stat-value { font-size: 1.25rem; }
.stat-label { font-size: 0.625rem; }
.stats-card-header { padding: 0.5rem 0.75rem; } /* Adjust header padding */
.key-content ul { grid-template-columns: 1fr; } /* Stack keys vertically on small screens */
}
/* Tailwind Toggle Switch Helper CSS */
.toggle-checkbox:checked {
@apply: right-0 border-primary-600;
right: 0;
border-color: #4F46E5;
}
.toggle-checkbox:checked + .toggle-label {
@apply: bg-primary-600;
background-color: #4F46E5;
}
/* Pagination Controls */
#validPaginationControls, #invalidPaginationControls {
display: flex;
justify-content: center;
align-items: center;
margin-top: 1rem; /* mt-4 */
gap: 0.5rem; /* space-x-2 */
}
/* Ensure list items are flex for alignment */
#validKeys li, #invalidKeys li {
display: flex;
align-items: flex-start; /* Align checkbox with top of content */
gap: 0.75rem; /* gap-3 */
}
/* Ensure grid layout for key lists */
#validKeys, #invalidKeys {
display: grid;
grid-template-columns: 1fr; /* Default single column */
gap: 0.75rem; /* gap-3 */
}
@media (min-width: 768px) { /* md breakpoint */
#validKeys, #invalidKeys {
grid-template-columns: repeat(2, 1fr); /* Two columns on medium screens and up */
}
}
</style>
{% endblock %}
{% block head_extra_scripts %}
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
<script src="/static/js/keys_status.js"></script>
</body>
</html>
{% endblock %}
{% block content %}
<div class="container max-w-6xl mx-auto px-4"> <!-- Increased max-width -->
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
<div class="absolute top-6 right-6 flex items-center gap-3">
<!-- 自动刷新开关 -->
<div class="flex items-center text-sm text-gray-600 select-none">
<span class="mr-2">自动刷新</span>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="autoRefreshToggle" id="autoRefreshToggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
<label for="autoRefreshToggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- 手动刷新按钮 -->
<button class="bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)" title="手动刷新">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance - 监控面板
</h1>
<!-- Navigation Tabs -->
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
<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">
<i class="fas fa-cog"></i> 配置编辑
</a>
<a href="/keys" 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">
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<a href="/logs" 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">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<!-- 现代化统计面板 -->
<div class="stats-dashboard animate-fade-in" style="animation-delay: 0.1s">
<!-- 密钥统计卡片 -->
<div class="stats-card">
<div class="stats-card-header">
<h3 class="stats-card-title">
<i class="fas fa-key"></i>
<span>密钥统计</span>
</h3>
<span class="text-xs text-gray-500">总计: {{ total_keys }}</span>
</div>
<div class="stats-grid">
<div class="stat-item stat-primary" title="总密钥数">
<div class="stat-value">{{ total_keys }}</div>
<div class="stat-label">总密钥数</div>
<i class="stat-icon fas fa-key"></i>
</div>
<div class="stat-item stat-success" title="有效密钥">
<div class="stat-value">{{ valid_key_count }}</div>
<div class="stat-label">有效密钥</div>
<i class="stat-icon fas fa-check-circle"></i>
</div>
<div class="stat-item stat-danger" title="无效密钥">
<div class="stat-value">{{ invalid_key_count }}</div>
<div class="stat-label">无效密钥</div>
<i class="stat-icon fas fa-times-circle"></i>
</div>
</div>
</div>
<!-- API调用统计卡片 -->
<div class="stats-card">
<div class="stats-card-header">
<h3 class="stats-card-title">
<i class="fas fa-chart-line"></i>
<span>API调用统计</span>
</h3>
<span class="text-xs text-gray-500">本月: {{ api_stats.calls_month }}</span>
</div>
<div class="stats-grid">
<div class="stat-item stat-warning cursor-pointer hover:bg-amber-100" title="点击查看1分钟内调用详情" data-period="1m" onclick="showApiCallDetails('1m')">
<div class="stat-value">{{ api_stats.calls_1m }}</div>
<div class="stat-label">1分钟调用</div>
<i class="stat-icon fas fa-stopwatch"></i>
</div>
<div class="stat-item stat-info cursor-pointer hover:bg-blue-100" title="点击查看1小时内调用详情" data-period="1h" onclick="showApiCallDetails('1h')">
<div class="stat-value">{{ api_stats.calls_1h }}</div>
<div class="stat-label">1小时调用</div>
<i class="stat-icon fas fa-hourglass-half"></i>
</div>
<div class="stat-item stat-primary cursor-pointer hover:bg-indigo-100" title="点击查看24小时内调用详情" data-period="24h" onclick="showApiCallDetails('24h')">
<div class="stat-value">{{ api_stats.calls_24h }}</div>
<div class="stat-label">24小时调用</div>
<i class="stat-icon fas fa-calendar-day"></i>
</div>
</div>
</div>
</div>
<!-- 有效密钥区域 -->
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'validKeys')">
<!-- Left side: Title and Toggle Icon -->
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
<i class="fas fa-check-circle text-success-500"></i>
<h2 class="text-lg font-semibold whitespace-nowrap">有效密钥列表 ({{ valid_key_count }})</h2>
</div>
<!-- Middle: Filters and Search (Allow wrapping) -->
<div class="flex items-center gap-x-4 gap-y-2 flex-grow flex-wrap justify-start md:justify-center"> <!-- Allow wrapping, center on medium+ -->
<!-- 失败次数筛选 -->
<div class="flex items-center gap-1">
<label for="failCountThreshold" class="text-sm text-gray-600 select-none whitespace-nowrap">失败次数≥</label>
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
</div>
<!-- 密钥搜索 -->
<div class="flex items-center gap-1">
<label for="keySearchInput" class="text-sm text-gray-600 select-none whitespace-nowrap"><i class="fas fa-search mr-1"></i>搜索</label>
<input type="search" id="keySearchInput" placeholder="输入密钥..." class="form-input h-7 w-32 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
</div>
<!-- 每页显示数量 -->
<div class="flex items-center gap-1">
<label for="itemsPerPageSelect" class="text-sm text-gray-600 select-none whitespace-nowrap">每页</label>
<select id="itemsPerPageSelect" class="form-select h-7 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500 bg-white" onclick="event.stopPropagation();">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="text-sm text-gray-600 select-none"></span>
</div>
</div>
<!-- Right side: Select All -->
<div class="flex items-center gap-1 flex-shrink-0" onclick="event.stopPropagation();"> <!-- Prevent shrinking -->
<input type="checkbox" id="selectAllValid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('valid', this.checked)">
<label for="selectAllValid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
</div>
</div>
<!-- 批量操作按钮组 (仅在选中时显示) -->
<div id="validBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="validSelectedCount">0</span></span>
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('valid', event)" disabled>
<i class="fas fa-check-double"></i> 批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid" disabled>
<i class="fas fa-redo-alt"></i> 批量重置
</button>
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('valid')" disabled>
<i class="fas fa-copy"></i> 批量复制
</button>
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
<!-- Key list will be populated by JS -->
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{# Initial keys rendered by server-side for non-JS users or initial load #}
{# JS will replace this content with paginated/filtered results #}
{% if valid_keys %}
{% for key, fail_count in valid_keys.items() %}
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}" data-key="{{ key }}">
<!-- Checkbox -->
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="valid" value="{{ key }}">
<!-- Key Info -->
<div class="flex-grow">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
<i class="fas fa-check mr-1"></i> 有效
</span>
<div class="flex items-center gap-1">
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
<i class="fas fa-eye"></i>
</button>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
<i class="fas fa-redo-alt"></i>
重置
</button>
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
<i class="fas fa-chart-pie"></i>
详情
</button>
</div>
</div>
</div>
</li>
{% endfor %}
{% else %}
<li class="text-center text-gray-500 py-4 col-span-full">暂无有效密钥</li>
{% endif %}
</ul>
<!-- 有效密钥分页控件容器 -->
<div id="validPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
<!-- Pagination controls will be generated by JS -->
</div>
</div>
</div>
<!-- 无效密钥区域 -->
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.4s">
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
<!-- Left side: Title and Toggle Icon -->
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
<i class="fas fa-times-circle text-danger-500"></i>
<h2 class="text-lg font-semibold whitespace-nowrap">无效密钥列表 ({{ invalid_key_count }})</h2>
</div>
<!-- Right side: Select All -->
<div class="flex items-center gap-1 ml-auto flex-shrink-0" onclick="event.stopPropagation();"> <!-- Use ml-auto, Prevent shrinking -->
<input type="checkbox" id="selectAllInvalid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('invalid', this.checked)">
<label for="selectAllInvalid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
</div>
</div>
<!-- 批量操作按钮组 (仅在选中时显示) -->
<div id="invalidBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="invalidSelectedCount">0</span></span>
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('invalid', event)" disabled>
<i class="fas fa-check-double"></i> 批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid" disabled>
<i class="fas fa-redo-alt"></i> 批量重置
</button>
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('invalid')" disabled>
<i class="fas fa-copy"></i> 批量复制
</button>
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
<!-- Key list will be populated by JS -->
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{# Initial keys rendered by server-side #}
{# JS will replace this content with paginated results #}
{% if invalid_keys %}
{% for key, fail_count in invalid_keys.items() %}
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1" data-key="{{ key }}">
<!-- Checkbox -->
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="invalid" value="{{ key }}">
<!-- Key Info -->
<div class="flex-grow">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
<i class="fas fa-times mr-1"></i> 无效
</span>
<div class="flex items-center gap-1">
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
<i class="fas fa-eye"></i>
</button>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
<i class="fas fa-redo-alt"></i>
重置
</button>
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
<i class="fas fa-chart-pie"></i>
详情
</button>
</div>
</div>
</div>
</li>
{% endfor %}
{% else %}
<li class="text-center text-gray-500 py-4 col-span-full">暂无无效密钥</li>
{% endif %}
</ul>
<!-- 无效密钥分页控件容器 -->
<div id="invalidPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
<!-- Pagination controls will be generated by JS -->
</div>
</div>
</div>
<!-- Removed old total keys display -->
</div>
</div>
<!-- Scroll buttons are now in base.html -->
<div class="scroll-buttons">
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
<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>
<!-- 重置确认模态框 -->
<div id="resetModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800" id="resetModalTitle">批量重置失败次数</h3>
<button onclick="closeResetModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-6">
<p class="text-gray-600" id="resetModalMessage"></p>
</div>
<div class="flex justify-end gap-3">
<button onclick="closeResetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 rounded-lg transition-colors">
取消
</button>
<button id="confirmResetBtn" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors">
确认
</button>
</div>
</div>
</div>
<!-- 验证确认模态框 -->
<div id="verifyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800" id="verifyModalTitle">批量验证密钥</h3>
<button onclick="closeVerifyModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-6">
<p class="text-gray-600" id="verifyModalMessage"></p>
</div>
<div class="flex justify-end gap-3">
<button onclick="closeVerifyModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 rounded-lg transition-colors">
取消
</button>
<button id="confirmVerifyBtn" class="px-4 py-2 bg-teal-500 hover:bg-teal-600 text-white rounded-lg transition-colors">
确认验证
</button>
</div>
</div>
</div>
<!-- 操作结果模态框 -->
<div id="resultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200">
<div class="flex items-center justify-between px-6 pt-6 pb-2 border-b">
<h3 class="text-xl font-bold text-gray-800 text-center w-full" id="resultModalTitle" style="letter-spacing:0.05em;">操作结果</h3>
<button onclick="closeResultModal()" class="absolute right-6 top-6 text-gray-400 hover:text-gray-700 focus:outline-none text-2xl">
<i class="fas fa-times"></i>
</button>
</div>
<div class="flex flex-col items-center px-8 pt-6 pb-2">
<div id="resultIcon" class="text-6xl mb-3"></div>
</div>
<div class="px-8 pb-2 w-full">
<div id="resultModalMessage"
class="text-gray-700 text-base leading-relaxed break-words whitespace-pre-line max-h-80 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50 p-4 shadow-inner"
style="font-family: 'JetBrains Mono', 'Fira Mono', 'Consolas', 'monospace';">
<!-- Content is dynamically generated by JS -->
</div>
</div>
<div class="flex justify-center px-8 pb-6 pt-2">
<button id="resultModalConfirmBtn" onclick="closeResultModal()" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-semibold text-base shadow transition-colors">
确定
</button>
</div>
</div>
</div>
<!-- API 调用详情模态框 -->
<div id="apiCallDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 shadow-xl max-w-3xl w-full animate-fade-in"> <!-- Increased max-width -->
<div class="flex items-center justify-between mb-4 border-b pb-3">
<h3 class="text-xl font-semibold text-gray-800" id="apiCallDetailsModalTitle">API 调用详情</h3>
<button onclick="closeApiCallDetailsModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none text-xl">
<i class="fas fa-times"></i>
</button>
</div>
<div id="apiCallDetailsContent" class="mb-6 max-h-[60vh] overflow-y-auto pr-2"> <!-- Increased max-height and added padding-right -->
<!-- 详细数据将加载到这里 -->
<div class="text-center py-10">
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
<p class="text-gray-500 mt-2">加载中...</p>
</div>
</div>
<div class="flex justify-end pt-4 border-t">
<button onclick="closeApiCallDetailsModal()" class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors text-sm font-medium">
关闭
</button>
</div>
</div>
</div>
<!-- 密钥使用详情模态框 -->
<div id="keyUsageDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 shadow-xl max-w-lg w-full animate-fade-in"> <!-- Adjusted max-width -->
<div class="flex items-center justify-between mb-4 border-b pb-3">
<h3 class="text-xl font-semibold text-gray-800" id="keyUsageDetailsModalTitle">密钥请求详情</h3>
<button onclick="closeKeyUsageDetailsModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none text-xl">
<i class="fas fa-times"></i>
</button>
</div>
<div id="keyUsageDetailsContent" class="mb-6 max-h-[50vh] overflow-y-auto pr-2"> <!-- Adjusted max-height -->
<!-- 详细数据将加载到这里 -->
<div class="text-center py-10">
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
<p class="text-gray-500 mt-2">加载中...</p>
</div>
</div>
<div class="flex justify-end pt-4 border-t">
<button onclick="closeKeyUsageDetailsModal()" class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors text-sm font-medium">
关闭
</button>
</div>
</div>
</div>
<!-- Footer is now in base.html -->
{% endblock %}
{% block body_scripts %}
<script>
// keys_status.html specific JavaScript initialization is now handled by keys_status.js
// The DOMContentLoaded listener in keys_status.js will execute after the DOM is ready.
// No inline script needed here anymore.
</script>
{% endblock %}

3
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
工具包初始化模块
"""

176
app/utils/helpers.py Normal file
View File

@@ -0,0 +1,176 @@
"""
通用工具函数模块
"""
import json
import re
import base64
import requests
from typing import Dict, Any, List, Optional, Tuple
from pathlib import Path
import logging # Import logging
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
# Define logger for helper functions if needed, or use specific loggers
helper_logger = logging.getLogger("app.utils") # Or use a more specific logger if available
# Define project root and version file path here for get_current_version
# Assuming this file is at app/utils/helpers.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
"""
从 base64 字符串中提取 MIME 类型和数据
Args:
base64_string: 可能包含 MIME 类型信息的 base64 字符串
Returns:
tuple: (mime_type, encoded_data)
"""
# 检查字符串是否以 "data:" 格式开始
if base64_string.startswith('data:'):
# 提取 MIME 类型和数据
pattern = DATA_URL_PATTERN
match = re.match(pattern, base64_string)
if match:
mime_type = "image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
encoded_data = match.group(2)
return mime_type, encoded_data
# 如果不是预期格式,假定它只是数据部分
return None, base64_string
def convert_image_to_base64(url: str) -> str:
"""
将图片URL转换为base64编码
Args:
url: 图片URL
Returns:
str: base64编码的图片数据
Raises:
Exception: 如果获取图片失败
"""
response = requests.get(url)
if response.status_code == 200:
# 将图片内容转换为base64
img_data = base64.b64encode(response.content).decode('utf-8')
return img_data
else:
raise Exception(f"Failed to fetch image: {response.status_code}")
def format_json_response(data: Dict[str, Any], indent: int = 2) -> str:
"""
格式化JSON响应
Args:
data: 要格式化的数据
indent: 缩进空格数
Returns:
str: 格式化后的JSON字符串
"""
return json.dumps(data, indent=indent, ensure_ascii=False)
def parse_prompt_parameters(prompt: str, default_ratio: str = "1:1") -> Tuple[str, int, str]:
"""
从prompt中解析参数
支持的格式:
- {n:数量} 例如: {n:2} 生成2张图片
- {ratio:比例} 例如: {ratio:16:9} 使用16:9比例
Args:
prompt: 提示文本
default_ratio: 默认比例
Returns:
tuple: (清理后的提示文本, 图片数量, 比例)
"""
# 默认值
n = 1
aspect_ratio = default_ratio
# 解析n参数
n_match = re.search(r'{n:(\d+)}', prompt)
if n_match:
n = int(n_match.group(1))
if n < 1 or n > 4:
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
prompt = prompt.replace(n_match.group(0), '').strip()
# 解析ratio参数
ratio_match = re.search(r'{ratio:(\d+:\d+)}', prompt)
if ratio_match:
aspect_ratio = ratio_match.group(1)
if aspect_ratio not in VALID_IMAGE_RATIOS:
raise ValueError(
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(VALID_IMAGE_RATIOS)}"
)
prompt = prompt.replace(ratio_match.group(0), '').strip()
return prompt, n, aspect_ratio
def extract_image_urls_from_markdown(text: str) -> List[str]:
"""
从Markdown文本中提取图片URL
Args:
text: Markdown文本
Returns:
List[str]: 图片URL列表
"""
pattern = IMAGE_URL_PATTERN
matches = re.findall(pattern, text)
return [match[1] for match in matches]
def is_valid_api_key(key: str) -> bool:
"""
检查API密钥格式是否有效
Args:
key: API密钥
Returns:
bool: 如果密钥格式有效则返回True
"""
# 检查Gemini API密钥格式
if key.startswith('AIza'):
return len(key) >= 30
# 检查OpenAI API密钥格式
if key.startswith('sk-'):
return len(key) >= 30
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

393
app/utils/uploader.py Normal file
View File

@@ -0,0 +1,393 @@
import requests
from app.domain.image_models import ImageMetadata, ImageUploader, UploadResponse
from enum import Enum
from typing import Optional, Any
class UploadErrorType(Enum):
"""上传错误类型枚举"""
NETWORK_ERROR = "network_error" # 网络请求错误
AUTH_ERROR = "auth_error" # 认证错误
INVALID_FILE = "invalid_file" # 无效文件
SERVER_ERROR = "server_error" # 服务器错误
PARSE_ERROR = "parse_error" # 响应解析错误
UNKNOWN = "unknown" # 未知错误
class UploadError(Exception):
"""图片上传错误异常类"""
def __init__(
self,
message: str,
error_type: UploadErrorType = UploadErrorType.UNKNOWN,
status_code: Optional[int] = None,
details: Optional[dict] = None,
original_error: Optional[Exception] = None
):
"""
初始化上传错误异常
Args:
message: 错误消息
error_type: 错误类型
status_code: HTTP状态码
details: 详细错误信息
original_error: 原始异常
"""
self.message = message
self.error_type = error_type
self.status_code = status_code
self.details = details or {}
self.original_error = original_error
# 构建完整错误信息
full_message = f"[{error_type.value}] {message}"
if status_code:
full_message = f"{full_message} (Status: {status_code})"
if details:
full_message = f"{full_message} - Details: {details}"
super().__init__(full_message)
@classmethod
def from_response(cls, response: Any, message: Optional[str] = None) -> "UploadError":
"""
从HTTP响应创建错误实例
Args:
response: HTTP响应对象
message: 自定义错误消息
"""
try:
error_data = response.json()
details = error_data.get("data", {})
return cls(
message=message or error_data.get("message", "Unknown error"),
error_type=UploadErrorType.SERVER_ERROR,
status_code=response.status_code,
details=details
)
except Exception:
return cls(
message=message or "Failed to parse error response",
error_type=UploadErrorType.PARSE_ERROR,
status_code=response.status_code
)
class SmMsUploader(ImageUploader):
API_URL = "https://sm.ms/api/v2/upload"
def __init__(self, api_key: str):
self.api_key = api_key
def upload(self, file: bytes, filename: str) -> UploadResponse:
try:
# 准备请求头
headers = {
"Authorization": f"Basic {self.api_key}"
}
# 准备文件数据
files = {
"smfile": (filename, file, "image/png")
}
# 发送请求
response = requests.post(
self.API_URL,
headers=headers,
files=files
)
# 检查响应状态
response.raise_for_status()
# 解析响应
result = response.json()
# 验证上传是否成功
if not result.get("success"):
raise UploadError(result.get("message", "Upload failed"))
# 转换为统一格式
data = result["data"]
image_metadata = ImageMetadata(
width=data["width"],
height=data["height"],
filename=data["filename"],
size=data["size"],
url=data["url"],
delete_url=data["delete"]
)
return UploadResponse(
success=True,
code="success",
message="Upload success",
data=image_metadata
)
except requests.RequestException as e:
# 处理网络请求相关错误
raise UploadError(f"Upload request failed: {str(e)}")
except (KeyError, ValueError) as e:
# 处理响应解析错误
raise UploadError(f"Invalid response format: {str(e)}")
except Exception as e:
# 处理其他未预期的错误
raise UploadError(f"Upload failed: {str(e)}")
class QiniuUploader(ImageUploader):
def __init__(self, access_key: str, secret_key: str):
self.access_key = access_key
self.secret_key = secret_key
def upload(self, file: bytes, filename: str) -> UploadResponse:
# 实现七牛云的具体上传逻辑
pass
class PicGoUploader(ImageUploader):
"""Chevereto API 图片上传器"""
def __init__(self, api_key: str, api_url: str = "https://www.picgo.net/api/1/upload"):
"""
初始化 Chevereto 上传器
Args:
api_key: Chevereto API 密钥
api_url: Chevereto API 上传地址
"""
self.api_key = api_key
self.api_url = api_url
def upload(self, file: bytes, filename: str) -> UploadResponse:
"""
上传图片到 Chevereto 服务
Args:
file: 图片文件二进制数据
filename: 文件名
Returns:
UploadResponse: 上传响应对象
Raises:
UploadError: 上传失败时抛出异常
"""
try:
# 准备请求头
headers = {
"X-API-Key": self.api_key
}
# 准备文件数据
files = {
"source": (filename, file)
}
# 发送请求
response = requests.post(
self.api_url,
headers=headers,
files=files
)
# 检查响应状态
response.raise_for_status()
# 解析响应
result = response.json()
# 验证上传是否成功
if result.get("status_code") != 200:
error_message = "Upload failed"
if "error" in result:
error_message = result["error"].get("message", error_message)
raise UploadError(
message=error_message,
error_type=UploadErrorType.SERVER_ERROR,
status_code=result.get("status_code"),
details=result.get("error")
)
# 从响应中提取图片信息
image_data = result.get("image", {})
# 构建图片元数据
image_metadata = ImageMetadata(
width=image_data.get("width", 0),
height=image_data.get("height", 0),
filename=image_data.get("filename", filename),
size=image_data.get("size", 0),
url=image_data.get("url", ""),
delete_url=image_data.get("delete_url", None)
)
return UploadResponse(
success=True,
code="success",
message=result.get("success", {}).get("message", "Upload success"),
data=image_metadata
)
except requests.RequestException as e:
# 处理网络请求相关错误
raise UploadError(
message=f"Upload request failed: {str(e)}",
error_type=UploadErrorType.NETWORK_ERROR,
original_error=e
)
except (KeyError, ValueError, TypeError) as e:
# 处理响应解析错误
raise UploadError(
message=f"Invalid response format: {str(e)}",
error_type=UploadErrorType.PARSE_ERROR,
original_error=e
)
except UploadError:
# 重新抛出已经是 UploadError 类型的异常
raise
except Exception as e:
# 处理其他未预期的错误
raise UploadError(
message=f"Upload failed: {str(e)}",
error_type=UploadErrorType.UNKNOWN,
original_error=e
)
class CloudFlareImgBedUploader(ImageUploader):
"""CloudFlare图床上传器"""
def __init__(self, auth_code: str, api_url: str):
"""
初始化CloudFlare图床上传器
Args:
auth_code: 认证码
api_url: 上传API地址
"""
self.auth_code = auth_code
self.api_url = api_url
def upload(self, file: bytes, filename: str) -> UploadResponse:
"""
上传图片到CloudFlare图床
Args:
file: 图片文件二进制数据
filename: 文件名
Returns:
UploadResponse: 上传响应对象
Raises:
UploadError: 上传失败时抛出异常
"""
try:
# 准备请求URL添加认证码参数如果存在
if self.auth_code:
request_url = f"{self.api_url}?authCode={self.auth_code}&uploadNameType=origin"
else:
request_url = f"{self.api_url}?uploadNameType=origin"
# 准备文件数据
files = {
"file": (filename, file)
}
# 发送请求
response = requests.post(
request_url,
files=files
)
# 检查响应状态
response.raise_for_status()
# 解析响应
result = response.json()
# 验证响应格式
if not result or not isinstance(result, list) or len(result) == 0:
raise UploadError(
message="Invalid response format",
error_type=UploadErrorType.PARSE_ERROR
)
# 获取文件URL
file_path = result[0].get("src")
if not file_path:
raise UploadError(
message="Missing file URL in response",
error_type=UploadErrorType.PARSE_ERROR
)
# 构建完整URL如果返回的是相对路径
base_url = self.api_url.split("/upload")[0]
full_url = file_path if file_path.startswith(("http://", "https://")) else f"{base_url}{file_path}"
# 构建图片元数据注意CloudFlare-ImgBed不返回所有元数据所以部分字段为默认值
image_metadata = ImageMetadata(
width=0, # CloudFlare-ImgBed不返回宽度
height=0, # CloudFlare-ImgBed不返回高度
filename=filename,
size=0, # CloudFlare-ImgBed不返回大小
url=full_url,
delete_url=None # CloudFlare-ImgBed不返回删除URL
)
return UploadResponse(
success=True,
code="success",
message="Upload success",
data=image_metadata
)
except requests.RequestException as e:
# 处理网络请求相关错误
raise UploadError(
message=f"Upload request failed: {str(e)}",
error_type=UploadErrorType.NETWORK_ERROR,
original_error=e
)
except (KeyError, ValueError, TypeError, IndexError) as e:
# 处理响应解析错误
raise UploadError(
message=f"Invalid response format: {str(e)}",
error_type=UploadErrorType.PARSE_ERROR,
original_error=e
)
except UploadError:
# 重新抛出已经是 UploadError 类型的异常
raise
except Exception as e:
# 处理其他未预期的错误
raise UploadError(
message=f"Upload failed: {str(e)}",
error_type=UploadErrorType.UNKNOWN,
original_error=e
)
class ImageUploaderFactory:
@staticmethod
def create(provider: str, **credentials) -> ImageUploader:
if provider == "smms":
return SmMsUploader(credentials["api_key"])
elif provider == "qiniu":
return QiniuUploader(
credentials["access_key"],
credentials["secret_key"]
)
elif provider == "picgo":
api_url = credentials.get("api_url", "https://www.picgo.net/api/1/upload")
return PicGoUploader(credentials["api_key"], api_url)
elif provider == "cloudflare_imgbed":
return CloudFlareImgBedUploader(
credentials["auth_code"],
credentials["base_url"]
)
raise ValueError(f"Unknown provider: {provider}")

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
volumes:
mysql_data:
services:
gemini-balance:
image: ghcr.io/snailyp/gemini-balance:latest
container_name: gemini-balance
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests; exit(0) if requests.get('http://localhost:8000/health').status_code == 200 else exit(1)\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
mysql:
image: mysql:8
container_name: gemini-balance-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: your_root_password
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
# ports:
# - "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 10s # 每隔10秒检查一次
timeout: 5s # 每次检查的超时时间为5秒
retries: 3 # 重试3次失败后标记为 unhealthy
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查

BIN
files/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

BIN
files/image1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

BIN
files/image2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

BIN
files/image3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

BIN
files/image4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

BIN
files/image5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
files/image6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
files/image7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

BIN
files/image8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,5 +1,5 @@
fastapi
httpx
httpx[socks]
openai
pydantic
pydantic_settings
@@ -9,3 +9,12 @@ uvicorn
google-genai
jinja2
python-multipart
cryptography # 支持 MySQL 8+ caching_sha2_password 验证
# 数据库相关依赖
pymysql
sqlalchemy
aiomysql
databases
python-dotenv
apscheduler
packaging