Compare commits

...

106 Commits

Author SHA1 Message Date
snaily
0b837c3f80 chore: 更新版本号至 2.1.9 2025-07-10 21:33:54 +08:00
snaily
a6cfc12443 feat: 更新响应处理逻辑以支持推理内容
- 修改了 response_handler.py 中的 _handle_openai_stream_response 和 _handle_openai_normal_response 方法,增加了对推理内容 (reasoning_content) 的支持。
- 更新了 _extract_result 方法的返回值,确保能够提取推理内容。
- 在 gemini_chat_service.py 和 openai_chat_service.py 中,调整了生成配置以包含思考过程的选项。
- 在 vertex_express_chat_service.py 中,增强了对客户端思考配置的处理逻辑,确保优先使用客户端提供的配置。
2025-07-10 21:21:55 +08:00
snaily
f6d64dd850 feat: 添加 TTS 语音名称常量并更新 TTS 服务逻辑
- 在 constants.py 中新增 TTS_VOICE_NAMES 列表,包含多个语音名称。
- 更新 tts_service.py 中的语音配置逻辑,确保使用请求中的语音名称(如果有效),否则回退到默认配置。
2025-07-10 01:03:20 +08:00
snaily
eed62caa78 refactor: 移除 ApiClient 中的 count_tokens 抽象方法
- 从 ApiClient 类中删除了 count_tokens 方法的抽象定义,以简化接口。
2025-07-10 00:53:06 +08:00
ripper
204d41d6f3 feat: add JSON Schema cleaning function to remove unsupported fields in Gemini API 2025-07-09 10:29:42 +08:00
ripper
858df0548e fix: ensure generationConfig is not None in payload 2025-07-09 10:17:32 +08:00
snaily
b3da021803 refactor: 优化配置解析逻辑,增强对泛型类型的支持
- 在 config.py 中引入 get_args 和 get_origin 函数,以更好地处理 List 和 Dict 类型的解析。
- 更新了对 List[str] 和 List[Dict[str, str]] 的解析逻辑,增加了错误处理和日志记录。
- 在 keys_status.js 中将 filterValidKeys 函数替换为 filterAndSearchValidKeys,保留旧函数以避免破坏潜在的遗留调用。
- 在 keys_status.html 中新增选项以支持更多项目选择。
2025-07-08 16:35:56 +08:00
snaily
d234f826f4 chore: 更新 Vertex API 相关注释和正则表达式为 Vertex Express API,确保一致性和准确性。修改了多个文件中的相关描述和提示信息,以反映 API 名称的变化。 2025-07-08 15:27:16 +08:00
snaily
231b69ecf8 feat: 添加自定义 Headers 功能
- 在配置中添加 `CUSTOM_HEADERS` 选项,允许用户定义全局请求头。
- 更新 API 客户端,将自定义 `header` 应用于所有出站请求。
- 在配置页面上为 `CUSTOM_HEADERS` 添加了完整的前端编辑功能。
2025-07-08 13:58:05 +08:00
snaily
0a08913677 Merge pull request #183 from liucong2013/feature/count-tokens-compatibility 2025-07-07 17:24:45 +08:00
snaily
49d32813ea chore: 更新 GitHub Actions 工作流以生成发布说明
- 修改了版本标签的引号格式
- 添加了生成发布说明的步骤
- 更新了创建发布的步骤以包含发布说明
- 调整了步骤的顺序和注释
2025-07-07 14:45:07 +08:00
snaily
c5d57e97b1 chore: 更新版本号至2.1.8 2025-07-07 14:21:41 +08:00
lc631017672
da8f7539a1 Fix: Handle empty parts in CountTokensRequest and improve payload filtering 2025-07-07 14:13:16 +08:00
lc631017672
64a68f1176 refactor: Remove debug logging for security checks 2025-07-07 10:27:48 +08:00
lc631017672
1199d7cc3c feat: Add support for countTokens API and improve debug logging 2025-07-07 10:08:57 +08:00
ry
8a827d2acb feat: 支持CloudFlare图床自定义上传文件夹路径
- 新增CLOUDFLARE_IMGBED_UPLOAD_FOLDER环境变量配置
- 用户可通过该配置项指定图片在CloudFlare图床中的上传路径
2025-07-05 23:32:45 +08:00
snaily
0e8a943d7f chore:更新 README 和 README_ZH 文件,调整徽章的 HTML 结构,使其居中显示。 2025-07-05 16:49:57 +08:00
snaily
4f62658440 Update README.md 2025-07-05 16:39:18 +08:00
snaily
6e7c3d5f6a Update README.md 2025-07-05 16:38:35 +08:00
snaily
d5062db9b6 Update README_ZH.md 2025-07-05 16:27:20 +08:00
snaily
a6ad006a49 Update README.md 2025-07-05 16:26:59 +08:00
snaily
57d593fa17 chore: 更新版本号至2.1.7 2025-07-05 00:48:50 +08:00
snaily
f38b5ae870 feat: 添加TTS相关配置和功能
- 在.env.example中添加TTS模型、语音名称和语速的配置选项
- 更新README文件,增加TTS相关配置的说明
- 在配置类中添加TTS相关设置
- 新增TTS请求模型以支持文本转语音功能
- 更新智能路由中间件以支持音频请求
- 在路由中添加处理TTS请求的API接口
- 更新前端配置编辑器以支持TTS配置选项
2025-07-05 00:47:55 +08:00
snaily
418b3ca13c Merge branch 'pr/BigLiao/172' 2025-07-03 23:44:02 +08:00
jesonliao
09bfa85e69 fix: 修复api中对role的校验
官方给的demo是不传role的
2025-07-03 23:08:31 +08:00
jesonliao
62b132208b fix: 修复数据库密码中包含特殊字符串时的问题 2025-07-03 22:23:47 +08:00
snaily
fc28f4f74e Merge branch 'pr/chinrain/167' 2025-07-03 17:28:58 +08:00
snaily
f79a52f839 fix:优化智能路由中间件,增强URL处理逻辑
- 增加对新路径模式的支持,包括对`v1beta/models`的处理
- 统一日志记录格式,提升调试信息的可读性
- 规范化代码风格,确保一致性和可维护性
- 修复了请求体和查询参数的模型名称提取逻辑
2025-07-03 17:25:50 +08:00
chinrain
94d1041961 Merge branch 'feat/AutoRoute' of https://github.com/chinrain/gemini-balance into feat/AutoRoute 2025-07-03 03:05:39 +08:00
chinrain
ada32d526a refactor: 简化智能路由中间件,优化混合格式URL处理
- 重构智能路由逻辑,在保证聊天的同时尽量简化
- 只会修改常见错误,其余的透传(方便以后维护或者不用维护)
- 常见错误都能正常聊天
- 统一前端样式
2025-07-03 03:01:10 +08:00
snaily
ef1e38aba1 fix: 在智能路由中间件中添加对请求体的JSON解析异常处理,确保在提取模型时的稳定性 2025-07-03 00:56:57 +08:00
snaily
60b2d59e25 fix:修正Gemini路径模式,移除末尾的斜杠以确保路径匹配的一致性 2025-07-03 00:45:11 +08:00
chinrain
e18aa73456 添加gemini前缀模型列表 2025-07-02 23:52:03 +08:00
chinrain
24747a5f09 移除重复配置 2025-07-02 23:41:48 +08:00
chinrain
621dac22dc Merge remote-tracking branch 'origin/main' into feat/AutoRoute 2025-07-01 02:41:18 +08:00
chinrain
23d7004b60 - 增加vertex-express支持
- 移除了不必要的判断流式请求的方法
2025-07-01 02:25:32 +08:00
snaily
c3b3d34127 Merge branch 'pr/stevessr/160' 2025-06-30 23:54:42 +08:00
chchchchc1023
18a166afb0 feat: 添加智能路由中间件,支持API路径自动规范化
- 新增SmartRoutingMiddleware智能路由中间件
- 支持OpenAI/HF/Gemini/默认格式的自动检测和转换
- 修复错误URL路径格式,提升API兼容性
- 添加URL_NORMALIZATION_ENABLED配置开关,默认关闭
- 智能路由功能默认关闭,需手动启用
2025-06-30 22:58:58 +08:00
stevessr
a41447a96d fix: 更新 thinkingBudget 的最大值限制至32767 , 最小值为 -1 2025-06-30 20:43:27 +08:00
Wangnov
df8d543539 删除ruff导致的格式化换行 2025-06-30 17:52:10 +08:00
Wangnov
5ecce8e0fe fix: 使用Union替代类型注解中的管道符号,使python3.9版本不报错 2025-06-30 17:37:02 +08:00
snaily
00f423a622 Update README.md 2025-06-28 00:00:22 +08:00
snaily
05ce04de69 Update README.md 2025-06-27 23:49:05 +08:00
snaily
cd5549e1aa chore: 更新版本号至2.1.6 2025-06-26 17:13:22 +08:00
snaily
f573c0255a Update README.md 2025-06-18 23:59:48 +08:00
snaily
060d7fffe6 docs: 在README中添加对支持者的感谢,并新增DigitalOcean的logo文件 2025-06-18 22:49:18 +08:00
snaily
38dbcd1643 fix: 更新API请求URL,增加pageSize参数以支持更大模型列表的获取 2025-06-17 23:30:36 +08:00
snaily
241d97027c Update README.md 2025-06-15 18:29:18 +08:00
snaily
d18689fe9f Merge pull request #151 from sk163/main 2025-06-14 15:12:33 +08:00
sk163
b72298fef4 feat: 增加了代理列表使用策略选项,对于同一个API_KEY可以使用固定代理 2025-06-14 14:36:11 +08:00
snaily
2d73503b00 chore: 更新版本号至2.1.5 2025-06-07 21:08:55 +08:00
snaily
fb106cd975 Merge branch 'pr/coulsontl/148' 2025-06-07 15:12:36 +08:00
snaily
5f74aacfdf Merge branch 'pr/coulsontl/147' 2025-06-07 14:47:59 +08:00
coulsontl
d9729a8a89 chore: 修改批量验证结果弹窗错误信息的样式 2025-06-07 08:58:12 +08:00
snaily
0665d5227d Update README.md 2025-06-07 01:32:38 +08:00
snaily
85a89669ff Update README.md 2025-06-07 01:28:21 +08:00
coulsontl
a2a77e607c chore: 优化UI为更耐看的浅色系主题 2025-06-06 20:03:55 +08:00
coulsontl
258df26399 feat(response_handler): 更新_extract_result函数以返回思考内容 2025-06-06 19:56:04 +08:00
snaily
df9c980ca1 Merge pull request #141 from happy-game/main
Fix: 修复使用 docker 运行时环境变量的错误解析
2025-06-01 19:13:52 +08:00
happy game
117f327e7b fix(config): Fix SAFETY_SETTINGS parsing by removing quotes 2025-05-31 21:53:08 +08:00
happy game
d599ba6be3 fix(config): Move inline .env comments to prevent parsing errors 2025-05-31 21:48:29 +08:00
snaily
8484651fdd Merge branch 'pr/coulsontl/135' 2025-05-26 01:24:16 +08:00
snaily
aab38648f8 Merge branch 'pr/Inblac/132' 2025-05-25 02:39:03 +08:00
snaily
9d4b45cf35 Merge pull request #131 from TroyMitchell911/main
Fix wrong commands in README
2025-05-24 17:03:34 +08:00
coulsontl
484e5cdc42 feat: 添加环境变量加载和思考配置处理 2025-05-24 09:26:20 +08:00
Nalvick
e37e11bf57 feat: 在OpenAI chat服务中,适配googleSearch内置工具调用支持 2025-05-23 23:45:03 +08:00
Troy
7661b71fcc Fix wrong commands in README
In the readme, the parameters for mounting the sqlite volume are wrong, which does not match the comments
2025-05-23 21:58:14 +08:00
snaily
b3a4306332 chore: Add Chinese README for Gemini Balance project with detailed features and setup instructions 2025-05-19 16:29:58 +08:00
snaily
6aab140ec2 feat(vertex): 集成 Vertex AI Express API 支持
本次更新引入了对 Google Vertex AI Express API 的支持,允许用户配置和使用 Vertex AI 模型。

主要变更包括:

后端:
- 新增 `VERTEX_API_KEYS` 和 `VERTEX_EXPRESS_BASE_URL` 至系统配置 ([`.env.example`](.env.example:13), [`app/config/config.py:62`](app/config/config.py:62), [`app/database/models.py`](app/database/models.py), [`app/database/services.py`](app/database/services.py))。
- 实现 `VertexExpressChatService` ([`app/service/chat/vertex_express_chat_service.py`](app/service/chat/vertex_express_chat_service.py)) 用于处理与 Vertex AI Express API 的交互。
- 添加 `vertex_express_routes` ([`app/router/vertex_express_routes.py`](app/router/vertex_express_routes.py)) 来暴露 Vertex AI 相关的 API 端点,并集成到主应用 ([`app/core/application.py:36`](app/core/application.py:36), [`app/router/routes.py:15`](app/router/routes.py:15))。
- 更新密钥管理器 ([`app/service/key/key_manager.py`](app/service/key/key_manager.py)) 以支持 Vertex API 密钥的获取、检查和轮换。

前端 (配置编辑器):
- 在配置页面 ([`app/templates/config_editor.html:463`](app/templates/config_editor.html:463)) 添加了 Vertex API 密钥列表和 Vertex Express API 基础 URL 的表单字段。
- 实现了批量添加和删除 Vertex API 密钥的功能,包括相应的模态框和操作逻辑 ([`app/static/js/config_editor.js:550`](app/static/js/config_editor.js:550), [`app/static/js/config_editor.js:1097`](app/static/js/config_editor.js:1097), [`app/templates/config_editor.html:1657`](app/templates/config_editor.html:1657))。
- 确保新的配置项在初始化 ([`app/static/js/config_editor.js:598`](app/static/js/config_editor.js:598)) 和表单填充 ([`app/static/js/config_editor.js:671`](app/static/js/config_editor.js:671)) 时得到正确处理。
- 更新了数组项添加逻辑以识别 `VERTEX_API_KEYS` 为敏感字段 ([`app/static/js/config_editor.js:1235`](app/static/js/config_editor.js:1235))。

此功能扩展了应用支持的 AI 服务范围,为用户提供了更多模型选择。
2025-05-17 00:13:49 +08:00
snaily
e260ad02bf feat(error_log): 添加清空所有错误日志的功能
主要变更:
- 在数据库服务层 ([`app/database/services.py:364`](app/database/services.py:364)) 添加了 `delete_all_error_logs` 函数。
- 在错误日志路由 ([`app/router/error_log_routes.py:186`](app/router/error_log_routes.py:186)) 中添加了新的 `DELETE /api/logs/errors/all` API 端点。
- 在前端 ([`app/static/js/error_logs.js`](app/static/js/error_logs.js)) 添加了“清空全部”按钮和相应的处理逻辑,并重构了删除确认模态框以支持此新功能。
- 将 [`app/core/application.py:42`](app/core/application.py:42) 中的 `initialize_database()` 调用从异步更改为同步。
2025-05-15 00:23:53 +08:00
snaily
4becc8d4d4 feat: 改进错误日志功能并优化应用初始化流程
本次提交主要包含以下更新:

- **错误日志页面增强**:
    - 重构了 [`app/static/js/error_logs.js`](app/static/js/error_logs.js) 中的分页逻辑,将样式控制移至 CSS,简化了 JavaScript 代码。
    - 更新了 [`app/templates/error_logs.html`](app/templates/error_logs.html) 中的分页样式,使其与 `keys_status.html` 保持一致,提升了视觉统一性。
    - 在错误日志页面新增了“清空全部”按钮,方便用户一键清除所有错误记录。
    - 调整了错误日志表格头部的文本颜色为白色,以改善深色主题下的可读性。

- **应用初始化与配置优化**:
    - 调整了 [`app/config/config.py`](app/config/config.py) 中日志记录器的获取方式,确保在配置加载早期即可用。
    - 在 [`app/core/application.py`](app/core/application.py) 中引入了更明确的数据库连接管理(连接、断开、初始化)逻辑。
    - 优化了 [`app/utils/helpers.py`](app/utils/helpers.py) 中项目路径和版本文件路径的定义方式,使其在模块级别初始化。

- **依赖清理**:
    - 从 [`requirements.txt`](requirements.txt) 中移除了不必要的注释。

这些更改旨在提升错误日志模块的用户体验和功能性,并优化应用程序的启动和配置管理流程。
2025-05-14 14:25:04 +08:00
snaily
67f87989db 更新版本号至 2.1.4
本次提交将版本号从 2.1.3 更新至 2.1.4,以反映最新的代码更改和功能增强。这是一个常规的版本更新,未涉及其他功能或修复。
2025-05-12 00:40:55 +08:00
snaily
17738b39a7 更新Telegram交流群链接至README和底部导航
本次提交更新了项目的Telegram交流群链接,具体变更包括:

- **README.md**:
  - 修改了Telegram交流群徽章的链接,确保用户能够访问最新的交流群。

- **base.html**:
  - 更新了底部导航中的Telegram交流群链接,提升了用户获取支持的便利性。

这些更改旨在确保用户能够顺利访问交流群,增强社区互动。
2025-05-12 00:39:00 +08:00
snaily
1e5312f96b feat: 添加Telegram交流群链接至README和底部导航
本次提交在项目的README文件和底部导航中添加了Telegram交流群的链接,旨在为用户提供更便捷的交流渠道。具体变更包括:

- **README.md**:
  - 新增Telegram交流群徽章和链接,方便用户访问。

- **base.html**:
  - 在底部导航中添加了Telegram交流群的链接,提升了用户获取支持的便利性。

这些更改旨在增强用户社区的互动性,促进用户之间的交流与支持。
2025-05-12 00:29:02 +08:00
BigUncleHomePC
548e69d87f fix: 修复请求日志删除任务中的时区属性错误 2025-05-11 14:51:26 +08:00
snaily
90161a1f47 feat(ui): 更新密钥状态页面样式和API调用详情
本次提交对密钥状态页面的样式进行了调整,主要变更包括:

- **位置调整**:
  - 将某些元素的位置从右上角移动至右下角,以改善布局。

- **API调用详情表格样式**:
  - 移除API调用详情模态框表格最后一行单元格的边框。
  - 恢复成功/失败状态颜色和图标颜色,确保在API调用详情表格中状态信息的清晰可见。

这些更改旨在提升用户界面的可用性和视觉效果,改善用户体验。
2025-05-10 12:27:35 +08:00
snaily
9ea3452b17 chore: 更新版本号至 2.1.3
本次提交将版本号从 2.1.2 更新至 2.1.3,以反映最新的代码更改和功能增强。这是一个常规的版本更新,未涉及其他功能或修复。
2025-05-09 19:09:13 +08:00
snaily
11e45fca37 feat: 增强流式响应处理,支持使用元数据
本次提交对流式响应处理进行了增强,主要变更包括:

- **参数更新**:
  - 在 `_handle_openai_stream_response` 方法中新增 `usage_metadata` 参数,以支持传递使用情况的元数据。

- **数据结构调整**:
  - 在返回的响应中,若提供了 `usage_metadata`,则将其包含在返回的 JSON 结构中,确保更全面的响应信息。

- **伪流式逻辑更新**:
  - 在 `OpenAIChatService` 中的多个方法中,更新了对流式响应的调用,确保在处理响应时也能传递和使用元数据。

这些更改旨在提升流式响应的灵活性和信息丰富性,改善用户体验。
2025-05-09 18:57:10 +08:00
snaily
c85fe979e5 feat(ui): 更新底部版权信息布局和样式
本次提交对底部版权信息的HTML结构和样式进行了重构,旨在提升用户界面的可读性和视觉效果。主要变更包括:

- **布局调整**:
  - 将版权信息分为两行,使用Flexbox布局,使内容更加整齐。

- **样式优化**:
  - 更新了链接和图标的样式,增强了悬停效果,提升了用户交互体验。

这些更改旨在改善用户体验,使底部信息更加清晰和美观。
2025-05-09 15:17:50 +08:00
snaily
a47edf1661 fix:修复伪流式传输中的数据块分隔符
本次提交主要修复了在伪流式传输中数据块的分隔符问题,将 `\n\` 修改为 `\n\n`,确保数据块的正确分隔。这一更改提高了数据传输的准确性,避免了潜在的解析错误。相关修改涉及 `OpenAIChatService` 类中的多个方法,确保在发送数据时遵循一致的格式。
2025-05-09 14:11:08 +08:00
snaily
814a2e66c0 feat(ui): 更新密钥状态页面样式和交互
本次提交主要对密钥状态页面的样式进行了调整,增强了用户界面的可用性和视觉效果。

主要变更包括:

- **悬停效果**:
  - 调整了API调用统计项的悬停背景色,使其更暗以更好地融合主题。

- **密钥列表按钮样式**:
  - 更新了有效、无效、复制、详情和删除按钮的背景色和悬停效果,确保在不同状态下的视觉一致性。

- **状态标签样式**:
  - 调整了有效、失败和无效标签的颜色和样式,使其在密钥列表中更加醒目。

这些更改旨在提升用户体验,使密钥管理界面更加直观和美观。
2025-05-09 00:43:48 +08:00
snaily
a7d548a849 feat: 实现伪流式传输功能
本次提交引入了伪流式传输(Fake Streaming)功能,旨在为不支持原生流式响应的语言模型或特定场景提供类似流式的用户体验。

主要变更包括:

- **配置更新**:
    - 在 `.env.example` 和 `app/config/config.py` 中添加了新的配置项 `FAKE_STREAM_ENABLED` 和 `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS`,用于控制伪流式功能的启用和心跳包发送间隔。
    - 更新了 `README.md` 以包含新的伪流式配置说明。

- **核心服务逻辑**:
    - 在 `app/service/chat/openai_chat_service.py` 中:
        - 新增 `_fake_stream_logic_impl` 方法,用于处理伪流式调用的核心逻辑。当启用伪流式时,该方法会调用非流式接口,并在等待期间定期发送空数据块以维持连接。
        - 修改 `_handle_stream_completion` 方法,使其能够根据 `FAKE_STREAM_ENABLED` 配置在真实流式和伪流式逻辑之间切换。
        - 改进了流式处理中的重试逻辑、API密钥切换机制以及错误日志记录,使其更加健壮。特别是在伪流式场景下,确保了即使在非流式调用中也能正确处理和记录错误。

- **前端配置界面**:
    - 在 `app/static/js/config_editor.js` 中添加了处理和填充伪流式配置项的逻辑。
    - 在 `app/templates/config_editor.html` 中为伪流式配置添加了相应的表单控件,允许用户在配置编辑器中启用/禁用伪流式并设置空数据发送间隔。

该功能通过在后端模拟流式输出,即使底层模型不支持流式传输,也能向客户端提供持续的数据流,从而改善了用户体验,特别是在处理可能耗时较长的请求时。
2025-05-08 23:37:35 +08:00
snaily
b6a54190ed feat(config): 更新数据库类型设置的同步逻辑
本次提交主要更改了 `sync_initial_settings` 函数,增加了对 `DATABASE_TYPE` 设置的处理逻辑。具体变更包括:

- 在从数据库更新内存设置时,跳过对 `DATABASE_TYPE` 的更新,并记录调试信息,说明该设置由环境变量控制。
- 在将内存设置同步到数据库时,同样跳过对 `DATABASE_TYPE` 的同步,并记录调试信息。

DATABASE_TYPE 配置项将不会从数据库加载,也不会被同步到数据库,确保了您可以通过环境配置来控制数据库类型。
2025-05-08 22:12:14 +08:00
snaily
920228d3aa feat: 实现API密钥的单独和批量删除功能
本次更新引入了删除API密钥的功能,包括前端界面和后端逻辑。

主要变更:

- **API路由 (`app/router/config_routes.py`):**
    - 添加了新的API端点 `/keys/{key_to_delete}` 用于删除单个密钥。
    - 添加了新的API端点 `/keys/delete-selected` 用于批量删除选定的密钥。
    - 增加了对请求体 `DeleteKeysRequest` 的Pydantic模型定义。
    - 在删除操作前进行身份验证。

- **配置服务 (`app/service/config/config_service.py`):**
    - 实现了 `delete_key` 方法来处理单个密钥的删除逻辑。
    - 实现了 `delete_selected_keys` 方法来处理批量密钥的删除逻辑。
    - 确保在删除操作后更新配置。

- **密钥管理器 (`app/service/key/key_manager.py`):**
    - 更新了 `remove_key` 方法,以确保从活动密钥列表中正确移除密钥。
    - 改进了 `reset_instance` 方法,在重置时保留下一个密钥提示(`_preserved_next_key_in_cycle`),以防止在配置重载后立即丢失轮换状态。

- **前端JavaScript (`app/static/js/keys_status.js`):**
    - 添加了 `showSingleKeyDeleteConfirmModal` 函数,用于显示单个密钥删除的确认模态框。
    - 添加了 `executeSingleKeyDelete` 函数,用于执行单个密钥的删除请求。
    - 添加了 `showDeleteConfirmationModal` 函数,用于显示批量删除密钥的确认模态框。
    - 添加了 `executeDeleteSelectedKeys` 函数,用于执行批量删除密钥的请求。
    - 更新了UI交互,包括按钮状态(加载中、禁用)和结果通知。

- **HTML模板 (`app/templates/keys_status.html`):**
    - 为有效密钥和无效密钥列表中的每个密钥添加了“删除”按钮。
    - 为有效密钥和无效密钥列表添加了“批量删除”按钮。
    - 添加了用于单个密钥删除和批量删除的确认模态框HTML结构。
    - 调整了现有模态框的样式,以提高视觉一致性。

这些更改增强了密钥管理功能,允许用户更灵活地管理其API密钥。
2025-05-08 21:58:26 +08:00
snaily
f1f568afca feat(config): 添加模型助手功能以选择和管理模型
本次提交主要包含以下更改:

1. **后端更新**:
   - 在 `app/service/config/config_service.py` 中新增 `fetch_ui_models` 方法,用于获取可用于 UI 的模型列表,并处理相关的错误情况。
   - 在 `app/router/config_routes.py` 中新增 `/ui/models` 路由,提供模型列表的 API 接口,并添加身份验证逻辑。

2. **前端更新**:
   - 在 `app/static/js/config_editor.js` 中实现模型助手的功能,包括模型列表的加载、搜索和选择。
   - 在 `app/templates/config_editor.html` 中添加模型助手的模态框和相关的 UI 元素,允许用户从列表中选择模型。

这些更改旨在增强用户体验,使用户能够更方便地选择和管理模型,提高配置界面的交互性和功能性。
2025-05-08 19:48:03 +08:00
snaily
30bf666a57 Merge branch 'pr/happy-game/96' 2025-05-08 19:14:05 +08:00
snaily
c65d5244d6 fix(stats): 修复状态码检查条件的比较方式
本次提交主要更改了 `StatsService` 中对 `RequestLog.status_code` 的比较方式,将 `== None` 修改为 `is None`,以符合 Python 的最佳实践。这一修复旨在提高代码的可读性和准确性。
2025-05-08 19:08:03 +08:00
snaily
4ad18e43ef refactor(ui): 优化无效密钥列表头部布局,使“全选”组件右对齐
这个消息表明了以下几点:
1.  **类型 (Type)**: `refactor` - 这是一次重构,主要改进了现有用户界面元素的布局,而不是添加新功能或修复错误。
2.  **范围 (Scope)**: `ui` - 表明更改影响的是用户界面部分。
3.  **主题 (Subject)**:
    *   `优化无效密钥列表头部布局`: 指出更改的具体位置是“无效密钥列表”的头部区域,并且是对其布局的优化。
    *   `使“全选”组件右对齐`: 明确了主要的视觉变化是将“全选”复选框及其标签对齐到该区域的右侧。
2025-05-08 19:06:46 +08:00
happy game
f17cd66127 feat(sqlite): 将 SQLite 数据库迁移到数据目录
- 创建 data 目录存放 SQLite 数据库
- 更新使用 SQLite 的文档
2025-05-08 11:05:00 +08:00
snaily
e1c068ed9e feat: 实现日志自动删除功能并更新配置管理
本次提交主要包含以下内容:

1.  **日志自动删除功能**:
    *   新增环境变量 (`AUTO_DELETE_ERROR_LOGS_ENABLED`, `AUTO_DELETE_ERROR_LOGS_DAYS`, `AUTO_DELETE_REQUEST_LOGS_ENABLED`, `AUTO_DELETE_REQUEST_LOGS_DAYS`) 用于控制错误日志和请求日志的自动删除策略。
    *   在 `app/config/config.py` 中添加了对这些新配置项的支持和验证逻辑 (Pydantic `validator` 更新为 `field_validator`)。
    *   修改了 `app/log/logger.py` 以适应新的日志配置。
    *   新增 `app/scheduler/scheduled_tasks.py` 用于执行定期的日志清理任务。
    *   新增 `app/service/error_log/error_log_service.py` 和 `app/service/request_log/request_log_service.py` 来处理具体的日志删除逻辑。
    *   更新了 `app/router/error_log_routes.py` 和 `app/router/scheduler_routes.py` 以集成新功能。

2.  **前端配置页面更新**:
    *   在 `app/templates/config_editor.html` 和 `app/static/js/config_editor.js` 中添加了用于配置日志自动删除选项的用户界面元素。

3.  **代码和文件结构调整**:
    *   删除了不再使用的 `app/scheduler/key_checker.py` 文件。
    *   在 `.gitignore` 文件中添加了 `default_db` 以忽略该目录。

4.  **其他**:
    *   对 `app/core/application.py` 进行了相应调整。

该更新旨在增强应用的日志管理能力,提供更灵活的日志保留策略,并优化了配置界面的用户体验。
2025-05-08 00:31:17 +08:00
snaily
b86eac839d Merge pull request #93 from happy-game/sqlite
支持 SQLite
2025-05-08 00:04:04 +08:00
happy game
83252cbf33 docs(readme): 优化数据库相关环境变量的说明 2025-05-07 22:26:31 +08:00
happy game
12f6665519 feat(database): 支持使用 SQLite 数据库
- 在 `.env.example` 文件中添加了 `DATABASE_TYPE` 变量,用于指定数据库类型,默认使用 mysql
 - 添加了 `DATABASE_TYPE` 和 `SQLITE_DATABASE` 配置项
 - 在使用 mysql 时,对其他 MySQL 配置进行验证
 - 添加 `aiosqlite` 依赖
2025-05-07 22:19:46 +08:00
snaily
1ff494416b Refactor: 大幅清理代码注释并优化配置提示
本次提交主要包含以下更改:

- 代码清理:
  - 移除了 `app/router/` 目录下多个路由文件 ([`config_routes.py`](app/router/config_routes.py:1), [`error_log_routes.py`](app/router/error_log_routes.py:1), [`gemini_routes.py`](app/router/gemini_routes.py:1), [`openai_compatiable_routes.py`](app/router/openai_compatiable_routes.py:1), [`openai_routes.py`](app/router/openai_routes.py:1), [`routes.py`](app/router/routes.py:1), [`scheduler_routes.py`](app/router/scheduler_routes.py:1), [`stats_routes.py`](app/router/stats_routes.py:1), [`version_routes.py`](app/router/version_routes.py:1)) 中的大量解释性注释、TODO 注释和多余的日志标记。
  - 清理了 [`scheduler_routes.py`](app/router/scheduler_routes.py:31) 中被注释掉的认证逻辑。
  - 这些清理旨在提高代码的整洁度和可维护性。

- UI 优化:
  - 在 [`app/templates/config_editor.html`](app/templates/config_editor.html:327) 中,为 Gemini 模型的安全过滤级别设置增加了一条重要的提示信息,建议用户将其设置为 "OFF" 以避免影响输出速度,并强调非必要不应随意改动。
2025-05-07 14:47:22 +08:00
snaily
8ec1d16e9d refactor: 优化 JS 结构、API 调用和密钥管理
此次提交引入了重要的重构和改进:

- JavaScript ([`app/static/js/config_editor.js`](app/static/js/config_editor.js:1), [`app/static/js/keys_status.js`](app/static/js/keys_status.js:1), [`app/static/js/error_logs.js`](app/static/js/error_logs.js:1)):
  - 通过初始化函数(例如 [`initializeKeyPaginationAndSearch()`](app/static/js/config_editor.js:985),[`initializeAutoRefreshControls()`](app/static/js/config_editor.js:936))实现代码模块化,以实现更好的组织。
  - 通过采用 `fetchAPI` 辅助函数(在 [`showApiCallDetails()`](app/static/js/config_editor.js:1097),[`fetchAndDisplayLogs()`](app/static/js/error_logs.js:68),[`fetchKeyStatus()`](app/static/js/keys_status.js:283) 中可见其用法)标准化 API 交互。
  - 改进了分页、搜索和 DOM 元素管理,尤其是在 [`config_editor.js`](app/static/js/config_editor.js:1) 和 [`keys_status.js`](app/static/js/keys_status.js:1) 中。
  - 在 [`config_editor.js`](app/static/js/config_editor.js:1029) 中通过 [`registerServiceWorker()`](app/static/js/config_editor.js:1018) 添加了 service worker 注册。

- Gemini API ([`app/router/gemini_routes.py`](app/router/gemini_routes.py:1)):
  - 在 [`verify_selected_keys()`](app/router/gemini_routes.py:328) 端点内的 `GeminiRequest` 中添加了 `generation_config`(包含 `temperature`、`top_p`、`max_output_tokens`),以实现更可控和一致的 API 密钥验证调用。

- 配置用户界面 ([`app/templates/config_editor.html`](app/templates/config_editor.html:1)):
  - 将 `sensitive-input` 类应用于各种 API 密钥和令牌字段(例如 [`AUTH_TOKEN`](app/templates/config_editor.html:149),[`PAID_KEY`](app/templates/config_editor.html:339),[`SMMS_SECRET_TOKEN`](app/templates/config_editor.html:364)),以启用特定的客户端处理(例如屏蔽或特殊验证)。

这些更改旨在提高代码的可维护性,标准化前端后端通信,增强 API 交互的稳健性,并优化用于应用程序配置和 API 密钥状态管理的用户界面。
2025-05-07 13:58:05 +08:00
snaily
f13a4fba5f feat: 在 OpenAI 聊天响应中集成 usage_metadata 以跟踪 token 使用情况
此更改将 `usage_metadata` 参数添加到了 `app/handler/response_handler.py` 和 `app/service/chat/openai_chat_service.py` 中的相关函数。

`usage_metadata`(通常包含 token 计数:prompt_tokens, completion_tokens, total_tokens)现在会从 OpenAI API 响应中提取,并用于填充标准化响应格式中的 `usage` 字段。

这样可以更准确地跟踪 OpenAI 聊天完成接口的 token 消耗。
2025-05-06 18:32:47 +08:00
snaily
d4a3ed3a57 refactor(gemini): 优化 Gemini API 请求中可选参数的处理
- 调整 `gemini_chat_service` 中的 `_build_payload` 函数,使其在请求中未明确提供 `generationConfig` 和 `systemInstruction` 时,不会向 Gemini API 发送默认的空值(例如 `{}` 或 `""`)。现在将传递 `None`,这更符合 API 的预期行为。
- 在 `gemini_routes` 的 `verify_key` 函数中,为测试 API 密钥有效性的示例请求添加了明确的 `generationConfig`,以确保验证调用的健壮性。
2025-05-06 17:32:46 +08:00
snaily
a6a1e7fb52 refactor(retry): 统一管理 API 最大重试次数配置
将 API 调用的最大重试次数 (`MAX_RETRIES`) 的配置移至 `app.config.config.settings`。

- 修改 `RetryHandler` 以直接从全局设置读取 `MAX_RETRIES`。
- 更新使用 `RetryHandler` 的路由装饰器,移除冗余的 `max_retries` 参数传递。

这使得重试次数的配置更加集中和易于管理。
2025-05-06 12:38:31 +08:00
snaily
c01bc242aa fix(config): 将 MYSQL_SOCKET 的默认值从 None 更改为 "" 2025-05-06 11:20:29 +08:00
snaily
ab06627d3f docs(readme): 添加 SAFETY_SETTINGS 环境变量说明
在 README.md 文件中增加了对 `SAFETY_SETTINGS` 环境变量的配置说明,用于配置内容安全阈值。
2025-05-06 11:14:30 +08:00
snaily
631d054d9e Merge pull request #79 from DullJZ/main
feat: 支持mysql socket连接
2025-05-05 22:36:52 +08:00
snaily
d835085e61 Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-05-05 18:26:40 +08:00
snaily
7c3ebe7e8b feat(config): 更新 .env.example 中的默认安全设置
将骚扰、仇恨言论、色情、危险内容的阈值从 BLOCK_NONE 调整为 OFF,并添加公民诚信类别(阈值为 BLOCK_NONE)。
2025-05-05 18:26:35 +08:00
DullJZ
7e76d07e28 feat: 支持mysql socket连接 2025-05-05 09:45:34 +00:00
snaily
d21fb6c455 更新 README.md 2025-05-05 10:21:17 +08:00
snaily
56f6f5e198 feat: 支持图像生成流式响应并优化配置
- 为 OpenAI 兼容路由的图像生成聊天添加流式支持。
- 重构 `gemini-2.0-flash-exp` 安全设置,使用常量统一管理。
- 更改图像生成默认响应格式为 `url`。
- 启用 `.env.example` 中的 `AUTH_TOKEN`。
- 清理部分代码注释。
2025-05-03 20:37:09 +08:00
62 changed files with 14783 additions and 6104 deletions

View File

@@ -1,12 +1,19 @@
# MySQL数据库配置
# 数据库配置
DATABASE_TYPE=mysql
#SQLITE_DATABASE=default_db
MYSQL_HOST=gemini-balance-mysql
#MYSQL_SOCKET=/run/mysqld/mysqld.sock
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
AUTH_TOKEN=sk-123456
# For Vertex AI Platform API Keys
VERTEX_API_KEYS=["AQ.Abxxxxxxxxxxxxxxxxxxx"]
# For Vertex AI Platform Express API Base URL
VERTEX_EXPRESS_BASE_URL=https://aiplatform.googleapis.com/v1beta1/publishers/google
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}
@@ -26,6 +33,8 @@ TIME_OUT=300
# 代理服务器配置 (支持 http 和 socks5)
# 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"]
PROXIES=[]
# 对同一个API_KEY使用代理列表中固定的IP策略
PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY=true
#########################image_generate 相关配置###########################
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
@@ -34,6 +43,7 @@ SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PICGO_API_KEY=xxxx
CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
CLOUDFLARE_IMGBED_UPLOAD_FOLDER=
##########################################################################
#########################stream_optimizer 相关配置########################
STREAM_OPTIMIZER_ENABLED=false
@@ -46,8 +56,27 @@ STREAM_CHUNK_SIZE=5
######################### 日志配置 #######################################
# 日志级别 (debug, info, warning, error, critical),默认为 info
LOG_LEVEL=info
# 是否开启自动删除错误日志
AUTO_DELETE_ERROR_LOGS_ENABLED=true
# 自动删除多少天前的错误日志 (1, 7, 30)
AUTO_DELETE_ERROR_LOGS_DAYS=7
# 是否开启自动删除请求日志
AUTO_DELETE_REQUEST_LOGS_ENABLED=false
# 自动删除多少天前的请求日志 (1, 7, 30)
AUTO_DELETE_REQUEST_LOGS_DAYS=30
##########################################################################
# 假流式配置 (Fake Streaming Configuration)
# 是否启用假流式输出
FAKE_STREAM_ENABLED=True
# 假流式发送空数据的间隔时间(秒)
FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS=5
# 安全设置 (JSON 字符串格式)
# 注意:这里的示例值可能需要根据实际模型支持情况调整
SAFETY_SETTINGS='[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "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"}]'
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"}]
URL_NORMALIZATION_ENABLED=false
# tts配置
TTS_MODEL=gemini-2.5-flash-preview-tts
TTS_VOICE_NAME=Zephyr
TTS_SPEED=normal

View File

@@ -3,7 +3,7 @@ name: Publish Release
on:
push:
tags:
- 'v*' # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0
- "v*" # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0
jobs:
update-release-draft:
@@ -15,8 +15,17 @@ jobs:
# Step 1: 检出代码库
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Step 2: 自动生成 Release
# Step 2: 自动生成 Release Notes
- name: Generate release notes
id: changelog
uses: mikepenz/release-changelog-builder-action@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Step 3: 自动生成 Release
- name: Create Release
id: create_release
uses: actions/create-release@v1
@@ -25,15 +34,16 @@ jobs:
with:
tag_name: ${{ github.ref_name }}
release_name: ${{ github.ref_name }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
# Step 3: 可选构建zip文件
# Step 4: 可选构建zip文件
- name: Create ZIP file
run: |
zip -r gemini-balance.zip . -x "*.git*" "*.github*" "*.env*" "logs/*" "tests/*"
# Step 4: 可选,上传构建文件
# Step 5: 可选,上传构建文件
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
@@ -41,5 +51,5 @@ jobs:
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./gemini-balance.zip # 替换为你的构建文件路径
asset_name: gemini-balance.zip # 替换为你的文件名
asset_name: gemini-balance.zip # 替换为你的文件名
asset_content_type: application/zip

3
.gitignore vendored
View File

@@ -257,4 +257,5 @@ $RECYCLE.BIN/
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
tests/
tests/
default_db

View File

@@ -14,6 +14,8 @@ ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta
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"]'
ENV URL_NORMALIZATION_ENABLED=false
ENV CLOUDFLARE_IMGBED_UPLOAD_FOLDER=""
# Expose port
EXPOSE 8000

368
README.md
View File

@@ -1,241 +1,293 @@
# Gemini Balance - Gemini API 代理和负载均衡器
[Read this document in Chinese](README_ZH.md)
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
# Gemini Balance - Gemini API Proxy and Load Balancer
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
<p align="center">
<a href="https://trendshift.io/repositories/13692" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/13692" alt="snailyp%2Fgemini-balance | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
> ⚠️ This project is licensed under the CC BY-NC 4.0 (Attribution-NonCommercial) license. Any form of commercial resale service is prohibited. See the LICENSE file for details.
> I have never sold this service on any platform. If you encounter someone selling this service, they are definitely a reseller. Please be careful not to be deceived.
[![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/)
[![Telegram Group](https://img.shields.io/badge/Telegram-Group-blue.svg?logo=telegram)](https://t.me/+soaHax5lyI0wZDVl)
## 项目简介
> Telegram Group: <https://t.me/+soaHax5lyI0wZDVl>
Gemini Balance 是一个基于 Python FastAPI 构建的应用程序,旨在提供 Google Gemini API 的代理和负载均衡功能。它允许您管理多个 Gemini API Key并通过简单的配置实现 Key 的轮询、认证、模型过滤和状态监控。此外,项目还集成了图像生成和多种图床上传功能,并支持 OpenAI API 格式的代理。
## Project Introduction
**项目结构:**
Gemini Balance is an application built with Python FastAPI, designed to provide proxy and load balancing functions for the Google Gemini API. It allows you to manage multiple Gemini API Keys and implement key rotation, authentication, model filtering, and status monitoring through simple configuration. Additionally, the project integrates image generation and multiple image hosting upload functions, and supports proxying in the OpenAI API format.
**Project Structure:**
```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/ # 工具函数
├── config/ # Configuration management
├── core/ # Core application logic (FastAPI instance creation, middleware, etc.)
├── database/ # Database models and connections
├── domain/ # Business domain objects (optional)
├── exception/ # Custom exceptions
├── handler/ # Request handlers (optional, or handled in router)
├── log/ # Logging configuration
├── main.py # Application entry point
├── middleware/ # FastAPI middleware
├── router/ # API routes (Gemini, OpenAI, status page, etc.)
├── scheduler/ # Scheduled tasks (e.g., Key status check)
├── service/ # Business logic services (chat, Key management, statistics, etc.)
├── static/ # Static files (CSS, JS)
├── templates/ # HTML templates (e.g., Key status page)
├── utils/ # Utility functions
```
## ✨ 功能亮点
## ✨ Feature Highlights
* **多 Key 负载均衡**: 支持配置多个 Gemini API Key (`API_KEYS`),自动按顺序轮询使用,提高可用性和并发能力。
* **可视化配置即时生效**: 通过管理后台修改配置后,无需重启服务即可生效,切记要点击保存才会生效。
![配置面板](files/image4.png)
* **双协议API 兼容**: 同时支持 Gemini OpenAI 格式的 CHAT API 请求转发。
* **Multi-Key Load Balancing**: Supports configuring multiple Gemini API Keys (`API_KEYS`) for automatic sequential polling, improving availability and concurrency.
* **Visual Configuration Takes Effect Immediately**: Configurations modified through the admin backend take effect without restarting the service. Remember to click save for changes to apply.
![Configuration Panel](files/image4.png)
* **Dual Protocol API Compatibility**: Supports forwarding CHAT API requests in both Gemini and OpenAI formats.
```palintext
```plaintext
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方便在特殊网络环境下使用。支持批量添加代理。
* **Supports Image-Text Chat and Image Modification**: `IMAGE_MODELS` configures which models can perform image-text chat and image editing. When actually calling, use the `configured_model-image` model name to use this feature.
![Chat with Image Generation](files/image6.png)
![Modify Image](files/image7.png)
* **Supports Web Search**: Supports web search. `SEARCH_MODELS` configures which models can perform web searches. When actually calling, use the `configured_model-search` model name to use this feature.
![Web Search](files/image8.png)
* **Key Status Monitoring**: Provides a `/keys_status` page (requires authentication) to view the status and usage of each Key in real-time.
![Monitoring Panel](files/image.png)
* **Detailed Logging**: Provides detailed error logs for easy troubleshooting.
![Call Details](files/image1.png)
![Log List](files/image2.png)
![Log Details](files/image3.png)
* **Support for Custom Gemini Proxy**: Supports custom Gemini proxies, such as those built on Deno or Cloudflare.
* **OpenAI Image Generation API Compatibility**: Adapts the `imagen-3.0-generate-002` model interface to be compatible with the OpenAI image generation API, supporting client calls.
* **Flexible Key Addition**: Flexible way to add keys using regex matching for `gemini_key`, with key deduplication.
![Add Key](files/image5.png)
* **OpenAI Format Embeddings API Compatibility**: Perfectly adapts to the OpenAI format `embeddings` interface, usable for local document vectorization.
* **Streamlined Response Optimization**: Optional stream output optimizer (`STREAM_OPTIMIZER_ENABLED`) to improve the experience of long-text stream responses.
* **Failure Retry and Key Management**: Automatically handles API request failures, retries (`MAX_RETRIES`), automatically disables Keys after too many failures (`MAX_FAILURES`), and periodically checks for recovery (`CHECK_INTERVAL_HOURS`).
* **Docker Support**: Supports AMD and ARM architecture Docker deployments. You can also build your own Docker image.
> Image address: docker pull ghcr.io/snailyp/gemini-balance:latest
* **Automatic Model List Maintenance**: Supports fetching OpenAI and Gemini model lists, perfectly compatible with NewAPI's automatic model list fetching, no manual entry required.
* **Support for Removing Unused Models**: Too many default models are provided, many of which are not used. You can filter them out using `FILTERED_MODELS`.
* **Proxy Support**: Supports configuring HTTP/SOCKS5 proxy servers (`PROXIES`) for accessing the Gemini API, convenient for use in special network environments. Supports batch adding proxies.
## 🚀 快速开始
## 🚀 Quick Start
### 自行构建 Docker (推荐)
### Build Docker Yourself (Recommended)
#### a) dockerfile构建
#### a) Build with Dockerfile
1. **构建镜像**:
1. **Build Image**:
```bash
docker build -t gemini-balance .
```
2. **运行容器**:
2. **Run Container**:
```bash
docker run -d -p 8000:8000 --env-file .env gemini-balance
```
* `-d`: 后台运行。
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
* `--env-file .env`: 使用 `.env` 文件设置环境变量。
* `-d`: Run in detached mode.
* `-p 8000:8000`: Map port 8000 of the container to port 8000 of the host.
* `--env-file .env`: Use the `.env` file to set environment variables.
#### b) 用现有的docker镜像部署
> Note: If using an SQLite database, you need to mount a data volume to persist
>
> ```bash
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data gemini-balance
> ```
>
> Where `/path/to/data` is the data storage path on the host, and `/app/data` is the data directory inside the container.
1. **拉取镜像**:
#### b) Deploy with an Existing Docker Image
```bash
docker pull ghcr.io/snailyp/gemini-balance:latest
```
1. **Pull Image**:
2. **运行容器**:
```bash
docker pull ghcr.io/snailyp/gemini-balance:latest
```
```bash
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
```
2. **Run Container**:
* `-d`: 后台运行。
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口 (根据需要调整)。
* `--env-file .env`: 使用 `.env` 文件设置环境变量 (确保 `.env` 文件存在于执行命令的目录)。
```bash
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
```
### 本地运行 (适用于开发和测试)
* `-d`: Run in detached mode.
* `-p 8000:8000`: Map port 8000 of the container to port 8000 of the host (adjust as needed).
* `--env-file .env`: Use the `.env` file to set environment variables (ensure the `.env` file exists in the directory where the command is executed).
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作:
> Note: If using an SQLite database, you need to mount a data volume to persist
>
> ```bash
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data ghcr.io/snailyp/gemini-balance:latest
> ```
>
> Where `/path/to/data` is the data storage path on the host, and `/app/data` is the data directory inside the container.
1. **确保已完成准备工作**:
* 克隆仓库到本地。
* 安装 Python 3.9 或更高版本。
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的“配置环境变量”部分)。
* 安装项目依赖:
### Run Locally (Suitable for Development and Testing)
If you want to run the source code directly locally for development or testing, follow these steps:
1. **Ensure Prerequisites are Met**:
* Clone the repository locally.
* Install Python 3.9 or higher.
* Create and configure the `.env` file in the project root directory (refer to the "Configure Environment Variables" section above).
* Install project dependencies:
```bash
pip install -r requirements.txt
```
2. **启动应用**:
在项目根目录下运行以下命令:
2. **Start Application**:
Run the following command in the project root directory:
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
* `app.main:app`: 指定 FastAPI 应用实例的位置 (`app` 模块中的 `main.py` 文件里的 `app` 对象)。
* `--host 0.0.0.0`: 使应用可以从本地网络中的任何 IP 地址访问。
* `--port 8000`: 指定应用监听的端口号 (您可以根据需要修改)。
* `--reload`: 启用自动重载功能。当您修改代码时,服务会自动重启,非常适合开发环境 (生产环境请移除此选项)。
* `app.main:app`: Specifies the location of the FastAPI application instance (the `app` object in the `main.py` file within the `app` module).
* `--host 0.0.0.0`: Makes the application accessible from any IP address on the local network.
* `--port 8000`: Specifies the port number the application listens on (you can change this as needed).
* `--reload`: Enables automatic reloading. When you modify the code, the service will automatically restart, which is very suitable for development environments (remove this option in production environments).
3. **访问应用**:
应用启动后,您可以通过浏览器或 API 工具访问 `http://localhost:8000` (或您指定的主机和端口)。
3. **Access Application**:
After the application starts, you can access `http://localhost:8000` (or the host and port you specified) through a browser or API tool.
### 完整配置项列表
### Complete Configuration List
| 配置项 | 说明 | 默认值 |
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
| **数据库配置** | | |
| `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` |
| Configuration Item | Description | Default Value |
| :----------------------------- | :-------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Database Configuration** | | |
| `DATABASE_TYPE` | Optional, database type, supports `mysql` or `sqlite` | `mysql` |
| `SQLITE_DATABASE` | Optional, required when using `sqlite`, SQLite database file path | `default_db` |
| `MYSQL_HOST` | Required when using `mysql`, MySQL database host address | `localhost` |
| `MYSQL_SOCKET` | Optional, MySQL database socket address | `/var/run/mysqld/mysqld.sock` |
| `MYSQL_PORT` | Required when using `mysql`, MySQL database port | `3306` |
| `MYSQL_USER` | Required when using `mysql`, MySQL database username | `your_db_user` |
| `MYSQL_PASSWORD` | Required when using `mysql`, MySQL database password | `your_db_password` |
| `MYSQL_DATABASE` | Required when using `mysql`, MySQL database name | `defaultdb` |
| **API Related Configuration** | | |
| `API_KEYS` | Required, list of Gemini API keys for load balancing | `["your-gemini-api-key-1", "your-gemini-api-key-2"]` |
| `ALLOWED_TOKENS` | Required, list of tokens allowed to access | `["your-access-token-1", "your-access-token-2"]` |
| `AUTH_TOKEN` | Optional, super admin token with all permissions, defaults to the first of `ALLOWED_TOKENS` if not set | `sk-123456` |
| `TEST_MODEL` | Optional, model name used to test if a key is usable | `gemini-1.5-flash` |
| `IMAGE_MODELS` | Optional, list of models that support drawing functions | `["gemini-2.0-flash-exp"]` |
| `SEARCH_MODELS` | Optional, list of models that support search functions | `["gemini-2.0-flash-exp"]` |
| `FILTERED_MODELS` | Optional, list of disabled models | `["gemini-1.0-pro-vision-latest", ...]` |
| `TOOLS_CODE_EXECUTION_ENABLED` | Optional, whether to enable the code execution tool | `false` |
| `SHOW_SEARCH_LINK` | Optional, whether to display search result links in the response | `true` |
| `SHOW_THINKING_PROCESS` | Optional, whether to display the model's thinking process | `true` |
| `THINKING_MODELS` | Optional, list of models that support thinking functions | `[]` |
| `THINKING_BUDGET_MAP` | Optional, thinking function budget mapping (model_name:budget_value) | `{}` |
| `URL_NORMALIZATION_ENABLED` | Optional, whether to enable intelligent URL routing mapping | `false` |
| `BASE_URL` | Optional, Gemini API base URL, no modification needed by default | `https://generativelanguage.googleapis.com/v1beta` |
| `MAX_FAILURES` | Optional, number of times a single key is allowed to fail | `3` |
| `MAX_RETRIES` | Optional, maximum number of retries for failed API requests | `3` |
| `CHECK_INTERVAL_HOURS` | Optional, time interval (hours) to check if a disabled Key has recovered | `1` |
| `TIMEZONE` | Optional, timezone used by the application | `Asia/Shanghai` |
| `TIME_OUT` | Optional, request timeout (seconds) | `300` |
| `PROXIES` | Optional, list of proxy servers (e.g., `http://user:pass@host:port`, `socks5://host:port`) | `[]` |
| `LOG_LEVEL` | Optional, log level, e.g., DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | Optional, whether to enable automatic deletion of error logs | `true` |
| `AUTO_DELETE_ERROR_LOGS_DAYS` | Optional, automatically delete error logs older than this many days (e.g., 1, 7, 30) | `7` |
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| Optional, whether to enable automatic deletion of request logs | `false` |
| `AUTO_DELETE_REQUEST_LOGS_DAYS` | Optional, automatically delete request logs older than this many days (e.g., 1, 7, 30) | `30` |
| `SAFETY_SETTINGS` | Optional, safety settings (JSON string format), used to configure content safety thresholds. Example values may need adjustment based on actual model support. | `[{"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"}]` |
| **TTS Related** | | |
| `TTS_MODEL` | Optional, TTS model name | `gemini-2.5-flash-preview-tts` |
| `TTS_VOICE_NAME` | Optional, TTS voice name | `Zephyr` |
| `TTS_SPEED` | Optional, TTS speed | `normal` |
| **Image Generation Related** | | |
| `PAID_KEY` | Optional, paid API Key for advanced features like image generation | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | Optional, image generation model | `imagen-3.0-generate-002` |
| `UPLOAD_PROVIDER` | Optional, image upload provider: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
| `SMMS_SECRET_TOKEN` | Optional, API Token for SM.MS image hosting | `your-smms-token` |
| `PICGO_API_KEY` | Optional, API Key for [PicoGo](https://www.picgo.net/) image hosting | `your-picogo-apikey` |
| `CLOUDFLARE_IMGBED_URL` | Optional, [CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) image hosting upload address | `https://xxxxxxx.pages.dev/upload` |
| `CLOUDFLARE_IMGBED_AUTH_CODE` | Optional, authentication key for CloudFlare image hosting | `your-cloudflare-imgber-auth-code` |
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER` | Optional, upload folder path for CloudFlare image hosting | `""` |
| **Stream Optimizer Related** | | |
| `STREAM_OPTIMIZER_ENABLED` | Optional, whether to enable stream output optimization | `false` |
| `STREAM_MIN_DELAY` | Optional, minimum delay for stream output | `0.016` |
| `STREAM_MAX_DELAY` | Optional, maximum delay for stream output | `0.024` |
| `STREAM_SHORT_TEXT_THRESHOLD` | Optional, short text threshold | `10` |
| `STREAM_LONG_TEXT_THRESHOLD` | Optional, long text threshold | `50` |
| `STREAM_CHUNK_SIZE` | Optional, stream output chunk size | `5` |
| **Fake Stream Related** | | |
| `FAKE_STREAM_ENABLED` | Optional, whether to enable fake streaming for models or scenarios that don't support streaming | `false` |
| `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS` | Optional, interval in seconds for sending heartbeat empty data during fake streaming | `5` |
## ⚙️ API 端点
## ⚙️ API Endpoints
以下是服务提供的主要 API 端点:
The following are the main API endpoints provided by the service:
### Gemini API 相关 (`(/gemini)/v1beta`)
### Gemini API Related (`(/gemini)/v1beta`)
* `GET /models`: 列出可用的 Gemini 模型。
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
* `GET /models`: List available Gemini models.
* `POST /models/{model_name}:generateContent`: Generate content using the specified Gemini model.
* `POST /models/{model_name}:streamGenerateContent`: Stream content generation using the specified Gemini model.
### OpenAI API 相关
### OpenAI API Related
* `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格式)。
* `GET (/hf)/v1/models`: List available models (uses Gemini format underneath).
* `POST (/hf)/v1/chat/completions`: Perform chat completion (uses Gemini format underneath, supports streaming).
* `POST (/hf)/v1/embeddings`: Create text embeddings (uses Gemini format underneath).
* `POST (/hf)/v1/images/generations`: Generate images (uses Gemini format underneath).
* `GET /openai/v1/models`: List available models (uses OpenAI format underneath).
* `POST /openai/v1/chat/completions`: Perform chat completion (uses OpenAI format underneath, supports streaming, can prevent truncation, and is faster).
* `POST /openai/v1/embeddings`: Create text embeddings (uses OpenAI format underneath).
* `POST /openai/v1/images/generations`: Generate images (uses OpenAI format underneath).
## 🤝 贡献
## 🤝 Contributing
欢迎提交 Pull Request Issue
Pull Requests or Issues are welcome.
## 🎉 特别鸣谢
## 🎉 Special Thanks
特别鸣谢以下项目和平台为本项目提供图床服务:
Special thanks to the following projects and platforms for providing image hosting services for this project:
* [PicGo](https://www.picgo.net/)
* [SM.MS](https://smms.app/)
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 开源项目
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) open source project
## 🙏 感谢贡献者
## 🙏 Thanks to Contributors
感谢所有为本项目做出贡献的开发者!
Thanks to all developers who contributed to this project!
[![Contributors](https://contrib.rocks/image?repo=snailyp/gemini-balance)](https://github.com/snailyp/gemini-balance/graphs/contributors)
## Thanks to Our Supporters
A special shout-out to DigitalOcean for providing the rock-solid and dependable cloud infrastructure that keeps this project humming!
[![DigitalOcean Logo](files/dataocean.svg)](https://m.do.co/c/b249dd7f3b4c)
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
[![EdgeOne Logo](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/?from=github)
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=snailyp/gemini-balance&type=Date)](https://star-history.com/#snailyp/gemini-balance&Date)
## 💖 友情项目
## 💖 Friendly Projects
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线AI驱动的热点事件时间轴生成工具
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine: AI-driven hot event timeline generation tool
## 🎁 项目支持
## 🎁 Project Support
如果你觉得这个项目对你有帮助,可以考虑通过 [爱发电](https://afdian.com/a/snaily) 支持我。
If you find this project helpful, consider supporting me via [Afdian](https://afdian.com/a/snaily).
## 许可证
## License
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
This project is licensed under the CC BY-NC 4.0 (Attribution-NonCommercial) license. Any form of commercial resale service is prohibited. See the LICENSE file for details.

278
README_ZH.md Normal file
View File

@@ -0,0 +1,278 @@
# Gemini Balance - Gemini API 代理和负载均衡器
<p align="center">
<a href="https://trendshift.io/repositories/13692" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/13692" alt="snailyp%2Fgemini-balance | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
[![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/)
[![Telegram Group](https://img.shields.io/badge/Telegram-Group-blue.svg?logo=telegram)](https://t.me/+soaHax5lyI0wZDVl)
> 交流群https://t.me/+soaHax5lyI0wZDVl
## 项目简介
Gemini Balance 是一个基于 Python FastAPI 构建的应用程序,旨在提供 Google Gemini API 的代理和负载均衡功能。它允许您管理多个 Gemini API Key并通过简单的配置实现 Key 的轮询、认证、模型过滤和状态监控。此外,项目还集成了图像生成和多种图床上传功能,并支持 OpenAI API 格式的代理。
**项目结构:**
```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 (推荐)
#### a) dockerfile构建
1. **构建镜像**:
```bash
docker build -t gemini-balance .
```
2. **运行容器**:
```bash
docker run -d -p 8000:8000 --env-file .env gemini-balance
```
* `-d`: 后台运行。
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
* `--env-file .env`: 使用 `.env` 文件设置环境变量。
> 注意:如果使用 SQLite 数据库,需要挂载数据卷以持久化数据:
> ```bash
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data gemini-balance
> ```
> 其中 `/path/to/data` 是主机上的数据存储路径,`/app/data` 是容器内的数据目录。
#### b) 用现有的docker镜像部署
1. **拉取镜像**:
```bash
docker pull ghcr.io/snailyp/gemini-balance:latest
```
2. **运行容器**:
```bash
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
```
* `-d`: 后台运行。
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口 (根据需要调整)。
* `--env-file .env`: 使用 `.env` 文件设置环境变量 (确保 `.env` 文件存在于执行命令的目录)。
> 注意:如果使用 SQLite 数据库,需要挂载数据卷以持久化数据:
> ```bash
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data ghcr.io/snailyp/gemini-balance:latest
> ```
> 其中 `/path/to/data` 是主机上的数据存储路径,`/app/data` 是容器内的数据目录。
### 本地运行 (适用于开发和测试)
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作:
1. **确保已完成准备工作**:
* 克隆仓库到本地。
* 安装 Python 3.9 或更高版本。
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的"配置环境变量"部分)。
* 安装项目依赖:
```bash
pip install -r requirements.txt
```
2. **启动应用**:
在项目根目录下运行以下命令:
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
* `app.main:app`: 指定 FastAPI 应用实例的位置 (`app` 模块中的 `main.py` 文件里的 `app` 对象)。
* `--host 0.0.0.0`: 使应用可以从本地网络中的任何 IP 地址访问。
* `--port 8000`: 指定应用监听的端口号 (您可以根据需要修改)。
* `--reload`: 启用自动重载功能。当您修改代码时,服务会自动重启,非常适合开发环境 (生产环境请移除此选项)。
3. **访问应用**:
应用启动后,您可以通过浏览器或 API 工具访问 `http://localhost:8000` (或您指定的主机和端口)。
### 完整配置项列表
| 配置项 | 说明 | 默认值 |
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
| **数据库配置** | | |
| `DATABASE_TYPE` | 可选,数据库类型,支持 `mysql` 或 `sqlite` | `mysql` |
| `SQLITE_DATABASE` | 可选,当使用 `sqlite` 时必填SQLite 数据库文件路径 | `default_db` |
| `MYSQL_HOST` | 当使用 `mysql` 时必填MySQL 数据库主机地址 | `localhost` |
| `MYSQL_SOCKET` | 可选MySQL 数据库 socket 地址 | `/var/run/mysqld/mysqld.sock` |
| `MYSQL_PORT` | 当使用 `mysql` 时必填MySQL 数据库端口 | `3306` |
| `MYSQL_USER` | 当使用 `mysql` 时必填MySQL 数据库用户名 | `your_db_user` |
| `MYSQL_PASSWORD` | 当使用 `mysql` 时必填MySQL 数据库密码 | `your_db_password` |
| `MYSQL_DATABASE` | 当使用 `mysql` 时必填MySQL 数据库名称 | `defaultdb` |
| **API 相关配置** | | |
| `API_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 的第一个 | `sk-123456` |
| `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` | 可选,思考功能预算映射 (模型名:预算值) | `{}` |
| `URL_NORMALIZATION_ENABLED` | 可选,是否启用智能路由映射功能 | `false` |
| `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` |
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | 可选,是否开启自动删除错误日志 | `true` |
| `AUTO_DELETE_ERROR_LOGS_DAYS` | 可选,自动删除多少天前的错误日志 (例如 1, 7, 30) | `7` |
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 可选,是否开启自动删除请求日志 | `false` |
| `AUTO_DELETE_REQUEST_LOGS_DAYS` | 可选,自动删除多少天前的请求日志 (例如 1, 7, 30) | `30` |
| `SAFETY_SETTINGS` | 可选,安全设置 (JSON 字符串格式),用于配置内容安全阈值。示例值可能需要根据实际模型支持情况调整。 | `[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}]` |
| **TTS 相关** | | |
| `TTS_MODEL` | 可选TTS 模型名称 | `gemini-2.5-flash-preview-tts` |
| `TTS_VOICE_NAME` | 可选TTS 语音名称 | `Zephyr` |
| `TTS_SPEED` | 可选TTS 语速 | `normal` |
| **图像生成相关** | | |
| `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` |
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| 可选CloudFlare图床的上传文件夹路径 | `""` |
| **流式优化器相关** | | |
| `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` |
| **伪流式 (Fake Stream) 相关** | | |
| `FAKE_STREAM_ENABLED` | 可选,是否启用伪流式传输,用于不支持流式的模型或场景 | `false` |
| `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS` | 可选,伪流式传输时发送心跳空数据的间隔秒数 | `5` |
## ⚙️ API 端点
以下是服务提供的主要 API 端点:
### Gemini API 相关 (`(/gemini)/v1beta`)
* `GET /models`: 列出可用的 Gemini 模型。
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
### OpenAI API 相关
* `GET (/hf)/v1/models`: 列出可用的模型 (底层用的gemini格式)。
* `POST (/hf)/v1/chat/completions`: 进行聊天补全 (底层用的gemini格式, 支持流式传输)。
* `POST (/hf)/v1/embeddings`: 创建文本嵌入 (底层用的gemini格式)。
* `POST (/hf)/v1/images/generations`: 生成图像 (底层用的gemini格式)。
* `GET /openai/v1/models`: 列出可用的模型 (底层用的openai格式)。
* `POST /openai/v1/chat/completions`: 进行聊天补全 (底层用的openai格式, 支持流式传输, 可防止截断,速度也快)。
* `POST /openai/v1/embeddings`: 创建文本嵌入 (底层用的openai格式)。
* `POST /openai/v1/images/generations`: 生成图像 (底层用的openai格式)。
## 🤝 贡献
欢迎提交 Pull Request 或 Issue。
## 🎉 特别鸣谢
特别鸣谢以下项目和平台为本项目提供图床服务:
* [PicGo](https://www.picgo.net/)
* [SM.MS](https://smms.app/)
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 开源项目
## 🙏 感谢贡献者
感谢所有为本项目做出贡献的开发者!
[![Contributors](https://contrib.rocks/image?repo=snailyp/gemini-balance)](https://github.com/snailyp/gemini-balance/graphs/contributors)
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=snailyp/gemini-balance&type=Date)](https://star-history.com/#snailyp/gemini-balance&Date)
## 💖 友情项目
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线AI驱动的热点事件时间轴生成工具
## 🎁 项目支持
如果你觉得这个项目对你有帮助,可以考虑通过 [爱发电](https://afdian.com/a/snaily) 支持我。
## 许可证
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。

View File

@@ -1 +1 @@
2.1.2
2.1.9

View File

@@ -1,26 +1,55 @@
"""
应用程序配置模块
"""
import datetime
import json
from typing import List, Any, Dict, Type
from typing import Any, Dict, List, Type, get_args, get_origin
from pydantic import ValidationError
from pydantic import ValidationError, ValidationInfo, field_validator
from pydantic_settings import BaseSettings
from sqlalchemy import insert, update, select
from sqlalchemy import insert, select, update
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_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.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
DATABASE_TYPE: str = "mysql" # sqlite 或 mysql
SQLITE_DATABASE: str = "default_db"
MYSQL_HOST: str = ""
MYSQL_PORT: int = 3306
MYSQL_USER: str = ""
MYSQL_PASSWORD: str = ""
MYSQL_DATABASE: str = ""
MYSQL_SOCKET: str = ""
# 验证 MySQL 配置
@field_validator(
"MYSQL_HOST", "MYSQL_PORT", "MYSQL_USER", "MYSQL_PASSWORD", "MYSQL_DATABASE"
)
def validate_mysql_config(cls, v: Any, info: ValidationInfo) -> Any:
if info.data.get("DATABASE_TYPE") == "mysql":
if v is None or v == "":
raise ValueError(
"MySQL configuration is required when DATABASE_TYPE is 'mysql'"
)
return v
# API相关配置
API_KEYS: List[str]
ALLOWED_TOKENS: List[str]
@@ -30,7 +59,16 @@ class Settings(BaseSettings):
TEST_MODEL: str = DEFAULT_MODEL
TIME_OUT: int = DEFAULT_TIMEOUT
MAX_RETRIES: int = MAX_RETRIES
PROXIES: List[str] = [] # 新增:代理服务器列表
PROXIES: List[str] = []
PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: bool = True # 是否使用一致性哈希来选择代理
VERTEX_API_KEYS: List[str] = []
VERTEX_EXPRESS_BASE_URL: str = "https://aiplatform.googleapis.com/v1beta1/publishers/google"
# 智能路由配置
URL_NORMALIZATION_ENABLED: bool = False # 是否启用智能路由映射功能
# 自定义 Headers
CUSTOM_HEADERS: Dict[str, str] = {}
# 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
@@ -39,9 +77,14 @@ class Settings(BaseSettings):
TOOLS_CODE_EXECUTION_ENABLED: bool = False
SHOW_SEARCH_LINK: bool = True
SHOW_THINKING_PROCESS: bool = True
THINKING_MODELS: List[str] = [] # 新增:用于思考过程的模型列表
THINKING_BUDGET_MAP: Dict[str, float] = {} # 新增:模型对应的预算映射
THINKING_MODELS: List[str] = []
THINKING_BUDGET_MAP: Dict[str, float] = {}
# TTS相关配置
TTS_MODEL: str = "gemini-2.5-flash-preview-tts"
TTS_VOICE_NAME: str = "Zephyr"
TTS_SPEED: str = "normal"
# 图像生成相关配置
PAID_KEY: str = ""
CREATE_IMAGE_MODEL: str = DEFAULT_CREATE_IMAGE_MODEL
@@ -50,7 +93,8 @@ class Settings(BaseSettings):
PICGO_API_KEY: str = ""
CLOUDFLARE_IMGBED_URL: str = ""
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
CLOUDFLARE_IMGBED_UPLOAD_FOLDER: str = ""
# 流式输出优化器配置
STREAM_OPTIMIZER_ENABLED: bool = False
STREAM_MIN_DELAY: float = DEFAULT_STREAM_MIN_DELAY
@@ -59,17 +103,26 @@ class Settings(BaseSettings):
STREAM_LONG_TEXT_THRESHOLD: int = DEFAULT_STREAM_LONG_TEXT_THRESHOLD
STREAM_CHUNK_SIZE: int = DEFAULT_STREAM_CHUNK_SIZE
# 假流式配置 (Fake Streaming Configuration)
FAKE_STREAM_ENABLED: bool = False # 是否启用假流式输出
FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS: int = 5 # 假流式发送空数据的间隔时间(秒)
# 调度器配置
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
TIMEZONE: str = "Asia/Shanghai" # 默认时区
# github
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 # 新增:安全设置
LOG_LEVEL: str = "INFO"
AUTO_DELETE_ERROR_LOGS_ENABLED: bool = True
AUTO_DELETE_ERROR_LOGS_DAYS: int = 7
AUTO_DELETE_REQUEST_LOGS_ENABLED: bool = False
AUTO_DELETE_REQUEST_LOGS_DAYS: int = 30
SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -77,80 +130,120 @@ class Settings(BaseSettings):
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() # 函数内初始化
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()}
origin_type = get_origin(target_type)
args = get_args(target_type)
# 处理 List 类型
if origin_type is list:
# 处理 List[str]
if args and args[0] == 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()]
# 处理 List[Dict[str, str]]
elif args and get_origin(args[0]) is dict:
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"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
logger.warning(
f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}"
)
return []
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 []
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 []
# 处理 Dict 类型
elif origin_type is dict:
# 处理 Dict[str, str]
if args and args == (str, str):
parsed_dict = {}
try:
parsed = json.loads(db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): str(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:
logger.error(f"Could not parse '{db_value}' as Dict[str, str] for key '{key}'. Returning empty dict.")
return parsed_dict
# 处理 Dict[str, float]
elif args and args == (str, float):
parsed_dict = {}
try:
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:
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:
logger.error(
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict."
)
return parsed_dict
# 处理 bool
elif target_type == bool:
return db_value.lower() in ('true', '1', 'yes', 'on')
return db_value.lower() in ("true", "1", "yes", "on")
# 处理 int
elif target_type == int:
return int(db_value)
@@ -161,8 +254,11 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
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 # 解析失败则返回原始字符串
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():
"""
@@ -171,8 +267,9 @@ async def sync_initial_settings():
2. 将数据库设置合并到内存 settings (数据库优先)。
3. 将最终的内存 settings 同步回数据库。
"""
from app.log.logger import get_config_logger # 函数内导入
logger = get_config_logger() # 函数内初始化
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
@@ -185,7 +282,9 @@ async def sync_initial_settings():
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.")
logger.error(
f"Failed to connect to database for initial settings sync: {e}. Skipping sync."
)
return
try:
@@ -194,18 +293,30 @@ async def sync_initial_settings():
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]
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.")
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}
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 key == "DATABASE_TYPE":
logger.debug(
f"Skipping update of '{key}' in memory from database. "
"This setting is controlled by environment/dotenv."
)
continue
if hasattr(settings, key):
target_type = Settings.__annotations__.get(key)
if target_type:
@@ -218,35 +329,46 @@ async def sync_initial_settings():
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):
origin_type = get_origin(target_type)
if origin_type: # It's a generic type
if isinstance(parsed_db_value, origin_type):
type_match = True
# It's a non-generic type, or a specific generic we want to handle
elif 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}).")
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.")
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}")
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.")
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.")
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.")
logger.error(
f"Validation error after merging database settings: {e}. Settings might be inconsistent."
)
# 3. 将最终的内存 settings 同步回数据库
final_memory_settings = settings.model_dump()
@@ -257,21 +379,30 @@ async def sync_initial_settings():
existing_db_keys = set(db_settings_map.keys())
for key, value in final_memory_settings.items():
if key == "DATABASE_TYPE":
logger.debug(
f"Skipping synchronization of '{key}' to database. "
"This setting is controlled by environment/dotenv."
)
continue
# 序列化值为字符串或 JSON 字符串
if isinstance(value, (list, dict)): # 处理列表和字典
db_value = json.dumps(value, ensure_ascii=False) # 使用 ensure_ascii=False 以支持非 ASCII 字符
if isinstance(value, (list, dict)):
db_value = json.dumps(
value, ensure_ascii=False
)
elif isinstance(value, bool):
db_value = str(value).lower()
elif value is None: # 处理 None 值
db_value = "" # 或者根据需要设为 NULL 或其他标记
elif value is None:
db_value = ""
else:
db_value = str(value)
data = {
'key': key,
'value': db_value,
'description': f"{key} configuration setting", # 默认描述
'updated_at': now
"key": key,
"value": db_value,
"description": f"{key} configuration setting",
"updated_at": now,
}
if key in existing_db_keys:
@@ -280,7 +411,7 @@ async def sync_initial_settings():
settings_to_update.append(data)
else:
# 如果键不在数据库中,则插入
data['created_at'] = now
data["created_at"] = now
settings_to_insert.append(data)
# 在事务中执行批量插入和更新
@@ -289,48 +420,78 @@ async def sync_initial_settings():
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)}
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'])
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.")
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)}
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'])
setting_data["description"] = existing_desc.get(
setting_data["key"], setting_data["description"]
)
query_update = (
update(SettingsModel)
.where(SettingsModel.key == setting_data['key'])
.where(SettingsModel.key == setting_data["key"])
.values(
value=setting_data['value'],
description=setting_data['description'],
updated_at=setting_data['updated_at']
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.")
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)}")
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.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}")
try:
pass
except Exception as e:
logger.error(f"Error disconnecting database after initial sync: {e}")
logger.info("Initial settings synchronization finished.")

View File

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

View File

@@ -76,4 +76,15 @@ DEFAULT_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
]
]
TTS_VOICE_NAMES = [
"Zephyr", "Puck", "Charon", "Kore",
"Fenrir", "Leda", "Orus", "Aoede",
"Callirhoe", "Autonoe", "Enceladus", "Iapetus",
"Umbriel", "Algieba", "Despina", "Erinome",
"Algenib", "Rasalgethi", "Laomedeia", "Achernar",
"Alnilam", "Schedar", "Gacrux", "Pulcherrima",
"Achird", "Zubenelgenubi", "Vindemiatrix", "Sadachbia",
"Sadaltager", "Sulafat"
]

View File

@@ -1,9 +1,10 @@
"""
数据库连接池模块
"""
from pathlib import Path
from urllib.parse import quote_plus
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
@@ -12,7 +13,19 @@ 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}"
if settings.DATABASE_TYPE == "sqlite":
# 确保 data 目录存在
data_dir = Path("data")
data_dir.mkdir(exist_ok=True)
db_path = data_dir / settings.SQLITE_DATABASE
DATABASE_URL = f"sqlite:///{db_path}"
elif settings.DATABASE_TYPE == "mysql":
if settings.MYSQL_SOCKET:
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{quote_plus(settings.MYSQL_PASSWORD)}@/{settings.MYSQL_DATABASE}?unix_socket={settings.MYSQL_SOCKET}"
else:
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{quote_plus(settings.MYSQL_PASSWORD)}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
else:
raise ValueError("Unsupported database type. Please set DATABASE_TYPE to 'sqlite' or 'mysql'.")
# 创建数据库引擎
# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效
@@ -24,24 +37,24 @@ metadata = MetaData()
# 创建基类
Base = declarative_base(metadata=metadata)
# 创建数据库连接池,并配置连接池参数
# 创建数据库连接池,并配置连接池参数在sqlite中不使用连接池
# 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
if settings.DATABASE_TYPE == "sqlite":
database = Database(DATABASE_URL)
else:
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800)
# 移除了 SessionLocal 和 get_db 函数
# --- Async connection functions for lifespan/async routes ---
async def connect_to_db():
"""
连接到数据库
"""
try:
await database.connect()
logger.info("Connected to database")
logger.info(f"Connected to {settings.DATABASE_TYPE}")
except Exception as e:
logger.error(f"Failed to connect to database: {str(e)}")
raise
@@ -53,6 +66,6 @@ async def disconnect_from_db():
"""
try:
await database.disconnect()
logger.info("Disconnected from database")
logger.info(f"Disconnected from {settings.DATABASE_TYPE}")
except Exception as e:
logger.error(f"Failed to disconnect from database: {str(e)}")

View File

@@ -2,7 +2,7 @@
数据库模型模块
"""
import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean # 添加 Boolean
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean
from app.database.connection import Base
@@ -42,17 +42,18 @@ class ErrorLog(Base):
def __repr__(self):
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
# 新增 RequestLog 模型
class RequestLog(Base):
"""
API 请求日志表
"""
__tablename__ = "t_request_log"
id = Column(Integer, primary_key=True, autoincrement=True)
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密钥") # 考虑安全性,后续可优化
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="请求耗时(毫秒)")

View File

@@ -71,7 +71,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
.values(
value=value,
description=description if description else setting["description"],
updated_at=datetime.now() # Use datetime.now()
updated_at=datetime.now()
)
)
await database.execute(query)
@@ -85,8 +85,8 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
key=key,
value=value,
description=description,
created_at=datetime.now(), # Use datetime.now()
updated_at=datetime.now() # Use datetime.now()
created_at=datetime.now(),
updated_at=datetime.now()
)
)
await database.execute(query)
@@ -158,8 +158,8 @@ async def get_error_logs(
error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
sort_by: str = 'id', # 新增排序字段
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
sort_by: str = 'id',
sort_order: str = 'desc'
) -> List[Dict[str, Any]]:
"""
获取错误日志,支持搜索、日期过滤和排序
@@ -189,7 +189,6 @@ async def get_error_logs(
ErrorLog.request_time
)
# Apply filters
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
@@ -200,41 +199,33 @@ async def get_error_logs(
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
# or handle as needed (e.g., return no results for invalid code format)
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force no results if the format is invalid:
# query = query.where(False) # This ensures no rows are returned
# 添加排序逻辑
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id)
if sort_order.lower() == 'asc':
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply limit and offset
query = query.limit(limit).offset(offset)
result = await database.fetch_all(query)
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
logger.exception(f"Failed to get error logs with filters: {str(e)}")
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
error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> int:
@@ -254,7 +245,6 @@ async def get_error_logs_count(
try:
query = select(func.count()).select_from(ErrorLog)
# Apply the same filters as get_error_logs
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
@@ -265,23 +255,19 @@ async def get_error_logs_count(
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force count to 0 if the format is invalid:
# return 0 # Or query = query.where(False) before fetching
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
logger.exception(f"Failed to count error logs with filters: {str(e)}")
raise
@@ -307,7 +293,7 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
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
log_dict['request_msg'] = str(log_dict['request_msg'])
return log_dict
else:
return None
@@ -315,7 +301,6 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise
# --- 异步删除函数 (使用 databases 库) ---
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
@@ -345,7 +330,7 @@ async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
except Exception as e:
# 数据库连接或执行错误
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
raise
async def delete_error_log_by_id(log_id: int) -> bool:
"""
@@ -364,7 +349,7 @@ async def delete_error_log_by_id(log_id: int) -> bool:
if not exists:
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
return False # 或者可以抛出 404 异常,由路由处理
return False
# 执行删除
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
@@ -373,10 +358,36 @@ async def delete_error_log_by_id(log_id: int) -> bool:
return True
except Exception as e:
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
# --- RequestLog Services (保持异步) ---
raise
async def delete_all_error_logs() -> int:
"""
删除所有错误日志条目。
Returns:
int: 被删除的错误日志数量。
"""
try:
# 1. 获取删除前的总数
count_query = select(func.count()).select_from(ErrorLog)
total_to_delete = await database.fetch_val(count_query)
if total_to_delete == 0:
logger.info("No error logs found to delete.")
return 0
# 2. 执行删除操作
delete_query = delete(ErrorLog)
await database.execute(delete_query)
logger.info(f"Successfully deleted all {total_to_delete} error logs.")
return total_to_delete
except Exception as e:
logger.error(f"Failed to delete all error logs: {str(e)}", exc_info=True)
raise
# 新增函数:添加请求日志
async def add_request_log(
model_name: Optional[str],
@@ -412,7 +423,6 @@ async def add_request_log(
latency_ms=latency_ms
)
await database.execute(query)
# logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level
return True
except Exception as e:
logger.error(f"Failed to add request log: {str(e)}")

View File

@@ -40,15 +40,16 @@ class GenerationConfig(BaseModel):
frequencyPenalty: Optional[float] = None
responseLogprobs: Optional[bool] = None
logprobs: Optional[int] = None
thinkingConfig: Optional[Dict[str, Any]] = None
class SystemInstruction(BaseModel):
role: str = "system"
parts: List[Dict[str, Any]] | Dict[str, Any]
role: Optional[str] = "system"
parts: Union[List[Dict[str, Any]], Dict[str, Any]]
class GeminiContent(BaseModel):
role: str
role: Optional[str] = None
parts: List[Dict[str, Any]]

View File

@@ -1,23 +1,20 @@
from typing import Union
class ImageMetadata:
def __init__(self, width: int, height: int, filename: str, size: int, url: str, delete_url: str | None = None):
def __init__(self, width: int, height: int, filename: str, size: int, url: str, delete_url: Union[str, None] = None):
self.width = width
self.height = height
self.filename = filename
self.size = size
self.url = url
self.delete_url = delete_url
class UploadResponse:
def __init__(self, success: bool, code: str, message: str, data: ImageMetadata):
self.success = success
self.code = code
self.message = message
self.data = data
class ImageUploader:
def upload(self, file: bytes, filename: str) -> UploadResponse:
raise NotImplementedError

View File

@@ -32,4 +32,11 @@ class ImageGenerationRequest(BaseModel):
size: Optional[str] = "1024x1024"
quality: Optional[str] = None
style: Optional[str] = None
response_format: Optional[str] = "b64_json"
response_format: Optional[str] = "url"
class TTSRequest(BaseModel):
model: str = "gemini-2.5-flash-preview-tts"
input: str
voice: str = "Kore"
response_format: Optional[str] = "wav"

View File

@@ -128,12 +128,7 @@ class OpenAIMessageConverter(MessageConverter):
raise ValueError(f"Unsupported media format: {format}")
try:
# Decode Base64 to check size
# Be careful with memory usage for very large files
# Consider streaming decoding or checking length heuristic first if memory is a concern
decoded_data = base64.b64decode(
data, validate=True
) # Use validate=True for stricter check
decoded_data = base64.b64decode(data, validate=True)
if len(decoded_data) > max_size:
logger.error(
f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)."
@@ -141,7 +136,6 @@ class OpenAIMessageConverter(MessageConverter):
raise ValueError(
f"Media data size exceeds limit of {max_size // 1024 // 1024}MB"
)
# No need to return decoded_data, just the original base64 if valid
return data
except base64.binascii.Error as e:
logger.error(f"Invalid Base64 data provided: {e}")
@@ -163,7 +157,6 @@ class OpenAIMessageConverter(MessageConverter):
if "content" in msg and isinstance(msg["content"], list):
for content_item in msg["content"]:
if not isinstance(content_item, dict):
# Skip non-dict items if any unexpected format appears
logger.warning(
f"Skipping unexpected content item format: {type(content_item)}"
)
@@ -184,13 +177,11 @@ class OpenAIMessageConverter(MessageConverter):
logger.error(
f"Failed to convert image URL {content_item['image_url']['url']}: {e}"
)
# Decide how to handle: skip part, add error text, etc.
parts.append(
{
"text": f"[Error processing image: {content_item['image_url']['url']}]"
}
)
# --- Add handling for input_audio ---
elif content_type == "input_audio" and content_item.get(
"input_audio"
):
@@ -205,7 +196,6 @@ class OpenAIMessageConverter(MessageConverter):
continue
try:
# Validate size and format
validated_data = self._validate_media_data(
audio_format,
audio_data,

View File

@@ -29,7 +29,7 @@ class GeminiResponseHandler(ResponseHandler):
self.thinking_status = False
def handle_response(
self, response: Dict[str, Any], model: str, stream: bool = False
self, response: Dict[str, Any], model: str, stream: bool = False, usage_metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
if stream:
return _handle_gemini_stream_response(response, model, stream)
@@ -37,31 +37,33 @@ class GeminiResponseHandler(ResponseHandler):
def _handle_openai_stream_response(
response: Dict[str, Any], model: str, finish_reason: str
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
text, reasoning_content, tool_calls, _ = _extract_result(
response, model, stream=True, gemini_format=False
)
if not text and not tool_calls:
if not text and not tool_calls and not reasoning_content:
delta = {}
else:
delta = {"content": text, "role": "assistant"}
delta = {"content": text, "reasoning_content": reasoning_content, "role": "assistant"}
if tool_calls:
delta["tool_calls"] = tool_calls
return {
template_chunk = {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
if usage_metadata:
template_chunk["usage"] = {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)}
return template_chunk
def _handle_openai_normal_response(
response: Dict[str, Any], model: str, finish_reason: str
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
text, reasoning_content, tool_calls, _ = _extract_result(
response, model, stream=False, gemini_format=False
)
return {
@@ -75,12 +77,13 @@ def _handle_openai_normal_response(
"message": {
"role": "assistant",
"content": text,
"reasoning_content": reasoning_content,
"tool_calls": tool_calls,
},
"finish_reason": finish_reason,
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"usage": {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)},
}
@@ -98,10 +101,11 @@ class OpenAIResponseHandler(ResponseHandler):
model: str,
stream: bool = False,
finish_reason: str = None,
usage_metadata: Optional[Dict[str, Any]] = 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)
return _handle_openai_stream_response(response, model, finish_reason, usage_metadata)
return _handle_openai_normal_response(response, model, finish_reason, usage_metadata)
def handle_image_chat_response(
self, image_str: str, model: str, stream=False, finish_reason="stop"
@@ -153,17 +157,22 @@ def _extract_result(
model: str,
stream: bool = False,
gemini_format: bool = False,
) -> tuple[str, List[Dict[str, Any]]]:
text, tool_calls = "", []
) -> tuple[str, Optional[str], List[Dict[str, Any]], Optional[bool]]:
text, reasoning_content, tool_calls, thought = "", "", [], None
if stream:
if response.get("candidates"):
candidate = response["candidates"][0]
content = candidate.get("content", {})
parts = content.get("parts", [])
if not parts:
return "", []
return "", None, [], None
if "text" in parts[0]:
text = parts[0].get("text")
if "thought" in parts[0]:
if not gemini_format and settings.SHOW_THINKING_PROCESS:
reasoning_content = text
text = ""
thought = parts[0].get("thought")
elif "executableCode" in parts[0]:
text = _format_code_block(parts[0]["executableCode"])
elif "codeExecution" in parts[0]:
@@ -181,30 +190,18 @@ def _extract_result(
else:
if response.get("candidates"):
candidate = response["candidates"][0]
if "thinking" in model:
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"]
)
else:
text = candidate["content"]["parts"][0]["text"]
else:
if len(candidate["content"]["parts"]) == 2:
text = candidate["content"]["parts"][1]["text"]
else:
text = candidate["content"]["parts"][0]["text"]
else:
text = ""
if "parts" in candidate["content"]:
for part in candidate["content"]["parts"]:
if "text" in part:
text, reasoning_content = "", ""
if "parts" in candidate["content"]:
for part in candidate["content"]["parts"]:
if "text" in part:
if "thought" in part and settings.SHOW_THINKING_PROCESS:
reasoning_content += part["text"]
else:
text += part["text"]
elif "inlineData" in part:
text += _extract_image_data(part)
if "thought" in part and thought is None:
thought = part.get("thought")
elif "inlineData" in part:
text += _extract_image_data(part)
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(
@@ -212,7 +209,7 @@ def _extract_result(
)
else:
text = "暂无返回"
return text, tool_calls
return text, reasoning_content, tool_calls, thought
def _extract_image_data(part: dict) -> str:
@@ -230,6 +227,7 @@ def _extract_image_data(part: dict) -> str:
provider=settings.UPLOAD_PROVIDER,
base_url=settings.CLOUDFLARE_IMGBED_URL,
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
)
current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
@@ -285,13 +283,16 @@ def _extract_tool_calls(
def _handle_gemini_stream_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
text, reasoning_content, tool_calls, thought = _extract_result(
response, model, stream=stream, gemini_format=True
)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
content = {"parts": [{"text": text}], "role": "model"}
part = {"text": text}
if thought is not None:
part["thought"] = thought
content = {"parts": [part], "role": "model"}
response["candidates"][0]["content"] = content
return response
@@ -299,13 +300,18 @@ def _handle_gemini_stream_response(
def _handle_gemini_normal_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
text, reasoning_content, tool_calls, thought = _extract_result(
response, model, stream=stream, gemini_format=True
)
parts = []
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
parts = tool_calls
else:
content = {"parts": [{"text": text}], "role": "model"}
if thought is not None:
parts.append({"text": reasoning_content,"thought": thought})
part = {"text": text}
parts.append(part)
content = {"parts": parts, "role": "model"}
response["candidates"][0]["content"] = content
return response

View File

@@ -2,7 +2,7 @@
from functools import wraps
from typing import Callable, TypeVar
from app.core.constants import MAX_RETRIES
from app.config.config import settings
from app.log.logger import get_retry_logger
T = TypeVar("T")
@@ -12,8 +12,7 @@ logger = get_retry_logger()
class RetryHandler:
"""重试处理装饰器"""
def __init__(self, max_retries: int = MAX_RETRIES, key_arg: str = "api_key"):
self.max_retries = max_retries
def __init__(self, key_arg: str = "api_key"):
self.key_arg = key_arg
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
@@ -21,14 +20,14 @@ class RetryHandler:
async def wrapper(*args, **kwargs) -> T:
last_exception = None
for attempt in range(self.max_retries):
for attempt in range(settings.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}"
f"API call failed with error: {str(e)}. Attempt {retries} of {settings.MAX_RETRIES}"
)
# 从函数参数中获取 key_manager

View File

@@ -217,4 +217,17 @@ def get_api_client_logger():
def get_openai_compatible_logger():
return Logger.setup_logger("openai_compatible")
return Logger.setup_logger("openai_compatible")
def get_error_log_logger():
return Logger.setup_logger("error_log")
def get_request_log_logger():
return Logger.setup_logger("request_log")
def get_vertex_express_logger():
return Logger.setup_logger("vertex_express")

View File

@@ -1,4 +1,8 @@
import uvicorn
from dotenv import load_dotenv
# 在导入应用程序配置之前加载 .env 文件到环境变量
load_dotenv()
from app.core.application import create_app
from app.log.logger import get_main_logger

View File

@@ -8,6 +8,7 @@ from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
# from app.middleware.request_logging_middleware import RequestLoggingMiddleware
from app.middleware.smart_routing_middleware import SmartRoutingMiddleware
from app.core.constants import API_VERSION
from app.core.security import verify_auth_token
from app.log.logger import get_middleware_logger
@@ -32,6 +33,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
and not request.url.path.startswith("/hf")
and not request.url.path.startswith("/openai")
and not request.url.path.startswith("/api/version/check")
and not request.url.path.startswith("/vertex-express")
):
auth_token = request.cookies.get("auth_token")
@@ -51,6 +53,9 @@ def setup_middlewares(app: FastAPI) -> None:
Args:
app: FastAPI应用程序实例
"""
# 添加智能路由中间件(必须在认证中间件之前)
app.add_middleware(SmartRoutingMiddleware)
# 添加认证中间件
app.add_middleware(AuthMiddleware)
@@ -60,7 +65,7 @@ def setup_middlewares(app: FastAPI) -> None:
# 配置CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境建议配置具体的域名
allow_origins=["*"],
allow_credentials=True,
allow_methods=[
"GET",
@@ -68,8 +73,8 @@ def setup_middlewares(app: FastAPI) -> None:
"PUT",
"DELETE",
"OPTIONS",
], # 明确指定允许的HTTP方法
allow_headers=["*"], # 生产环境建议配置具体的请求头
expose_headers=["*"], # 允许前端访问的响应头
max_age=600, # 预检请求缓存时间(秒)
],
allow_headers=["*"],
expose_headers=["*"],
max_age=600,
)

View File

@@ -0,0 +1,210 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.config.config import settings
from app.log.logger import get_main_logger
import re
logger = get_main_logger()
class SmartRoutingMiddleware(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
# 简化的路由规则 - 直接根据检测结果路由
pass
async def dispatch(self, request: Request, call_next):
if not settings.URL_NORMALIZATION_ENABLED:
return await call_next(request)
logger.debug(f"request: {request}")
original_path = str(request.url.path)
method = request.method
# 尝试修复URL
fixed_path, fix_info = self.fix_request_url(original_path, method, request)
if fixed_path != original_path:
logger.info(f"URL fixed: {method} {original_path}{fixed_path}")
if fix_info:
logger.debug(f"Fix details: {fix_info}")
# 重写请求路径
request.scope["path"] = fixed_path
request.scope["raw_path"] = fixed_path.encode()
return await call_next(request)
def fix_request_url(self, path: str, method: str, request: Request) -> tuple:
"""简化的URL修复逻辑"""
# 首先检查是否已经是正确的格式,如果是则不处理
if self.is_already_correct_format(path):
return path, None
# 1. 最高优先级包含generateContent → Gemini格式
if "generatecontent" in path.lower() or "v1beta/models" in path.lower():
return self.fix_gemini_by_operation(path, method, request)
# 2. 第二优先级:包含/openai/ → OpenAI格式
if "/openai/" in path.lower():
return self.fix_openai_by_operation(path, method)
# 3. 第三优先级:包含/v1/ → v1格式
if "/v1/" in path.lower():
return self.fix_v1_by_operation(path, method)
# 4. 第四优先级:包含/chat/completions → chat功能
if "/chat/completions" in path.lower():
return "/v1/chat/completions", {"type": "v1_chat"}
# 5. 默认:原样传递
return path, None
def is_already_correct_format(self, path: str) -> bool:
"""检查是否已经是正确的API格式"""
# 检查是否已经是正确的端点格式
correct_patterns = [
r"^/v1beta/models/[^/:]+:(generate|streamGenerate)Content$", # Gemini原生
r"^/gemini/v1beta/models/[^/:]+:(generate|streamGenerate)Content$", # Gemini带前缀
r"^/v1beta/models$", # Gemini模型列表
r"^/gemini/v1beta/models$", # Gemini带前缀的模型列表
r"^/v1/(chat/completions|models|embeddings|images/generations|audio/speech)$", # v1格式
r"^/openai/v1/(chat/completions|models|embeddings|images/generations|audio/speech)$", # OpenAI格式
r"^/hf/v1/(chat/completions|models|embeddings|images/generations|audio/speech)$", # HF格式
r"^/vertex-express/v1beta/models/[^/:]+:(generate|streamGenerate)Content$", # Vertex Express Gemini格式
r"^/vertex-express/v1beta/models$", # Vertex Express模型列表
r"^/vertex-express/v1/(chat/completions|models|embeddings|images/generations)$", # Vertex Express OpenAI格式
]
for pattern in correct_patterns:
if re.match(pattern, path):
return True
return False
def fix_gemini_by_operation(
self, path: str, method: str, request: Request
) -> tuple:
"""根据Gemini操作修复考虑端点偏好"""
if method == "GET":
return "/v1beta/models", {
"role": "gemini_models",
}
# 提取模型名称
try:
model_name = self.extract_model_name(path, request)
except ValueError:
# 无法提取模型名称,返回原路径不做处理
return path, None
# 检测是否为流式请求
is_stream = self.detect_stream_request(path, request)
# 检查是否有vertex-express偏好
if "/vertex-express/" in path.lower():
if is_stream:
target_url = (
f"/vertex-express/v1beta/models/{model_name}:streamGenerateContent"
)
else:
target_url = (
f"/vertex-express/v1beta/models/{model_name}:generateContent"
)
fix_info = {
"rule": (
"vertex_express_generate"
if not is_stream
else "vertex_express_stream"
),
"preference": "vertex_express_format",
"is_stream": is_stream,
"model": model_name,
}
else:
# 标准Gemini端点
if is_stream:
target_url = f"/v1beta/models/{model_name}:streamGenerateContent"
else:
target_url = f"/v1beta/models/{model_name}:generateContent"
fix_info = {
"rule": "gemini_generate" if not is_stream else "gemini_stream",
"preference": "gemini_format",
"is_stream": is_stream,
"model": model_name,
}
return target_url, fix_info
def fix_openai_by_operation(self, path: str, method: str) -> tuple:
"""根据操作类型修复OpenAI格式"""
if method == "POST":
if "chat" in path.lower() or "completion" in path.lower():
return "/openai/v1/chat/completions", {"type": "openai_chat"}
elif "embedding" in path.lower():
return "/openai/v1/embeddings", {"type": "openai_embeddings"}
elif "image" in path.lower():
return "/openai/v1/images/generations", {"type": "openai_images"}
elif "audio" in path.lower():
return "/openai/v1/audio/speech", {"type": "openai_audio"}
elif method == "GET":
if "model" in path.lower():
return "/openai/v1/models", {"type": "openai_models"}
return path, None
def fix_v1_by_operation(self, path: str, method: str) -> tuple:
"""根据操作类型修复v1格式"""
if method == "POST":
if "chat" in path.lower() or "completion" in path.lower():
return "/v1/chat/completions", {"type": "v1_chat"}
elif "embedding" in path.lower():
return "/v1/embeddings", {"type": "v1_embeddings"}
elif "image" in path.lower():
return "/v1/images/generations", {"type": "v1_images"}
elif "audio" in path.lower():
return "/v1/audio/speech", {"type": "v1_audio"}
elif method == "GET":
if "model" in path.lower():
return "/v1/models", {"type": "v1_models"}
return path, None
def detect_stream_request(self, path: str, request: Request) -> bool:
"""检测是否为流式请求"""
# 1. 路径中包含stream关键词
if "stream" in path.lower():
return True
# 2. 查询参数
if request.query_params.get("stream") == "true":
return True
return False
def extract_model_name(self, path: str, request: Request) -> str:
"""从请求中提取模型名称用于构建Gemini API URL"""
# 1. 从请求体中提取
try:
if hasattr(request, "_body") and request._body:
import json
body = json.loads(request._body.decode())
if "model" in body and body["model"]:
return body["model"]
except Exception:
pass
# 2. 从查询参数中提取
model_param = request.query_params.get("model")
if model_param:
return model_param
# 3. 从路径中提取(用于已包含模型名称的路径)
match = re.search(r"/models/([^/:]+)", path, re.IGNORECASE)
if match:
return match.group(1)
# 4. 如果无法提取模型名称,抛出异常
raise ValueError("Unable to extract model name from request")

View File

@@ -1,15 +1,17 @@
"""
配置路由模块
"""
from typing import Any, Dict
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from app.core.security import verify_auth_token
from app.log.logger import get_config_routes_logger, Logger # 导入 Logger 类
from app.log.logger import Logger, get_config_routes_logger
from app.service.config.config_service import ConfigService
# 创建路由
router = APIRouter(prefix="/api/config", tags=["config"])
logger = get_config_routes_logger()
@@ -34,10 +36,10 @@ async def update_config(config_data: Dict[str, Any], request: Request):
result = await ConfigService.update_config(config_data)
# 配置更新成功后,立即更新所有 logger 的级别
Logger.update_log_levels(config_data["LOG_LEVEL"])
logger.info("Log levels updated after configuration change.") # 添加日志记录
logger.info("Log levels updated after configuration change.")
return result
except Exception as e:
logger.error(f"Error updating config or log levels: {e}", exc_info=True) # 记录详细错误
logger.error(f"Error updating config or log levels: {e}", exc_info=True)
raise HTTPException(status_code=400, detail=str(e))
@@ -51,3 +53,81 @@ async def reset_config(request: Request):
return await ConfigService.reset_config()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
class DeleteKeysRequest(BaseModel):
keys: List[str] = Field(..., description="List of API keys to delete")
@router.delete("/keys/{key_to_delete}", response_model=Dict[str, Any])
async def delete_single_key(key_to_delete: str, request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized attempt to delete key: {key_to_delete}")
return RedirectResponse(url="/", status_code=302)
try:
logger.info(f"Attempting to delete key: {key_to_delete}")
result = await ConfigService.delete_key(key_to_delete)
if not result.get("success"):
raise HTTPException(
status_code=(
404 if "not found" in result.get("message", "").lower() else 400
),
detail=result.get("message"),
)
return result
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Error deleting key: {str(e)}")
@router.post("/keys/delete-selected", response_model=Dict[str, Any])
async def delete_selected_keys_route(
delete_request: DeleteKeysRequest, request: Request
):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized attempt to bulk delete keys")
return RedirectResponse(url="/", status_code=302)
if not delete_request.keys:
logger.warning("Attempt to bulk delete keys with an empty list.")
raise HTTPException(status_code=400, detail="No keys provided for deletion.")
try:
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
result = await ConfigService.delete_selected_keys(delete_request.keys)
if not result.get("success") and result.get("deleted_count", 0) == 0:
raise HTTPException(
status_code=400, detail=result.get("message", "Failed to delete keys.")
)
return result
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Error bulk deleting keys: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Error bulk deleting keys: {str(e)}"
)
@router.get("/ui/models")
async def get_ui_models(request: Request):
auth_token_cookie = request.cookies.get("auth_token")
if not auth_token_cookie or not verify_auth_token(auth_token_cookie):
logger.warning("Unauthorized access attempt to /api/config/ui/models")
raise HTTPException(status_code=403, detail="Not authenticated")
try:
models = await ConfigService.fetch_ui_models()
return models
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Unexpected error in /ui/models endpoint: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred while fetching UI models: {str(e)}",
)

View File

@@ -1,55 +1,69 @@
"""
日志路由模块
"""
from typing import List, Optional, Dict
from datetime import datetime
from typing import Dict, List, Optional
from fastapi import (
APIRouter,
Body,
HTTPException,
Path,
Query,
Request,
Response,
status,
)
from pydantic import BaseModel
from 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
from app.service.error_log import error_log_service
# 创建路由
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 # 列表显示错误码 (应为整数)
error_code: Optional[int] = None
model_name: Optional[str] = None
request_time: Optional[datetime] = None
class ErrorLogListResponse(BaseModel):
logs: List[ErrorLogListItem] # 使用定义的模型列表
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')") # 新增排序参数
key_search: Optional[str] = Query(
None, description="Search term for Gemini key (partial match)"
),
error_search: Optional[str] = Query(
None, description="Search term for error type or log message"
),
error_code_search: Optional[str] = Query(
None, description="Search term for error code"
),
start_date: Optional[datetime] = Query(
None, description="Start datetime for filtering"
),
end_date: Optional[datetime] = Query(
None, description="End datetime for filtering"
),
sort_by: str = Query(
"id", description="Field to sort by (e.g., 'id', 'request_time')"
),
sort_order: str = Query("desc", description="Sort order ('asc' or 'desc')"),
):
"""
获取错误日志列表 (返回错误码),支持过滤和排序
@@ -72,49 +86,42 @@ async def get_error_logs_api(
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(
result = await error_log_service.process_get_error_logs(
limit=limit,
offset=offset,
key_search=key_search,
error_search=error_search, # 数据库查询需要处理这个
error_code_search=error_code_search, # Pass error code search to DB function
error_search=error_search,
error_code_search=error_code_search,
start_date=start_date,
end_date=end_date,
sort_by=sort_by, # 传递排序参数
sort_order=sort_order # 传递排序参数
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 模型
logs_data = result["logs"]
total_count = result["total"]
validated_logs = [ErrorLogListItem(**log) for log in logs_data]
return ErrorLogListResponse(logs=validated_logs, total=total_count)
except Exception as e:
logger.exception(f"Failed to get error logs list: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error logs list: {str(e)}")
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
error_log: Optional[str] = None
request_msg: Optional[str] = None
model_name: Optional[str] = None
request_time: Optional[datetime] = None
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=1)):
"""
@@ -122,31 +129,31 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=
"""
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}")
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)
log_details = await error_log_service.process_get_error_log_details(
log_id=log_id
)
if not log_details:
raise HTTPException(status_code=404, detail="Error log not found")
# 假设 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)}")
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
request: Request, payload: Dict[str, List[int]] = Body(...)
):
"""
批量删除错误日志 (异步)
@@ -161,23 +168,45 @@ async def delete_error_logs_bulk_api(
raise HTTPException(status_code=400, detail="No log IDs provided for deletion.")
try:
# 调用异步服务函数
deleted_count = await delete_error_logs_by_ids(log_ids)
deleted_count = await error_log_service.process_delete_error_logs_by_ids(
log_ids
)
# 注意:异步函数返回的是尝试删除的数量,可能不是精确值
logger.info(f"Attempted bulk deletion for {deleted_count} error logs with IDs: {log_ids}")
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")
raise HTTPException(
status_code=500, detail="Internal server error during bulk deletion"
)
# 新增:删除单个错误日志
@router.delete("/errors/all", status_code=status.HTTP_204_NO_CONTENT)
async def delete_all_error_logs_api(request: Request):
"""
删除所有错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to delete all error logs")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
deleted_count = await error_log_service.process_delete_all_error_logs()
logger.info(f"Successfully deleted all {deleted_count} error logs.")
# No body needed for 204 response
return Response(status_code=status.HTTP_204_NO_CONTENT)
except Exception as e:
logger.exception(f"Error deleting all error logs: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error during deletion of all logs"
)
@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
):
async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
"""
删除单个错误日志 (异步)
"""
@@ -185,17 +214,20 @@ async def delete_error_log_api(
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)
success = await error_log_service.process_delete_error_log_by_id(log_id)
if not success:
# 服务层现在在未找到时返回 False我们在这里转换为 404
raise HTTPException(status_code=404, detail=f"Error log with ID {log_id} not found")
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
raise http_exc
except Exception as e:
logger.exception(f"Error deleting error log with ID {log_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error during deletion")
raise HTTPException(
status_code=500, detail="Internal server error during deletion"
)

View File

@@ -5,7 +5,7 @@ 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.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
@@ -13,12 +13,10 @@ 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()
@@ -52,14 +50,14 @@ async def list_models(
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.")
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)
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.")
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
models_json = deepcopy(models_data) # 操作副本以防修改原始缓存
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):
@@ -74,7 +72,6 @@ async def list_models(
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")
@@ -88,7 +85,6 @@ async def list_models(
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)}")
@@ -99,7 +95,7 @@ async def list_models(
@router.post("/models/{model_name}:generateContent")
@router_v1beta.post("/models/{model_name}:generateContent")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
@RetryHandler(key_arg="api_key")
async def generate_content(
model_name: str,
request: GeminiRequest,
@@ -128,7 +124,7 @@ async def generate_content(
@router.post("/models/{model_name}:streamGenerateContent")
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
@RetryHandler(key_arg="api_key")
async def stream_generate_content(
model_name: str,
request: GeminiRequest,
@@ -139,7 +135,6 @@ async def stream_generate_content(
):
"""处理 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)}")
@@ -153,9 +148,38 @@ async def stream_generate_content(
request=request,
api_key=api_key
)
# 注意:流本身的错误需要在服务层或流迭代中处理,这里只返回流响应
return StreamingResponse(response_stream, media_type="text/event-stream")
@router.post("/models/{model_name}:countTokens")
@router_v1beta.post("/models/{model_name}:countTokens")
@RetryHandler(key_arg="api_key")
async def count_tokens(
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 token 计数请求。"""
operation_name = "gemini_count_tokens"
async with handle_route_errors(logger, operation_name, failure_message="Token counting failed"):
logger.info(f"Handling Gemini token count 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.count_tokens(
model=model_name,
request=request,
api_key=api_key
)
return response
@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密钥的失败计数可选择性地仅重置有效或无效密钥"""
@@ -203,7 +227,7 @@ async def reset_selected_key_fail_counts(
"""批量重置选定Gemini API密钥的失败计数"""
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
keys_to_reset = request.keys
key_type = request.key_type # 获取类型用于日志记录和响应消息
key_type = request.key_type
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
if not keys_to_reset:
@@ -219,38 +243,31 @@ async def reset_selected_key_fail_counts(
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
status_code = 207 if final_success and errors else 500
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密钥的失败计数"""
@@ -266,6 +283,7 @@ async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(g
logger.error(f"Failed to reset key failure count: {str(e)}")
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密钥的有效性"""
@@ -273,14 +291,14 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
logger.info("Verifying API key validity")
try:
# 使用generate_content接口测试key的有效性
gemini_request = GeminiRequest(
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}]
parts=[{"text": "hi"}],
)
]
],
generation_config={"temperature": 0.7, "top_p": 1.0, "max_output_tokens": 10}
)
response = await chat_service.generate_content(
@@ -294,7 +312,6 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
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
@@ -318,79 +335,63 @@ async def verify_selected_keys(
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
successful_keys = []
failed_keys = {} # 存储失败的 key 和错误信息
failed_keys = {}
async def _verify_single_key(api_key: str):
"""内部函数,用于验证单个密钥并处理异常"""
nonlocal successful_keys, failed_keys # 允许修改外部列表和字典
nonlocal successful_keys, failed_keys
try:
# 重用单密钥验证逻辑的核心部分
gemini_request = GeminiRequest(
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])]
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])],
generation_config={"temperature": 0.7, "top_p": 1.0, "max_output_tokens": 10}
)
# 注意:这里直接调用 chat_service.generate_content不依赖于 key_manager 获取密钥
await chat_service.generate_content(
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 和错误信息
failed_keys[api_key] = error_message
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 捕获任务本身的异常
results = await asyncio.gather(*tasks, 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, # 表示请求处理完成,具体结果看内容
"success": True,
"message": message,
"successful_keys": successful_keys,
"failed_keys": failed_keys,
"valid_count": valid_count, # 保留计数方便前端快速展示
"valid_count": valid_count,
"invalid_count": invalid_count
})
else:
# 完全成功
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
return JSONResponse({
"success": True,

View File

@@ -18,7 +18,6 @@ from app.service.openai_compatiable.openai_compatiable_service import OpenAIComp
router = APIRouter()
logger = get_openai_compatible_logger()
# 初始化服务
security_service = SecurityService()
async def get_key_manager():
@@ -52,7 +51,7 @@ async def list_models(
@router.post("/openai/v1/chat/completions")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
@RetryHandler(key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),
@@ -62,29 +61,23 @@ async def chat_completion(
):
"""处理聊天补全请求,支持流式响应和特定模型切换。"""
operation_name = "chat_completion"
# 检查是否为图像生成相关的聊天模型,如果是,则使用付费密钥
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
current_api_key = api_key # 保存原始key可能是普通key
current_api_key = api_key
if is_image_chat:
current_api_key = await key_manager.get_paid_key() # 获取付费密钥
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
logger.info(f"Using API key: {current_api_key}")
if is_image_chat:
# 图像生成聊天,调用特定服务,不处理流式
response = await openai_service.create_image_chat_completion(request, current_api_key)
return response # 直接返回结果
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
@@ -98,7 +91,6 @@ async def generate_image(
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)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from app.config.config import settings
@@ -7,24 +7,26 @@ from app.domain.openai_models import (
ChatRequest,
EmbeddingRequest,
ImageGenerationRequest,
TTSRequest,
)
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors # 导入共享错误处理器
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.tts.tts_service import TTSService
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()
tts_service = TTSService()
async def get_key_manager():
@@ -42,6 +44,11 @@ async def get_openai_chat_service(key_manager: KeyManager = Depends(get_key_mana
return OpenAIChatService(settings.BASE_URL, key_manager)
async def get_tts_service():
"""获取TTS服务实例"""
return tts_service
@router.get("/v1/models")
@router.get("/hf/v1/models")
async def list_models(
@@ -59,45 +66,40 @@ async def list_models(
@router.post("/v1/chat/completions")
@router.post("/hf/v1/chat/completions")
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
@RetryHandler(key_arg="api_key")
async def chat_completion(
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
key_manager: KeyManager = Depends(get_key_manager),
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
current_api_key = api_key
if is_image_chat:
current_api_key = await key_manager.get_paid_key() # 获取付费密钥
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
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
@@ -111,8 +113,6 @@ async def generate_image(
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
@@ -155,3 +155,21 @@ async def get_keys_list(
},
"total": len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]),
}
@router.post("/v1/audio/speech")
@router.post("/hf/v1/audio/speech")
async def text_to_speech(
request: TTSRequest,
_=Depends(security_service.verify_authorization),
api_key: str = Depends(get_next_working_key_wrapper),
tts_service: TTSService = Depends(get_tts_service),
):
"""处理 OpenAI TTS 请求。"""
operation_name = "text_to_speech"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling TTS request for model: {request.model}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
audio_data = await tts_service.create_tts(request, api_key)
return Response(content=audio_data, media_type="audio/wav")

View File

@@ -8,13 +8,12 @@ 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.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_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")
@@ -25,7 +24,6 @@ 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)
@@ -35,13 +33,12 @@ def setup_routers(app: FastAPI) -> None:
app.include_router(stats_routes.router)
app.include_router(version_routes.router)
app.include_router(openai_compatiable_routes.router)
app.include_router(vertex_express_routes.router)
# 添加页面路由
setup_page_routes(app)
# 添加健康检查路由
setup_health_routes(app)
setup_api_stats_routes(app) # Add API stats routes
setup_api_stats_routes(app)
def setup_page_routes(app: FastAPI) -> None:
@@ -106,16 +103,14 @@ def setup_page_routes(app: FastAPI) -> None:
"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
"total_keys": total_keys,
"valid_key_count": valid_key_count,
"invalid_key_count": invalid_key_count,
"api_stats": api_stats,
},
)
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)
@@ -175,16 +170,13 @@ def setup_api_stats_routes(app: FastAPI) -> None:
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
stats_service = StatsService()
details = await stats_service.get_api_call_details(period)
return details
except ValueError as e:

View File

@@ -2,22 +2,20 @@
定时任务控制路由模块
"""
from fastapi import APIRouter, Request, HTTPException, status # 移除 Depends, 添加 Request
from fastapi import APIRouter, Request, HTTPException, status
from fastapi.responses import JSONResponse
from 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 # 使用路由日志记录器
from app.core.security import verify_auth_token
from app.scheduler.scheduled_tasks 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):
@@ -29,14 +27,12 @@ async def verify_token(request: Request):
)
@router.post("/start", summary="启动定时任务")
async def start_scheduler_endpoint(request: Request): # 添加 request 参数
async def start_scheduler_endpoint(request: Request):
"""Start the background scheduler task"""
"""
await verify_token(request) # 在函数开始处进行认证检查
"""
await verify_token(request)
try:
logger.info("Received request to start scheduler.")
start_scheduler() # 调用 key_checker 中的函数
start_scheduler()
return JSONResponse(content={"message": "Scheduler started successfully."}, status_code=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error starting scheduler: {str(e)}", exc_info=True)
@@ -46,14 +42,12 @@ async def start_scheduler_endpoint(request: Request): # 添加 request 参数
)
@router.post("/stop", summary="停止定时任务")
async def stop_scheduler_endpoint(request: Request): # 添加 request 参数
async def stop_scheduler_endpoint(request: Request):
"""Stop the background scheduler task"""
"""
await verify_token(request) # 在函数开始处进行认证检查
"""
await verify_token(request)
try:
logger.info("Received request to stop scheduler.")
stop_scheduler() # 调用 key_checker 中的函数
stop_scheduler()
return JSONResponse(content={"message": "Scheduler stopped successfully."}, status_code=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error stopping scheduler: {str(e)}", exc_info=True)

View File

@@ -45,9 +45,6 @@ async def get_key_usage_details(key: str):
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:

View File

@@ -21,10 +21,9 @@ async def get_version_info():
检查当前应用程序版本与最新的 GitHub release 版本。
"""
try:
current_version = get_current_version() # Use imported function
current_version = get_current_version()
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(

View File

@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from copy import deepcopy
from app.config.config import settings
from app.log.logger import get_vertex_express_logger
from app.core.security import SecurityService
from app.domain.gemini_models import GeminiRequest
from app.service.chat.vertex_express_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"/vertex-express/{API_VERSION}")
logger = get_vertex_express_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_vertex_key()
async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
"""获取Gemini聊天服务实例"""
return GeminiChatService(settings.VERTEX_EXPRESS_BASE_URL, key_manager)
@router.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:
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")
@RetryHandler(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")
@RetryHandler(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")

View File

@@ -1,102 +0,0 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.service.key.key_manager import get_key_manager_instance
from app.service.chat.gemini_chat_service import GeminiChatService
from app.domain.gemini_models import GeminiRequest, GeminiContent
from app.config.config import settings
from app.log.logger import Logger # 导入 Logger 类
logger = Logger.setup_logger("scheduler") # 使用 Logger.setup_logger
async def check_failed_keys():
"""
定时检查失败次数大于0的API密钥并尝试验证它们。
如果验证成功,重置失败计数;如果失败,增加失败计数。
"""
logger.info("Starting scheduled check for failed API keys...")
try:
key_manager = await get_key_manager_instance()
# 确保 KeyManager 已经初始化
if not key_manager or not hasattr(key_manager, 'key_failure_counts'):
logger.warning("KeyManager instance not available or not initialized. Skipping check.")
return
# 创建 GeminiChatService 实例用于验证
# 注意:这里直接创建实例,而不是通过依赖注入,因为这是后台任务
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
# 获取需要检查的 key 列表 (失败次数 > 0)
keys_to_check = []
async with key_manager.failure_count_lock: # 访问共享数据需要加锁
# 复制一份以避免在迭代时修改字典
failure_counts_copy = key_manager.key_failure_counts.copy()
keys_to_check = [key for key, count in failure_counts_copy.items() if count > 0] # 检查所有失败次数大于0的key
if not keys_to_check:
logger.info("No keys with failure count > 0 found. Skipping verification.")
return
logger.info(f"Found {len(keys_to_check)} keys with failure count > 0 to verify.")
for key in keys_to_check:
# 隐藏部分 key 用于日志记录
log_key = f"{key[:4]}...{key[-4:]}" if len(key) > 8 else key
logger.info(f"Verifying key: {log_key}...")
try:
# 构造测试请求
gemini_request = GeminiRequest(
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}] # 使用简单的文本进行验证
)
]
)
# 调用 generate_content 进行验证
await chat_service.generate_content(
settings.TEST_MODEL, # 使用配置中定义的测试模型
gemini_request,
key
)
# 如果没有抛出异常,说明 key 有效
logger.info(f"Key {log_key} verification successful. Resetting failure count.")
await key_manager.reset_key_failure_count(key)
except Exception as e:
# 验证失败,增加失败计数
logger.warning(f"Key {log_key} verification failed: {str(e)}. Incrementing failure count.")
# 直接操作计数器,需要加锁
async with key_manager.failure_count_lock:
# 再次检查 key 是否存在且失败次数未达上限
if key in key_manager.key_failure_counts and key_manager.key_failure_counts[key] < key_manager.MAX_FAILURES:
key_manager.key_failure_counts[key] += 1
logger.info(f"Failure count for key {log_key} incremented to {key_manager.key_failure_counts[key]}.")
elif key in key_manager.key_failure_counts:
logger.warning(f"Key {log_key} reached MAX_FAILURES ({key_manager.MAX_FAILURES}). Not incrementing further.")
except Exception as e:
logger.error(f"An error occurred during the scheduled key check: {str(e)}", exc_info=True)
def setup_scheduler():
"""设置并启动 APScheduler"""
scheduler = AsyncIOScheduler(timezone=str(settings.TIMEZONE)) # 从配置读取时区
# 添加定时任务,例如每小时执行一次 (可以调整)
scheduler.add_job(check_failed_keys, 'interval', hours=settings.CHECK_INTERVAL_HOURS)
scheduler.start()
logger.info(f"Scheduler started. Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s).")
return scheduler
# 可以在这里添加一个全局的 scheduler 实例,以便在应用关闭时优雅地停止
scheduler_instance = None
def start_scheduler():
global scheduler_instance
if scheduler_instance is None 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

@@ -0,0 +1,159 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config.config import settings
from app.domain.gemini_models import GeminiContent, GeminiRequest
from app.log.logger import Logger
from app.service.chat.gemini_chat_service import GeminiChatService
from app.service.error_log.error_log_service import delete_old_error_logs
from app.service.key.key_manager import get_key_manager_instance
from app.service.request_log.request_log_service import delete_old_request_logs_task
logger = Logger.setup_logger("scheduler")
async def check_failed_keys():
"""
定时检查失败次数大于0的API密钥并尝试验证它们。
如果验证成功,重置失败计数;如果失败,增加失败计数。
"""
logger.info("Starting scheduled check for failed API keys...")
try:
key_manager = await get_key_manager_instance()
# 确保 KeyManager 已经初始化
if not key_manager or not hasattr(key_manager, "key_failure_counts"):
logger.warning(
"KeyManager instance not available or not initialized. Skipping check."
)
return
# 创建 GeminiChatService 实例用于验证
# 注意:这里直接创建实例,而不是通过依赖注入,因为这是后台任务
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
# 获取需要检查的 key 列表 (失败次数 > 0)
keys_to_check = []
async with key_manager.failure_count_lock: # 访问共享数据需要加锁
# 复制一份以避免在迭代时修改字典
failure_counts_copy = key_manager.key_failure_counts.copy()
keys_to_check = [
key for key, count in failure_counts_copy.items() if count > 0
] # 检查所有失败次数大于0的key
if not keys_to_check:
logger.info("No keys with failure count > 0 found. Skipping verification.")
return
logger.info(
f"Found {len(keys_to_check)} keys with failure count > 0 to verify."
)
for key in keys_to_check:
# 隐藏部分 key 用于日志记录
log_key = f"{key[:4]}...{key[-4:]}" if len(key) > 8 else key
logger.info(f"Verifying key: {log_key}...")
try:
# 构造测试请求
gemini_request = GeminiRequest(
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}],
)
]
)
await chat_service.generate_content(
settings.TEST_MODEL, gemini_request, key
)
logger.info(
f"Key {log_key} verification successful. Resetting failure count."
)
await key_manager.reset_key_failure_count(key)
except Exception as e:
logger.warning(
f"Key {log_key} verification failed: {str(e)}. Incrementing failure count."
)
# 直接操作计数器,需要加锁
async with key_manager.failure_count_lock:
# 再次检查 key 是否存在且失败次数未达上限
if (
key in key_manager.key_failure_counts
and key_manager.key_failure_counts[key]
< key_manager.MAX_FAILURES
):
key_manager.key_failure_counts[key] += 1
logger.info(
f"Failure count for key {log_key} incremented to {key_manager.key_failure_counts[key]}."
)
elif key in key_manager.key_failure_counts:
logger.warning(
f"Key {log_key} reached MAX_FAILURES ({key_manager.MAX_FAILURES}). Not incrementing further."
)
except Exception as e:
logger.error(
f"An error occurred during the scheduled key check: {str(e)}", exc_info=True
)
def setup_scheduler():
"""设置并启动 APScheduler"""
scheduler = AsyncIOScheduler(timezone=str(settings.TIMEZONE)) # 从配置读取时区
# 添加检查失败密钥的定时任务
scheduler.add_job(
check_failed_keys,
"interval",
hours=settings.CHECK_INTERVAL_HOURS,
id="check_failed_keys_job",
name="Check Failed API Keys",
)
logger.info(
f"Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s)."
)
# 新增添加自动删除错误日志的定时任务每天凌晨3点执行
scheduler.add_job(
delete_old_error_logs,
"cron",
hour=3,
minute=0,
id="delete_old_error_logs_job",
name="Delete Old Error Logs",
)
logger.info("Auto-delete error logs job scheduled to run daily at 3:00 AM.")
# 新增添加自动删除请求日志的定时任务每天凌晨3点05分执行
scheduler.add_job(
delete_old_request_logs_task,
"cron",
hour=3,
minute=5,
id="delete_old_request_logs_job",
name="Delete Old Request Logs",
)
logger.info(
f"Auto-delete request logs job scheduled to run daily at 3:05 AM, if enabled and AUTO_DELETE_REQUEST_LOGS_DAYS is set to {settings.AUTO_DELETE_REQUEST_LOGS_DAYS} days."
)
scheduler.start()
logger.info("Scheduler started with all jobs.")
return scheduler
# 可以在这里添加一个全局的 scheduler 实例,以便在应用关闭时优雅地停止
scheduler_instance = None
def start_scheduler():
global scheduler_instance
if scheduler_instance is None or not scheduler_instance.running:
logger.info("Starting scheduler...")
scheduler_instance = setup_scheduler()
logger.info("Scheduler is already running.")
def stop_scheduler():
global scheduler_instance
if scheduler_instance and scheduler_instance.running:
scheduler_instance.shutdown()
logger.info("Scheduler stopped.")

View File

@@ -2,17 +2,18 @@
import json
import re
import datetime # Add datetime import
import time # Add time import
import datetime
import time
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.domain.gemini_models import GeminiRequest
from app.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
from app.database.services import add_error_log, add_request_log
logger = get_gemini_logger()
@@ -27,6 +28,33 @@ def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
return False
def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
continue
if isinstance(value, dict):
cleaned[key] = _clean_json_schema_properties(value)
elif isinstance(value, list):
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
@@ -39,7 +67,15 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
for k, v in item.items():
if k == "functionDeclarations" and v and isinstance(v, list):
functions = record.get("functionDeclarations", [])
functions.extend(v)
# 清理每个函数声明中的不支持字段
cleaned_functions = []
for func in v:
if isinstance(func, dict):
cleaned_func = _clean_json_schema_properties(func)
cleaned_functions.append(cleaned_func)
else:
cleaned_functions.append(func)
functions.extend(cleaned_functions)
record["functionDeclarations"] = functions
else:
record[k] = v
@@ -73,20 +109,28 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
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"},
]
return GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return settings.SAFETY_SETTINGS
def _filter_empty_parts(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Filters out contents with empty or invalid parts."""
if not contents:
return []
filtered_contents = []
for content in contents:
if not content or "parts" not in content or not isinstance(content.get("parts"), list):
continue
valid_parts = [part for part in content["parts"] if isinstance(part, dict) and part]
if valid_parts:
new_content = content.copy()
new_content["parts"] = valid_parts
filtered_contents.append(new_content)
return filtered_contents
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
@@ -98,21 +142,41 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
request_dict["generationConfig"].pop("maxOutputTokens")
payload = {
"contents": request_dict.get("contents", []),
"contents": _filter_empty_parts(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", ""),
"generationConfig": request_dict.get("generationConfig"),
"systemInstruction": request_dict.get("systemInstruction"),
}
# 确保 generationConfig 不为 None
if payload["generationConfig"] is None:
payload["generationConfig"] = {}
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)}
# 处理思考配置:优先使用客户端提供的配置,否则使用默认配置
client_thinking_config = None
if request.generationConfig and request.generationConfig.thinkingConfig:
client_thinking_config = request.generationConfig.thinkingConfig
if client_thinking_config is not None:
# 客户端提供了思考配置,直接使用
payload["generationConfig"]["thinkingConfig"] = client_thinking_config
else:
# 客户端没有提供思考配置,使用默认配置
if model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif model in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
return payload
@@ -142,7 +206,7 @@ class GeminiChatService:
self, original_response: Dict[str, Any], text: str
) -> Dict[str, Any]:
"""创建包含指定文本的响应"""
response_copy = json.loads(json.dumps(original_response)) # 深拷贝
response_copy = json.loads(json.dumps(original_response))
if response_copy.get("candidates") and response_copy["candidates"][0].get(
"content", {}
).get("parts"):
@@ -155,7 +219,7 @@ class GeminiChatService:
"""生成内容"""
payload = _build_payload(model, request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Record request time
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
@@ -163,20 +227,18 @@ class GeminiChatService:
try:
response = await self.api_client.generate_content(payload, model, api_key)
is_success = True
status_code = 200 # Assume 200 on success
status_code = 200
return self.response_handler.handle_response(response, model, stream=False)
except Exception as e:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default to 500 if parsing fails
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=api_key,
model_name=model,
@@ -185,11 +247,58 @@ class GeminiChatService:
error_code=status_code,
request_msg=payload
)
raise e # Re-throw exception for upstream handling
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
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 count_tokens(
self, model: str, request: GeminiRequest, api_key: str
) -> Dict[str, Any]:
"""计算token数量"""
# countTokens API只需要contents
payload = {"contents": _filter_empty_parts(request.model_dump().get("contents", []))}
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
try:
response = await self.api_client.count_tokens(payload, model, 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"Count tokens API call failed with error: {error_log_msg}")
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="gemini-count-tokens",
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)
# Log request to request log table
await add_request_log(
model_name=model,
api_key=api_key,
@@ -214,7 +323,7 @@ class GeminiChatService:
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
final_api_key = current_attempt_key
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
@@ -251,16 +360,14 @@ class GeminiChatService:
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Log key used for this failed attempt
gemini_key=current_attempt_key,
model_name=model,
error_type="gemini-chat-stream",
error_log=error_log_msg,
@@ -268,28 +375,26 @@ class GeminiChatService:
request_msg=payload
)
# Attempt to switch API Key
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else: # No more keys or retries exceeded by handle_api_failure logic
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
else:
logger.error(f"No valid API key available after {retries} retries.")
break
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break # Exit loop after max retries
break
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
api_key=final_api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)

View File

@@ -1,5 +1,6 @@
# app/services/chat_service.py
import asyncio
import datetime
import json
import re
@@ -8,6 +9,7 @@ from copy import deepcopy
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.database.services import (
add_error_log,
add_request_log,
@@ -24,16 +26,43 @@ 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:
def _has_media_parts(messages: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含多媒体部分"""
for message in messages:
if "parts" in message:
for part in message["parts"]:
if "image_url" in part or "inline_data" in part:
return True
return False
def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
continue
if isinstance(value, dict):
cleaned[key] = _clean_json_schema_properties(value)
elif isinstance(value, list):
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
def _build_tools(
request: ChatRequest, messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
@@ -49,7 +78,7 @@ def _build_tools(
or model.endswith("-image")
or model.endswith("-image-generation")
)
and not _has_media_parts(messages) # Use the updated check
and not _has_media_parts(messages)
):
tool["codeExecution"] = {}
logger.debug("Code execution tool enabled.")
@@ -74,6 +103,8 @@ def _build_tools(
):
function.pop("parameters", None)
# 清理函数中的不支持字段
function = _clean_json_schema_properties(function)
function_declarations.append(function)
if function_declarations:
@@ -81,8 +112,13 @@ def _build_tools(
names, functions = set(), []
for fc in function_declarations:
if fc.get("name") not in names:
names.add(fc.get("name"))
functions.append(fc)
if fc.get("name")=="googleSearch":
# cherry开启内置搜索时添加googleSearch工具
tool["googleSearch"] = {}
else:
# 其他函数添加到functionDeclarations中
names.add(fc.get("name"))
functions.append(fc)
tool["functionDeclarations"] = functions
@@ -102,7 +138,7 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
# and "gemini-2.0-pro-exp" not in model
# ):
if model == "gemini-2.0-flash-exp":
return settings.GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return settings.SAFETY_SETTINGS
@@ -130,9 +166,13 @@ def _build_payload(
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 settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
"includeThoughts": True
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)}
if (
instruction
@@ -171,7 +211,7 @@ class OpenAIChatService:
self, original_chunk: Dict[str, Any], text: str
) -> Dict[str, Any]:
"""创建包含指定文本的OpenAI响应块"""
chunk_copy = json.loads(json.dumps(original_chunk)) # 深拷贝
chunk_copy = json.loads(json.dumps(original_chunk))
if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]:
chunk_copy["choices"][0]["delta"]["content"] = text
return chunk_copy
@@ -182,10 +222,8 @@ class OpenAIChatService:
api_key: str,
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
# 转换消息格式
messages, instruction = self.message_converter.convert(request.messages)
# 构建请求payload
payload = _build_payload(request, messages, instruction)
if request.stream:
@@ -203,16 +241,20 @@ class OpenAIChatService:
response = None
try:
response = await self.api_client.generate_content(payload, model, api_key)
usage_metadata = response.get("usageMetadata", {})
is_success = True
status_code = 200
return self.response_handler.handle_response(
response, model, stream=False, finish_reason="stop"
response,
model,
stream=False,
finish_reason="stop",
usage_metadata=usage_metadata,
)
except Exception as e:
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))
@@ -240,10 +282,119 @@ class OpenAIChatService:
request_time=request_datetime,
)
async def _fake_stream_logic_impl(
self, model: str, payload: Dict[str, Any], api_key: str
) -> AsyncGenerator[str, None]:
"""处理伪流式 (fake stream) 的核心逻辑"""
logger.info(
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
)
keep_sending_empty_data = True
async def send_empty_data_locally() -> AsyncGenerator[str, None]:
"""定期发送空数据以保持连接"""
while keep_sending_empty_data:
await asyncio.sleep(settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS)
if keep_sending_empty_data:
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
yield f"data: {json.dumps(empty_chunk)}\n\n"
logger.debug("Sent empty data chunk for fake stream heartbeat.")
empty_data_generator = send_empty_data_locally()
api_response_task = asyncio.create_task(
self.api_client.generate_content(payload, model, api_key)
)
try:
while not api_response_task.done():
try:
next_empty_chunk = await asyncio.wait_for(
empty_data_generator.__anext__(), timeout=0.1
)
yield next_empty_chunk
except asyncio.TimeoutError:
pass
except (
StopAsyncIteration
):
break
response = await api_response_task
finally:
keep_sending_empty_data = False
if response and response.get("candidates"):
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {}))
yield f"data: {json.dumps(response)}\n\n"
logger.info(f"Sent full response content for fake stream: {model}")
else:
error_message = "Failed to get response from model"
if (
response and isinstance(response, dict) and response.get("error")
):
error_details = response.get("error")
if isinstance(error_details, dict):
error_message = error_details.get("message", error_message)
logger.error(
f"No candidates or error in response for fake stream model {model}: {response}"
)
error_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
yield f"data: {json.dumps(error_chunk)}\n\n"
async def _real_stream_logic_impl(
self, model: str, payload: Dict[str, Any], api_key: str
) -> AsyncGenerator[str, None]:
"""处理真实流式 (real stream) 的核心逻辑"""
tool_call_flag = False
usage_metadata = None
async for line in self.api_client.stream_generate_content(
payload, model, api_key
):
if line.startswith("data:"):
chunk_str = line[6:]
if not chunk_str or chunk_str.isspace():
logger.debug(
f"Received empty data line for model {model}, skipping."
)
continue
try:
chunk = json.loads(chunk_str)
usage_metadata = chunk.get("usageMetadata", {})
except json.JSONDecodeError:
logger.error(
f"Failed to decode JSON from stream for model {model}: {chunk_str}"
)
continue
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None, usage_metadata=usage_metadata
)
if openai_chunk:
text = self._extract_text_from_openai_chunk(openai_chunk)
if text and settings.STREAM_OPTIMIZER_ENABLED:
async for (
optimized_chunk_data
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(openai_chunk, t),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk_data
else:
if openai_chunk.get("choices") and openai_chunk["choices"][0].get("delta", {}).get("tool_calls"):
tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n"
if tool_call_flag:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls', usage_metadata=usage_metadata))}\n\n"
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=usage_metadata))}\n\n"
async def _handle_stream_completion(
self, model: str, payload: Dict[str, Any], api_key: str
) -> AsyncGenerator[str, None]:
"""处理流式聊天完成,添加重试逻辑"""
"""处理流式聊天完成,添加重试逻辑和假流式支持"""
retries = 0
max_retries = settings.MAX_RETRIES
is_success = False
@@ -253,63 +404,53 @@ class OpenAIChatService:
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
current_attempt_key = final_api_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"
stream_generator = None
if settings.FAKE_STREAM_ENABLED:
logger.info(
f"Using fake stream logic for model: {model}, Attempt: {retries + 1}"
)
stream_generator = self._fake_stream_logic_impl(
model, payload, current_attempt_key
)
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
logger.info(
f"Using real stream logic for model: {model}, Attempt: {retries + 1}"
)
stream_generator = self._real_stream_logic_impl(
model, payload, current_attempt_key
)
async for chunk_data in stream_generator:
yield chunk_data
yield "data: [DONE]\n\n"
logger.info("Streaming completed successfully")
logger.info(
f"Streaming completed successfully for model: {model}, FakeStream: {settings.FAKE_STREAM_ENABLED}, Attempt: {retries + 1}"
)
is_success = True
status_code = 200
break # 成功后退出循环
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}"
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries} with key {current_attempt_key}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
match = re.search(r"status code (\\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
if isinstance(e, asyncio.TimeoutError):
status_code = 408
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
@@ -319,42 +460,48 @@ class OpenAIChatService:
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(
new_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."
if new_api_key and new_api_key != current_attempt_key:
final_api_key = new_api_key
logger.info(
f"Switched to new API key for next attempt: {final_api_key}"
)
break
elif not new_api_key:
logger.error(
f"No valid API key available after {retries} retries, ceasing attempts for this request."
)
break
else:
logger.error("KeyManager not available for retry logic.")
break
logger.error(
"KeyManager not available, cannot switch API key. Ceasing attempts for this request."
)
break
if retries >= max_retries:
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
logger.error(
f"Max retries ({max_retries}) reached for streaming model {model}."
)
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,
api_key=current_attempt_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"
if not is_success:
logger.error(
f"Streaming failed permanently for model {model} after {retries} attempts."
)
yield f"data: {json.dumps({'error': f'Streaming failed after {retries} retries.'})}\n\n"
yield "data: [DONE]\n\n"
async def create_image_chat_completion(
self, request: ChatRequest, api_key: str
@@ -382,7 +529,7 @@ class OpenAIChatService:
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
status_code = None
try:
if image_data:
@@ -416,16 +563,14 @@ class OpenAIChatService:
is_success = False
error_log_msg = f"Stream image completion failed for model {model}: {e}"
logger.error(error_log_msg)
status_code = 500
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]
},
request_msg={"image_data_truncated": image_data[:1000]},
)
yield f"data: {json.dumps({'error': error_log_msg})}\n\n"
yield "data: [DONE]\n\n"
@@ -445,13 +590,13 @@ class OpenAIChatService:
)
async def _handle_normal_image_completion(
self, model: str, image_data: str, api_key: str
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()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
status_code = None
result = None
try:
@@ -475,11 +620,8 @@ class OpenAIChatService:
error_type="openai-image-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg={
"image_data_truncated": image_data[:1000]
},
request_msg={"image_data_truncated": image_data[:1000]},
)
# Re-raise the exception so the caller knows about the failure
raise e
finally:
end_time = time.perf_counter()

View File

@@ -0,0 +1,328 @@
# app/services/chat_service.py
import json
import re
import datetime
import time
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.domain.gemini_models import GeminiRequest
from app.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
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 _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
continue
if isinstance(value, dict):
cleaned[key] = _clean_json_schema_properties(value)
elif isinstance(value, list):
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
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", [])
# 清理每个函数声明中的不支持字段
cleaned_functions = []
for func in v:
if isinstance(func, dict):
cleaned_func = _clean_json_schema_properties(func)
cleaned_functions.append(cleaned_func)
else:
cleaned_functions.append(func)
functions.extend(cleaned_functions)
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 GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return settings.SAFETY_SETTINGS
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"]
# 处理思考配置:优先使用客户端提供的配置,否则使用默认配置
client_thinking_config = None
if request.generationConfig and request.generationConfig.thinkingConfig:
client_thinking_config = request.generationConfig.thinkingConfig
if client_thinking_config is not None:
# 客户端提供了思考配置,直接使用
payload["generationConfig"]["thinkingConfig"] = client_thinking_config
else:
# 客户端没有提供思考配置,使用默认配置
if model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif model in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
}
else:
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()
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)
except Exception as e:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
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="gemini-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 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}"
)
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=current_attempt_key,
model_name=model,
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
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
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break
finally:
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
)

View File

@@ -40,29 +40,38 @@ class GeminiApiClient(ApiClient):
model = model[:-20]
return model
def _prepare_headers(self) -> Dict[str, str]:
headers = {}
if settings.CUSTOM_HEADERS:
headers.update(settings.CUSTOM_HEADERS)
logger.info(f"Using custom headers: {settings.CUSTOM_HEADERS}")
return headers
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)
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers()
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models?key={api_key}"
url = f"{self.base_url}/models?key={api_key}&pageSize=1000"
try:
response = await client.get(url)
response.raise_for_status() # 如果状态码不是 2xx则引发 HTTPStatusError
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"获取模型列表失败: {e.response.status_code}")
logger.error(e.response.text)
# 返回 None 而不是抛出异常,以便上层处理
return None
except httpx.RequestError as e:
logger.error(f"请求模型列表失败: {e}")
# 返回 None 而不是抛出异常
return None
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
@@ -71,12 +80,16 @@ class GeminiApiClient(ApiClient):
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers()
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)
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}")
@@ -88,12 +101,16 @@ class GeminiApiClient(ApiClient):
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers()
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:
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")
@@ -101,6 +118,27 @@ class GeminiApiClient(ApiClient):
async for line in response.aiter_lines():
yield line
async def count_tokens(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:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for counting tokens: {proxy_to_use}")
headers = self._prepare_headers()
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:countTokens?key={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()
class OpenaiApiClient(ApiClient):
"""OpenAI API客户端"""
@@ -109,11 +147,27 @@ class OpenaiApiClient(ApiClient):
self.base_url = base_url
self.timeout = timeout
def _prepare_headers(self, api_key: str) -> Dict[str, str]:
headers = {"Authorization": f"Bearer {api_key}"}
if settings.CUSTOM_HEADERS:
headers.update(settings.CUSTOM_HEADERS)
logger.info(f"Using custom headers: {settings.CUSTOM_HEADERS}")
return headers
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:
proxy_to_use = None
if settings.PROXIES:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) 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
@@ -122,15 +176,18 @@ class OpenaiApiClient(ApiClient):
async def generate_content(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
logger.info(f"settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: {settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY}")
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
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
@@ -139,15 +196,17 @@ class OpenaiApiClient(ApiClient):
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}")
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
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()
@@ -161,12 +220,15 @@ class OpenaiApiClient(ApiClient):
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
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,
@@ -179,15 +241,18 @@ class OpenaiApiClient(ApiClient):
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}")
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
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

View File

@@ -1,41 +1,49 @@
"""
配置服务模块
"""
import datetime
import json
from typing import Any, Dict, List
from dotenv import find_dotenv, load_dotenv
from fastapi import HTTPException
from sqlalchemy import insert, update
from app.config.config import Settings as ConfigSettings
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
from app.service.key.key_manager import (
get_key_manager_instance,
reset_key_manager_instance,
)
from app.service.model.model_service import ModelService
logger = get_config_routes_logger()
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}")
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_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]] = []
@@ -47,7 +55,7 @@ class ConfigService:
# 处理不同类型的值
if isinstance(value, list):
db_value = json.dumps(value)
elif isinstance(value, dict): # 新增对 dict 类型的处理
elif isinstance(value, dict):
db_value = json.dumps(value)
elif isinstance(value, bool):
db_value = str(value).lower()
@@ -55,24 +63,25 @@ class ConfigService:
db_value = str(value)
# 仅当值发生变化时才更新
if key in existing_keys and existing_settings_map[key]['value'] == db_value:
continue
if key in existing_keys and existing_settings_map[key]["value"] == db_value:
continue
description = f"{key}配置项"
description = f"{key}配置项"
data = {
'key': key,
'value': db_value,
'description': description,
'updated_at': now
"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)
data["description"] = existing_settings_map[key].get(
"description", description
)
settings_to_update.append(data)
else:
data['created_at'] = now
data["created_at"] = now
settings_to_insert.append(data)
# 在事务中执行批量插入和更新
@@ -82,37 +91,109 @@ class ConfigService:
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.")
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'])
.where(Settings.key == setting_data["key"])
.values(
value=setting_data['value'],
description=setting_data['description'],
updated_at=setting_data['updated_at']
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
raise
# 重置并重新初始化 KeyManager
try:
await reset_key_manager_instance()
await get_key_manager_instance(settings.API_KEYS)
await get_key_manager_instance(settings.API_KEYS, settings.VERTEX_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 delete_key(key_to_delete: str) -> Dict[str, Any]:
"""删除单个API密钥"""
# 确保 settings.API_KEYS 是一个列表
if not isinstance(settings.API_KEYS, list):
settings.API_KEYS = []
original_keys_count = len(settings.API_KEYS)
# 创建一个不包含待删除密钥的新列表
updated_api_keys = [k for k in settings.API_KEYS if k != key_to_delete]
if len(updated_api_keys) < original_keys_count:
# 密钥已找到并从列表中移除
settings.API_KEYS = updated_api_keys # 首先更新内存中的 settings
# 使用 update_config 持久化更改,它同时处理数据库和 KeyManager
await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
logger.info(f"密钥 '{key_to_delete}' 已成功删除。")
return {"success": True, "message": f"密钥 '{key_to_delete}' 已成功删除。"}
else:
# 未找到密钥
logger.warning(f"尝试删除密钥 '{key_to_delete}',但未找到该密钥。")
return {"success": False, "message": f"未找到密钥 '{key_to_delete}'"}
@staticmethod
async def delete_selected_keys(keys_to_delete: List[str]) -> Dict[str, Any]:
"""批量删除选定的API密钥"""
if not isinstance(settings.API_KEYS, list):
settings.API_KEYS = []
deleted_count = 0
not_found_keys: List[str] = []
current_api_keys = list(settings.API_KEYS)
keys_actually_removed: List[str] = []
for key_to_del in keys_to_delete:
if key_to_del in current_api_keys:
current_api_keys.remove(key_to_del)
keys_actually_removed.append(key_to_del)
deleted_count += 1
else:
not_found_keys.append(key_to_del)
if deleted_count > 0:
settings.API_KEYS = current_api_keys
await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
logger.info(
f"成功删除 {deleted_count} 个密钥。密钥: {keys_actually_removed}"
)
message = f"成功删除 {deleted_count} 个密钥。"
if not_found_keys:
message += f" {len(not_found_keys)} 个密钥未找到: {not_found_keys}"
return {
"success": True,
"message": message,
"deleted_count": deleted_count,
"not_found_keys": not_found_keys,
}
else:
message = "没有密钥被删除。"
if not_found_keys:
message = f"所有 {len(not_found_keys)} 个指定的密钥均未找到: {not_found_keys}"
elif not keys_to_delete:
message = "未指定要删除的密钥。"
logger.warning(message)
return {
"success": False,
"message": message,
"deleted_count": 0,
"not_found_keys": not_found_keys,
}
@staticmethod
async def reset_config() -> Dict[str, Any]:
"""
@@ -124,7 +205,9 @@ class ConfigService:
"""
# 1. 重新加载配置对象,它应该处理环境变量和 .env 的优先级
_reload_settings()
logger.info("Settings object reloaded, prioritizing system environment variables then .env file.")
logger.info(
"Settings object reloaded, prioritizing system environment variables then .env file."
)
# 2. 重置并重新初始化 KeyManager
try:
@@ -140,6 +223,34 @@ class ConfigService:
# 3. 返回更新后的配置
return await ConfigService.get_config()
@staticmethod
async def fetch_ui_models() -> List[Dict[str, Any]]:
"""获取用于UI显示的模型列表"""
try:
key_manager = await get_key_manager_instance()
model_service = ModelService()
api_key = await key_manager.get_first_valid_key()
if not api_key:
logger.error("No valid API keys available to fetch model list for UI.")
raise HTTPException(
status_code=500,
detail="No valid API keys available to fetch model list.",
)
models = await model_service.get_gemini_openai_models(api_key)
return models
except HTTPException as e:
raise e
except Exception as e:
logger.error(
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
)
raise HTTPException(
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
)
# 重新加载配置的函数
def _reload_settings():
"""重新加载环境变量并更新配置"""
@@ -147,4 +258,4 @@ def _reload_settings():
load_dotenv(find_dotenv(), override=True)
# 更新现有 settings 对象的属性,而不是新建实例
for key, value in ConfigSettings().model_dump().items():
setattr(settings, key, value)
setattr(settings, key, value)

View File

@@ -1,15 +1,15 @@
import datetime
import time
import re # For potential status code parsing from generic errors
import re
from typing import List, Union
import openai
from openai import APIStatusError # Import specific error type
from openai import APIStatusError
from openai.types import CreateEmbeddingResponse
from app.config.config import settings
from app.log.logger import get_embeddings_logger
from app.database.services import add_error_log, add_request_log # Import DB logging functions
from app.database.services import add_error_log, add_request_log
logger = get_embeddings_logger()
@@ -26,7 +26,6 @@ class EmbeddingService:
status_code = None
response = None
error_log_msg = ""
# Prepare request message for logging (truncate if list or long string)
if isinstance(input_text, list):
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
if len(input_text) > 5:
@@ -39,39 +38,36 @@ class EmbeddingService:
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
status_code = 200
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
raise e
except Exception as e:
is_success = False
error_log_msg = f"Generic error: {e}"
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
# Try to parse status code from generic error (less reliable)
match = re.search(r"status code (\d+)", str(e))
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
raise e # Re-raise the generic error
status_code = 500
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
if not is_success:
# Log error to database if it failed
await add_error_log(
gemini_key=api_key, # Using gemini_key parameter name for consistency
model_name=model,
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
)
# Log request outcome to database regardless of success/failure
await add_request_log(
model_name=model,
api_key=api_key,

View File

@@ -0,0 +1,178 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, select
from app.config.config import settings
from app.database import services as db_services
from app.database.connection import database
from app.database.models import ErrorLog
from app.log.logger import get_error_log_logger
logger = get_error_log_logger()
async def delete_old_error_logs():
"""
Deletes error logs older than a specified number of days,
based on the AUTO_DELETE_ERROR_LOGS_ENABLED and AUTO_DELETE_ERROR_LOGS_DAYS settings.
"""
if not settings.AUTO_DELETE_ERROR_LOGS_ENABLED:
logger.info("Auto-deletion of error logs is disabled. Skipping.")
return
days_to_keep = settings.AUTO_DELETE_ERROR_LOGS_DAYS
if not isinstance(days_to_keep, int) or days_to_keep <= 0:
logger.error(
f"Invalid AUTO_DELETE_ERROR_LOGS_DAYS value: {days_to_keep}. Must be a positive integer. Skipping deletion."
)
return
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
logger.info(
f"Attempting to delete error logs older than {days_to_keep} days (before {cutoff_date.strftime('%Y-%m-%d %H:%M:%S %Z')})."
)
try:
if not database.is_connected:
await database.connect()
logger.info("Database connection established for deleting error logs.")
# First, count how many logs will be deleted (optional, for logging)
count_query = select(func.count(ErrorLog.id)).where(
ErrorLog.request_time < cutoff_date
)
num_logs_to_delete = await database.fetch_val(count_query)
if num_logs_to_delete == 0:
logger.info(
"No error logs found older than the specified period. No deletion needed."
)
return
logger.info(f"Found {num_logs_to_delete} error logs to delete.")
# Perform the deletion
query = delete(ErrorLog).where(ErrorLog.request_time < cutoff_date)
await database.execute(query)
logger.info(
f"Successfully deleted {num_logs_to_delete} error logs older than {days_to_keep} days."
)
except Exception as e:
logger.error(
f"Error during automatic deletion of error logs: {e}", exc_info=True
)
async def process_get_error_logs(
limit: int,
offset: int,
key_search: Optional[str],
error_search: Optional[str],
error_code_search: Optional[str],
start_date: Optional[datetime],
end_date: Optional[datetime],
sort_by: str,
sort_order: str,
) -> Dict[str, Any]:
"""
处理错误日志的检索,支持分页和过滤。
"""
try:
logs_data = await db_services.get_error_logs(
limit=limit,
offset=offset,
key_search=key_search,
error_search=error_search,
error_code_search=error_code_search,
start_date=start_date,
end_date=end_date,
sort_by=sort_by,
sort_order=sort_order,
)
total_count = await db_services.get_error_logs_count(
key_search=key_search,
error_search=error_search,
error_code_search=error_code_search,
start_date=start_date,
end_date=end_date,
)
return {"logs": logs_data, "total": total_count}
except Exception as e:
logger.error(f"Service error in process_get_error_logs: {e}", exc_info=True)
raise
async def process_get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
"""
处理特定错误日志详细信息的检索。
如果未找到,则返回 None。
"""
try:
log_details = await db_services.get_error_log_details(log_id=log_id)
return log_details
except Exception as e:
logger.error(
f"Service error in process_get_error_log_details for ID {log_id}: {e}",
exc_info=True,
)
raise
async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
按 ID 批量删除错误日志。
返回尝试删除的日志数量。
"""
if not log_ids:
return 0
try:
deleted_count = await db_services.delete_error_logs_by_ids(log_ids)
return deleted_count
except Exception as e:
logger.error(
f"Service error in process_delete_error_logs_by_ids for IDs {log_ids}: {e}",
exc_info=True,
)
raise
async def process_delete_error_log_by_id(log_id: int) -> bool:
"""
按 ID 删除单个错误日志。
如果删除成功(或找到日志并尝试删除),则返回 True否则返回 False。
"""
try:
success = await db_services.delete_error_log_by_id(log_id)
return success
except Exception as e:
logger.error(
f"Service error in process_delete_error_log_by_id for ID {log_id}: {e}",
exc_info=True,
)
raise
async def process_delete_all_error_logs() -> int:
"""
处理删除所有错误日志的请求。
返回删除的日志数量。
"""
try:
if not database.is_connected:
await database.connect()
logger.info("Database connection established for deleting all error logs.")
deleted_count = await db_services.delete_all_error_logs()
logger.info(
f"Successfully processed request to delete all error logs. Count: {deleted_count}"
)
return deleted_count
except Exception as e:
logger.error(
f"Service error in process_delete_all_error_logs: {e}",
exc_info=True,
)
raise

View File

@@ -121,6 +121,7 @@ class ImageCreateService:
provider=settings.UPLOAD_PROVIDER,
base_url=settings.CLOUDFLARE_IMGBED_URL,
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
)
else:
raise ValueError(
@@ -137,7 +138,7 @@ class ImageCreateService:
)
response_data = {
"created": int(time.time()), # Current timestamp
"created": int(time.time()),
"data": images_data,
}
return response_data

View File

@@ -1,7 +1,6 @@
import asyncio
from itertools import cycle
from typing import Dict
from typing import Dict, Union
from app.config.config import settings
from app.log.logger import get_key_manager_logger
@@ -10,12 +9,19 @@ logger = get_key_manager_logger()
class KeyManager:
def __init__(self, api_keys: list):
def __init__(self, api_keys: list, vertex_api_keys: list):
self.api_keys = api_keys
self.vertex_api_keys = vertex_api_keys
self.key_cycle = cycle(api_keys)
self.vertex_key_cycle = cycle(vertex_api_keys)
self.key_cycle_lock = asyncio.Lock()
self.vertex_key_cycle_lock = asyncio.Lock()
self.failure_count_lock = asyncio.Lock()
self.vertex_failure_count_lock = asyncio.Lock()
self.key_failure_counts: Dict[str, int] = {key: 0 for key in api_keys}
self.vertex_key_failure_counts: Dict[str, int] = {
key: 0 for key in vertex_api_keys
}
self.MAX_FAILURES = settings.MAX_FAILURES
self.paid_key = settings.PAID_KEY
@@ -27,17 +33,33 @@ class KeyManager:
async with self.key_cycle_lock:
return next(self.key_cycle)
async def get_next_vertex_key(self) -> str:
"""获取下一个 Vertex Express API key"""
async with self.vertex_key_cycle_lock:
return next(self.vertex_key_cycle)
async def is_key_valid(self, key: str) -> bool:
"""检查key是否有效"""
async with self.failure_count_lock:
return self.key_failure_counts[key] < self.MAX_FAILURES
async def is_vertex_key_valid(self, key: str) -> bool:
"""检查 Vertex key 是否有效"""
async with self.vertex_failure_count_lock:
return self.vertex_key_failure_counts[key] < self.MAX_FAILURES
async def reset_failure_counts(self):
"""重置所有key的失败计数"""
async with self.failure_count_lock:
for key in self.key_failure_counts:
self.key_failure_counts[key] = 0
async def reset_vertex_failure_counts(self):
"""重置所有 Vertex key 的失败计数"""
async with self.vertex_failure_count_lock:
for key in self.vertex_key_failure_counts:
self.vertex_key_failure_counts[key] = 0
async def reset_key_failure_count(self, key: str) -> bool:
"""重置指定key的失败计数"""
async with self.failure_count_lock:
@@ -45,7 +67,21 @@ class KeyManager:
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}")
logger.warning(
f"Attempt to reset failure count for non-existent key: {key}"
)
return False
async def reset_vertex_key_failure_count(self, key: str) -> bool:
"""重置指定 Vertex key 的失败计数"""
async with self.vertex_failure_count_lock:
if key in self.vertex_key_failure_counts:
self.vertex_key_failure_counts[key] = 0
logger.info(f"Reset failure count for Vertex key: {key}")
return True
logger.warning(
f"Attempt to reset failure count for non-existent Vertex key: {key}"
)
return False
async def get_next_working_key(self) -> str:
@@ -59,10 +95,22 @@ class KeyManager:
current_key = await self.get_next_key()
if current_key == initial_key:
# await self.reset_failure_counts() 取消重置
return current_key
async def handle_api_failure(self, api_key: str,retries: int) -> str:
async def get_next_working_vertex_key(self) -> str:
"""获取下一可用的 Vertex Express API key"""
initial_key = await self.get_next_vertex_key()
current_key = initial_key
while True:
if await self.is_vertex_key_valid(current_key):
return current_key
current_key = await self.get_next_vertex_key()
if current_key == initial_key:
return current_key
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
@@ -72,13 +120,26 @@ class KeyManager:
)
if retries < settings.MAX_RETRIES:
return await self.get_next_working_key()
else:
else:
return ""
async def handle_vertex_api_failure(self, api_key: str, retries: int) -> str:
"""处理 Vertex Express API 调用失败"""
async with self.vertex_failure_count_lock:
self.vertex_key_failure_counts[api_key] += 1
if self.vertex_key_failure_counts[api_key] >= self.MAX_FAILURES:
logger.warning(
f"Vertex Express API key {api_key} has failed {self.MAX_FAILURES} times"
)
def get_fail_count(self, key: str) -> int:
"""获取指定密钥的失败次数"""
return self.key_failure_counts.get(key, 0)
def get_vertex_fail_count(self, key: str) -> int:
"""获取指定 Vertex 密钥的失败次数"""
return self.vertex_key_failure_counts.get(key, 0)
async def get_keys_by_status(self) -> dict:
"""获取分类后的API key列表包括失败次数"""
valid_keys = {}
@@ -94,40 +155,309 @@ class KeyManager:
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys}
async def get_vertex_keys_by_status(self) -> dict:
"""获取分类后的 Vertex Express API key 列表,包括失败次数"""
valid_keys = {}
invalid_keys = {}
async with self.vertex_failure_count_lock:
for key in self.vertex_api_keys:
fail_count = self.vertex_key_failure_counts[key]
if fail_count < self.MAX_FAILURES:
valid_keys[key] = fail_count
else:
invalid_keys[key] = fail_count
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
if self.api_keys:
return self.api_keys[0]
if not self.api_keys:
logger.warning("API key list is empty, cannot get first valid key.")
return ""
return self.api_keys[0]
_singleton_instance = None
_singleton_lock = asyncio.Lock()
_preserved_failure_counts: Union[Dict[str, int], None] = None
_preserved_vertex_failure_counts: Union[Dict[str, int], None] = None
_preserved_old_api_keys_for_reset: Union[list, None] = None
_preserved_vertex_old_api_keys_for_reset: Union[list, None] = None
_preserved_next_key_in_cycle: Union[str, None] = None
_preserved_vertex_next_key_in_cycle: Union[str, None] = None
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
async def get_key_manager_instance(
api_keys: list = None, vertex_api_keys: list = None
) -> KeyManager:
"""
获取 KeyManager 单例实例。
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。
如果尚未创建实例,将使用提供的 api_keys,vertex_api_keys 初始化 KeyManager。
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
如果在重置后调用,会尝试恢复之前的状态(失败计数、循环位置)。
"""
global _singleton_instance
global _singleton_instance, _preserved_failure_counts, _preserved_vertex_failure_counts, _preserved_old_api_keys_for_reset, _preserved_vertex_old_api_keys_for_reset, _preserved_next_key_in_cycle, _preserved_vertex_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance is None:
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.")
raise ValueError(
"API keys are required to initialize or re-initialize the KeyManager instance."
)
if vertex_api_keys is None:
raise ValueError(
"Vertex Express API keys are required to initialize or re-initialize the KeyManager instance."
)
if not api_keys:
logger.warning(
"Initializing KeyManager with an empty list of API keys."
)
if not vertex_api_keys:
logger.warning(
"Initializing KeyManager with an empty list of Vertex Express API keys."
)
_singleton_instance = KeyManager(api_keys, vertex_api_keys)
logger.info(
f"KeyManager instance created/re-created with {len(api_keys)} API keys and {len(vertex_api_keys)} Vertex Express API keys."
)
# 1. 恢复失败计数
if _preserved_failure_counts:
current_failure_counts = {
key: 0 for key in _singleton_instance.api_keys
}
for key, count in _preserved_failure_counts.items():
if key in current_failure_counts:
current_failure_counts[key] = count
_singleton_instance.key_failure_counts = current_failure_counts
logger.info("Inherited failure counts for applicable keys.")
_preserved_failure_counts = None
if _preserved_vertex_failure_counts:
current_vertex_failure_counts = {
key: 0 for key in _singleton_instance.vertex_api_keys
}
for key, count in _preserved_vertex_failure_counts.items():
if key in current_vertex_failure_counts:
current_vertex_failure_counts[key] = count
_singleton_instance.vertex_key_failure_counts = (
current_vertex_failure_counts
)
logger.info("Inherited failure counts for applicable Vertex keys.")
_preserved_vertex_failure_counts = None
# 2. 调整 key_cycle 的起始点
start_key_for_new_cycle = None
if (
_preserved_old_api_keys_for_reset
and _preserved_next_key_in_cycle
and _singleton_instance.api_keys
):
try:
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
_preserved_next_key_in_cycle
)
for i in range(len(_preserved_old_api_keys_for_reset)):
current_old_key_idx = (start_idx_in_old + i) % len(
_preserved_old_api_keys_for_reset
)
key_candidate = _preserved_old_api_keys_for_reset[
current_old_key_idx
]
if key_candidate in _singleton_instance.api_keys:
start_key_for_new_cycle = key_candidate
break
except ValueError:
logger.warning(
f"Preserved next key '{_preserved_next_key_in_cycle}' not found in preserved old API keys. "
"New cycle will start from the beginning of the new list."
)
except Exception as e:
logger.error(
f"Error determining start key for new cycle from preserved state: {e}. "
"New cycle will start from the beginning."
)
if start_key_for_new_cycle and _singleton_instance.api_keys:
try:
target_idx = _singleton_instance.api_keys.index(
start_key_for_new_cycle
)
for _ in range(target_idx):
next(_singleton_instance.key_cycle)
logger.info(
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
)
except ValueError:
logger.warning(
f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. "
"New cycle will start from the beginning."
)
except StopIteration:
logger.error(
"StopIteration while advancing key cycle, implies empty new API key list previously missed."
)
except Exception as e:
logger.error(
f"Error advancing new key cycle: {e}. Cycle will start from beginning."
)
else:
if _singleton_instance.api_keys:
logger.info(
"New key cycle will start from the beginning of the new API key list (no specific start key determined or needed)."
)
else:
logger.info(
"New key cycle not applicable as the new API key list is empty."
)
# 清理所有保存的状态
_preserved_old_api_keys_for_reset = None
_preserved_next_key_in_cycle = None
# 3. 调整 vertex_key_cycle 的起始点
start_key_for_new_vertex_cycle = None
if (
_preserved_vertex_old_api_keys_for_reset
and _preserved_vertex_next_key_in_cycle
and _singleton_instance.vertex_api_keys
):
try:
start_idx_in_old = _preserved_vertex_old_api_keys_for_reset.index(
_preserved_vertex_next_key_in_cycle
)
for i in range(len(_preserved_vertex_old_api_keys_for_reset)):
current_old_key_idx = (start_idx_in_old + i) % len(
_preserved_vertex_old_api_keys_for_reset
)
key_candidate = _preserved_vertex_old_api_keys_for_reset[
current_old_key_idx
]
if key_candidate in _singleton_instance.vertex_api_keys:
start_key_for_new_vertex_cycle = key_candidate
break
except ValueError:
logger.warning(
f"Preserved next key '{_preserved_vertex_next_key_in_cycle}' not found in preserved old Vertex Express API keys. "
"New cycle will start from the beginning of the new list."
)
except Exception as e:
logger.error(
f"Error determining start key for new Vertex key cycle from preserved state: {e}. "
"New cycle will start from the beginning."
)
if start_key_for_new_vertex_cycle and _singleton_instance.vertex_api_keys:
try:
target_idx = _singleton_instance.vertex_api_keys.index(
start_key_for_new_vertex_cycle
)
for _ in range(target_idx):
next(_singleton_instance.vertex_key_cycle)
logger.info(
f"Vertex key cycle in new instance advanced. Next call to get_next_vertex_key() will yield: {start_key_for_new_vertex_cycle}"
)
except ValueError:
logger.warning(
f"Determined start key '{start_key_for_new_vertex_cycle}' not found in new Vertex Express API keys during cycle advancement. "
"New cycle will start from the beginning."
)
except StopIteration:
logger.error(
"StopIteration while advancing Vertex key cycle, implies empty new Vertex Express API key list previously missed."
)
except Exception as e:
logger.error(
f"Error advancing new Vertex key cycle: {e}. Cycle will start from beginning."
)
else:
if _singleton_instance.vertex_api_keys:
logger.info(
"New Vertex key cycle will start from the beginning of the new Vertex Express API key list (no specific start key determined or needed)."
)
else:
logger.info(
"New Vertex key cycle not applicable as the new Vertex Express API key list is empty."
)
# 清理所有保存的状态
_preserved_vertex_old_api_keys_for_reset = None
_preserved_vertex_next_key_in_cycle = None
return _singleton_instance
async def reset_key_manager_instance():
"""重置 KeyManager 单例实例"""
global _singleton_instance
"""
重置 KeyManager 单例实例。
将保存当前实例的状态(失败计数、旧 API keys、下一个 key 提示)
以供下一次 get_key_manager_instance 调用时恢复。
"""
global _singleton_instance, _preserved_failure_counts, _preserved_vertex_failure_counts, _preserved_old_api_keys_for_reset, _preserved_vertex_old_api_keys_for_reset, _preserved_next_key_in_cycle, _preserved_vertex_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance:
# 1. 保存失败计数
_preserved_failure_counts = _singleton_instance.key_failure_counts.copy()
_preserved_vertex_failure_counts = (
_singleton_instance.vertex_key_failure_counts.copy()
)
# 2. 保存旧的 API keys 列表
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
_preserved_vertex_old_api_keys_for_reset = (
_singleton_instance.vertex_api_keys.copy()
)
# 3. 保存 key_cycle 的下一个 key 提示
try:
if _singleton_instance.api_keys:
_preserved_next_key_in_cycle = (
await _singleton_instance.get_next_key()
)
else:
_preserved_next_key_in_cycle = None
except StopIteration:
logger.warning(
"Could not preserve next key hint: key cycle was empty or exhausted in old instance."
)
_preserved_next_key_in_cycle = None
except Exception as e:
logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_next_key_in_cycle = None
# 4. 保存 vertex_key_cycle 的下一个 key 提示
try:
if _singleton_instance.vertex_api_keys:
_preserved_vertex_next_key_in_cycle = (
await _singleton_instance.get_next_vertex_key()
)
else:
_preserved_vertex_next_key_in_cycle = None
except StopIteration:
logger.warning(
"Could not preserve next key hint: Vertex key cycle was empty or exhausted in old instance."
)
_preserved_vertex_next_key_in_cycle = None
except Exception as e:
logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_vertex_next_key_in_cycle = None
_singleton_instance = None
logger.info("KeyManager instance reset.")
logger.info(
"KeyManager instance has been reset. State (failure counts, old keys, next key hint) preserved for next instantiation."
)
else:
logger.info(
"KeyManager instance was not set (or already reset), no reset action performed."
)

View File

@@ -10,8 +10,7 @@ logger = get_model_logger()
class ModelService:
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""使用 GeminiApiClient 获取并过滤模型列表"""
api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端
api_client = GeminiApiClient(base_url=settings.BASE_URL)
gemini_models = await api_client.get_models(api_key)
if gemini_models is None:

View File

@@ -79,7 +79,6 @@ class OpenAICompatiableService:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
@@ -132,7 +131,7 @@ class OpenAICompatiableService:
logger.info("Streaming completed successfully")
is_success = True
status_code = 200
break # 成功后退出循环
break
except Exception as e:
retries += 1
is_success = False
@@ -140,14 +139,12 @@ class OpenAICompatiableService:
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
@@ -157,8 +154,6 @@ class OpenAICompatiableService:
request_msg=payload,
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
@@ -178,7 +173,6 @@ class OpenAICompatiableService:
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
@@ -189,7 +183,6 @@ class OpenAICompatiableService:
latency_ms=latency_ms,
request_time=request_datetime,
)
# If the loop finished due to failure, yield error and DONE
if not is_success and retries >= max_retries:
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"

View File

@@ -0,0 +1,50 @@
"""
Service for request log operations.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from app.database.connection import database
from app.config.config import settings
from app.database.models import RequestLog
from app.log.logger import get_request_log_logger
logger = get_request_log_logger()
async def delete_old_request_logs_task():
"""
定时删除旧的请求日志。
"""
if not settings.AUTO_DELETE_REQUEST_LOGS_ENABLED:
logger.info(
"Auto-delete for request logs is disabled by settings. Skipping task."
)
return
days_to_keep = settings.AUTO_DELETE_REQUEST_LOGS_DAYS
logger.info(
f"Starting scheduled task to delete old request logs older than {days_to_keep} days."
)
try:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
query = delete(RequestLog).where(RequestLog.request_time < cutoff_date)
if not database.is_connected:
logger.info("Connecting to database for request log deletion.")
await database.connect()
result = await database.execute(query)
logger.info(
f"Request logs older than {cutoff_date} potentially deleted. Rows affected: {result.rowcount if result else 'N/A'}"
)
except Exception as e:
logger.error(
f"An error occurred during the scheduled request log deletion: {str(e)}",
exc_info=True,
)

View File

@@ -1,7 +1,9 @@
# app/service/stats_service.py
import datetime
from sqlalchemy import select, func
from typing import Union
from sqlalchemy import and_, case, func, or_, select
from app.database.connection import database
from app.database.models import RequestLog
@@ -13,66 +15,129 @@ 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 秒内的调用次数 (包括成功失败)"""
async def get_calls_in_last_seconds(self, seconds: int) -> dict[str, int]:
"""获取过去 N 秒内的调用次数 (总数、成功失败)"""
try:
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= cutoff_time
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
query = select(
func.count(RequestLog.id).label("total"),
func.sum(
case(
(
and_(
RequestLog.status_code >= 200,
RequestLog.status_code < 300,
),
1,
),
else_=0,
)
).label("success"),
func.sum(
case(
(
or_(
RequestLog.status_code < 200,
RequestLog.status_code >= 300,
),
1,
),
(RequestLog.status_code is None, 1),
else_=0,
)
).label("failure"),
).where(RequestLog.request_time >= cutoff_time)
result = await database.fetch_one(query)
if result:
return {
"total": result["total"] or 0,
"success": result["success"] or 0,
"failure": result["failure"] or 0,
}
return {"total": 0, "success": 0, "failure": 0}
except Exception as e:
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
return 0 # Return 0 on error
return {"total": 0, "success": 0, "failure": 0}
async def get_calls_in_last_minutes(self, minutes: int) -> int:
"""获取过去 N 分钟内的调用次数 (包括成功失败)"""
async def get_calls_in_last_minutes(self, minutes: int) -> dict[str, int]:
"""获取过去 N 分钟内的调用次数 (总数、成功失败)"""
return await self.get_calls_in_last_seconds(minutes * 60)
async def get_calls_in_last_hours(self, hours: int) -> int:
"""获取过去 N 小时内的调用次数 (包括成功失败)"""
async def get_calls_in_last_hours(self, hours: int) -> dict[str, int]:
"""获取过去 N 小时内的调用次数 (总数、成功失败)"""
return await self.get_calls_in_last_seconds(hours * 3600)
async def get_calls_in_current_month(self) -> int:
"""获取当前自然月内的调用次数 (包括成功失败)"""
async def get_calls_in_current_month(self) -> dict[str, int]:
"""获取当前自然月内的调用次数 (总数、成功失败)"""
try:
now = datetime.datetime.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= start_of_month
start_of_month = now.replace(
day=1, hour=0, minute=0, second=0, microsecond=0
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
query = select(
func.count(RequestLog.id).label("total"),
func.sum(
case(
(
and_(
RequestLog.status_code >= 200,
RequestLog.status_code < 300,
),
1,
),
else_=0,
)
).label("success"),
func.sum(
case(
(
or_(
RequestLog.status_code < 200,
RequestLog.status_code >= 300,
),
1,
),
(RequestLog.status_code is None, 1),
else_=0,
)
).label("failure"),
).where(RequestLog.request_time >= start_of_month)
result = await database.fetch_one(query)
if result:
return {
"total": result["total"] or 0,
"success": result["success"] or 0,
"failure": result["failure"] or 0,
}
return {"total": 0, "success": 0, "failure": 0}
except Exception as e:
logger.error(f"Failed to get calls in current month: {e}")
return 0 # Return 0 on error
return {"total": 0, "success": 0, "failure": 0}
async def get_api_usage_stats(self) -> dict:
"""获取所有需要的 API 使用统计数据"""
"""获取所有需要的 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()
stats_1m = await self.get_calls_in_last_minutes(1)
stats_1h = await self.get_calls_in_last_hours(1)
stats_24h = await self.get_calls_in_last_hours(24)
stats_month = await self.get_calls_in_current_month()
return {
"calls_1m": calls_1m,
"calls_1h": calls_1h,
"calls_24h": calls_24h,
"calls_month": calls_month,
"calls_1m": stats_1m,
"calls_1h": stats_1h,
"calls_24h": stats_24h,
"calls_month": stats_month,
}
except Exception as e:
logger.error(f"Failed to get API usage stats: {e}")
# Return default values on error
default_stat = {"total": 0, "success": 0, "failure": 0}
return {
"calls_1m": 0,
"calls_1h": 0,
"calls_24h": 0,
"calls_month": 0,
"calls_1m": default_stat.copy(),
"calls_1h": default_stat.copy(),
"calls_24h": default_stat.copy(),
"calls_month": default_stat.copy(),
}
async def get_api_call_details(self, period: str) -> list[dict]:
"""
获取指定时间段内的 API 调用详情
@@ -87,48 +152,55 @@ class StatsService:
ValueError: 如果 period 无效
"""
now = datetime.datetime.now()
if period == '1m':
if period == "1m":
start_time = now - datetime.timedelta(minutes=1)
elif period == '1h':
elif period == "1h":
start_time = now - datetime.timedelta(hours=1)
elif period == '24h':
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
query = (
select(
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code,
)
.where(RequestLog.request_time >= start_time)
.order_by(RequestLog.request_time.desc())
)
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}'")
status = "failure"
if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure"
details.append(
{
"timestamp": row[
"timestamp"
].isoformat(),
"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
logger.error(
f"Failed to get API call details for period '{period}': {e}")
raise
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
"""
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
@@ -140,35 +212,44 @@ class StatsService:
如果查询出错或没有找到记录,可能返回 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.")
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
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),
)
.group_by(RequestLog.model_name)
.order_by(func.count(RequestLog.id).desc())
)
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
logger.info(
f"No usage details found for key ending in ...{key[-4:]} in the last 24h."
)
return {}
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}")
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
logger.error(
f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}",
exc_info=True,
)
raise

View File

@@ -0,0 +1,95 @@
import datetime
import io
import re
import time
import wave
from typing import Optional
from google import genai
from app.config.config import settings
from app.core.constants import TTS_VOICE_NAMES
from app.database.services import add_error_log, add_request_log
from app.domain.openai_models import TTSRequest
from app.log.logger import get_openai_logger
logger = get_openai_logger()
def _create_wav_file(audio_data: bytes) -> bytes:
"""Creates a WAV file in memory from raw audio data."""
with io.BytesIO() as wav_file:
with wave.open(wav_file, "wb") as wf:
wf.setnchannels(1) # Mono
wf.setsampwidth(2) # 16-bit
wf.setframerate(24000) # 24kHz sample rate
wf.writeframes(audio_data)
return wav_file.getvalue()
class TTSService:
async def create_tts(self, request: TTSRequest, api_key: str) -> Optional[bytes]:
"""
使用 Google Gemini SDK 创建音频。
"""
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
error_log_msg = ""
try:
client = genai.Client(api_key=api_key)
response =await client.aio.models.generate_content(
model=settings.TTS_MODEL,
contents=f"Speak in a {settings.TTS_SPEED} speed voice: {request.input}",
config={
"response_modalities": ["Audio"],
"speech_config": {
"voice_config": {
"prebuilt_voice_config": {
"voice_name": request.voice if request.voice in TTS_VOICE_NAMES else settings.TTS_VOICE_NAME
}
}
},
},
)
if (
response.candidates
and response.candidates[0].content.parts
and response.candidates[0].content.parts[0].inline_data
):
raw_audio_data = response.candidates[0].content.parts[0].inline_data.data
is_success = True
status_code = 200
return _create_wav_file(raw_audio_data)
except Exception as e:
is_success = False
error_log_msg = f"Generic error: {e}"
logger.error(f"An error occurred in TTSService: {error_log_msg}")
match = re.search(r"status code (\d+)", str(e))
if match:
status_code = int(match.group(1))
else:
status_code = 500
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
if not is_success:
await add_error_log(
gemini_key=api_key,
model_name=settings.TTS_MODEL,
error_type="google-tts",
error_log=error_log_msg,
error_code=status_code,
request_msg=request.input
)
await add_request_log(
model_name=settings.TTS_MODEL,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)

View File

@@ -7,11 +7,7 @@ from app.log.logger import get_update_logger
logger = get_update_logger()
# GitHub repository details are read from settings (defined in app/config/config.py or environment variables)
# GITHUB_API_URL will be constructed inside the function to ensure settings are loaded
VERSION_FILE_PATH = "VERSION" # Path relative to project root
VERSION_FILE_PATH = "VERSION"
async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
"""
@@ -24,9 +20,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
- Optional[str]: 如果检查失败,则为错误消息,否则为 None。
"""
try:
# Read current version from VERSION file
# Ensure the path is correct relative to the execution context or use absolute path if needed
# Assuming execution from project root d:/develop/pythonProjects/gemini-balance
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
current_v = f.read().strip()
if not current_v:
@@ -41,25 +34,22 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
logger.info(f"当前应用程序版本 (from {VERSION_FILE_PATH}): {current_v}")
# Check if repository details are configured in settings
if not settings.GITHUB_REPO_OWNER or not settings.GITHUB_REPO_NAME or \
settings.GITHUB_REPO_OWNER == "your_owner" or settings.GITHUB_REPO_NAME == "your_repo":
logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.")
return False, None, "Update check skipped: Repository not configured in settings."
# Construct the API URL inside the function to ensure settings are loaded
github_api_url = f"https://api.github.com/repos/{settings.GITHUB_REPO_OWNER}/{settings.GITHUB_REPO_NAME}/releases/latest"
logger.debug(f"Checking for updates at URL: {github_api_url}") # Log the URL for debugging
logger.debug(f"Checking for updates at URL: {github_api_url}")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 添加 User-Agent 头GitHub API 可能需要
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0" # Use repo name from settings for User-Agent
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0"
}
response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL
response.raise_for_status() # 对错误的 HTTP 状态码4xx 或 5xx抛出异常
response = await client.get(github_api_url, headers=headers)
response.raise_for_status()
latest_release = response.json()
latest_v_str = latest_release.get("tag_name")
@@ -68,7 +58,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
logger.warning("在最新的 GitHub release 响应中找不到 'tag_name'")
return False, None, "无法从 GitHub 解析最新版本。"
# 移除 tag 名称中可能存在的 'v' 前缀
if latest_v_str.startswith('v'):
latest_v_str = latest_v_str[1:]
@@ -98,8 +87,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
logger.error(f"检查更新时发生网络错误: {e}")
return False, None, "更新检查期间发生网络错误。"
except version.InvalidVersion:
# Note: latest_v_str might not be defined if the error occurs before fetching it.
# Consider adding a check or default value for logging.
latest_v_str_for_log = latest_v_str if 'latest_v_str' in locals() else 'N/A'
logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'")
return False, None, "遇到无效的版本格式。"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,14 @@
<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 */
background: rgba(255, 255, 255, 0.95); /* High opacity white for light theme */
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.auth-bg-gradient { /* Renamed to avoid conflict if base.html has .bg-gradient */
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
background: #f8fafc; /* Light gray background for auth page */
}
/* .input-icon class removed, using direct Tailwind classes now */
/* Keep button ripple effect if needed, or remove if base provides similar */
@@ -49,7 +50,7 @@
</div>
</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">
<h2 class="text-3xl font-extrabold text-center text-gray-800 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>
@@ -67,9 +68,9 @@
>
</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
type="submit"
class="w-full py-4 rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-semibold transition duration-300 transform hover:-translate-y-1 hover:shadow-lg"
>
登录
</button>

View File

@@ -1,316 +1,642 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<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)' },
},
},
}
}
}
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 %}
.glass-card {
background: rgba(255, 255, 255, 0.95); /* High opacity white for light theme */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(0, 0, 0, 0.08); /* Light gray border */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.bg-gradient {
background: #ffffff; /* Clean white background */
}
/* 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(107, 114, 128, 0.6); /* gray-500 for light theme */
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(75, 85, 99, 0.8); /* gray-600 for light theme */
}
/* 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;
}
/* Global modal content styling for light theme consistency */
.modal .w-full[style*="background-color: rgba(70, 50, 150"],
.modal .w-full[style*="background-color: rgba(80, 60, 160"] {
background-color: rgba(255, 255, 255, 0.98) !important;
color: #374151 !important; /* gray-700 */
border: 1px solid rgba(0, 0, 0, 0.08) !important;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
}
/* Global modal text color fixes */
.modal .text-gray-100, .modal h2.text-gray-100, .modal h3.text-gray-100 {
color: #1f2937 !important; /* gray-800 */
font-weight: 600 !important;
}
.modal .text-gray-200, .modal .text-gray-300 {
color: #6b7280 !important; /* gray-500 */
}
.modal .text-gray-300:hover {
color: #374151 !important; /* gray-700 */
}
/* Global modal button styling */
.modal .bg-violet-600, .modal button.bg-violet-600 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
color: #ffffff !important;
}
.modal .bg-violet-600:hover, .modal button.bg-violet-600:hover {
background-color: #2563eb !important; /* blue-600 - darker light blue */
}
/* Global modal blue button styling */
.modal .bg-blue-500, .modal button.bg-blue-500,
.modal .bg-blue-600, .modal button.bg-blue-600,
.modal .bg-blue-700, .modal button.bg-blue-700 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
color: #ffffff !important;
}
.modal .bg-blue-500:hover, .modal button.bg-blue-500:hover,
.modal .bg-blue-600:hover, .modal button.bg-blue-600:hover,
.modal .bg-blue-700:hover, .modal button.bg-blue-700:hover {
background-color: #2563eb !important; /* blue-600 - darker light blue */
}
/* Global modal red button styling */
.modal .bg-red-500, .modal button.bg-red-500,
.modal .bg-red-600, .modal button.bg-red-600,
.modal .bg-red-700, .modal button.bg-red-700 {
background-color: #f87171 !important; /* red-400 - bright light red */
color: #ffffff !important;
}
.modal .bg-red-500:hover, .modal button.bg-red-500:hover,
.modal .bg-red-600:hover, .modal button.bg-red-600:hover,
.modal .bg-red-700:hover, .modal button.bg-red-700:hover {
background-color: #ef4444 !important; /* red-500 - darker bright light red */
}
/* Global modal gray button styling */
.modal .bg-gray-500, .modal button.bg-gray-500,
.modal .bg-gray-600, .modal button.bg-gray-600,
.modal .bg-gray-700, .modal button.bg-gray-700 {
background-color: #e5e7eb !important; /* gray-200 - light gray */
color: #374151 !important; /* gray-700 - dark text for contrast */
}
.modal .bg-gray-500:hover, .modal button.bg-gray-500:hover,
.modal .bg-gray-600:hover, .modal button.bg-gray-600:hover,
.modal .bg-gray-700:hover, .modal button.bg-gray-700:hover {
background-color: #d1d5db !important; /* gray-300 - darker light gray */
color: #374151 !important; /* gray-700 - dark text for contrast */
}
/* Comprehensive button contrast fixes */
/* Ensure all dark background buttons have white text */
.bg-blue-500, .bg-blue-600, .bg-blue-700, .bg-blue-800, .bg-blue-900,
.bg-red-500, .bg-red-600, .bg-red-700, .bg-red-800, .bg-red-900,
.bg-green-500, .bg-green-600, .bg-green-700, .bg-green-800, .bg-green-900,
.bg-purple-500, .bg-purple-600, .bg-purple-700, .bg-purple-800, .bg-purple-900,
.bg-indigo-500, .bg-indigo-600, .bg-indigo-700, .bg-indigo-800, .bg-indigo-900,
.bg-violet-500, .bg-violet-600, .bg-violet-700, .bg-violet-800, .bg-violet-900,
.bg-sky-500, .bg-sky-600, .bg-sky-700, .bg-sky-800, .bg-sky-900,
.bg-teal-500, .bg-teal-600, .bg-teal-700, .bg-teal-800, .bg-teal-900,
.bg-gray-700, .bg-gray-800, .bg-gray-900,
.bg-slate-500, .bg-slate-600, .bg-slate-700, .bg-slate-800, .bg-slate-900 {
color: #ffffff !important;
}
/* Ensure all light background buttons have dark text */
.bg-gray-50, .bg-gray-100, .bg-gray-200, .bg-gray-300,
.bg-white, .bg-transparent {
color: #374151 !important; /* gray-700 */
}
/* Fix button children text inheritance */
.bg-blue-500 *, .bg-blue-600 *, .bg-blue-700 *, .bg-blue-800 *, .bg-blue-900 *,
.bg-red-500 *, .bg-red-600 *, .bg-red-700 *, .bg-red-800 *, .bg-red-900 *,
.bg-green-500 *, .bg-green-600 *, .bg-green-700 *, .bg-green-800 *, .bg-green-900 *,
.bg-purple-500 *, .bg-purple-600 *, .bg-purple-700 *, .bg-purple-800 *, .bg-purple-900 *,
.bg-violet-500 *, .bg-violet-600 *, .bg-violet-700 *, .bg-violet-800 *, .bg-violet-900 *,
.bg-sky-500 *, .bg-sky-600 *, .bg-sky-700 *, .bg-sky-800 *, .bg-sky-900 *,
.bg-teal-500 *, .bg-teal-600 *, .bg-teal-700 *, .bg-teal-800 *, .bg-teal-900 *,
.bg-gray-700 *, .bg-gray-800 *, .bg-gray-900 *,
.bg-slate-500 *, .bg-slate-600 *, .bg-slate-700 *, .bg-slate-800 *, .bg-slate-900 * {
color: inherit !important;
}
/* Global form element styling for consistency */
select, input[type="text"], input[type="number"], input[type="search"],
input[type="email"], input[type="password"], input[type="datetime-local"],
textarea, .form-input, .form-select {
background-color: rgba(255, 255, 255, 0.95) !important;
color: #374151 !important; /* gray-700 */
border: 1px solid rgba(0, 0, 0, 0.12) !important;
border-radius: 0.375rem !important; /* rounded-md */
}
select:focus, input:focus, textarea:focus,
.form-input:focus, .form-select:focus {
border-color: #3b82f6 !important; /* blue-500 */
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
outline: none !important;
}
/* Fix dropdown option styling */
select option {
background-color: rgba(255, 255, 255, 0.98) !important;
color: #374151 !important; /* gray-700 */
padding: 8px !important;
}
/* Fix pagination controls globally */
.pagination-button, .pagination a, .pagination button {
background-color: rgba(255, 255, 255, 0.9) !important;
color: #374151 !important; /* gray-700 */
border: 1px solid rgba(0, 0, 0, 0.08) !important;
transition: all 0.15s ease-in-out !important;
}
.pagination-button:hover, .pagination a:hover, .pagination button:hover {
background-color: rgba(229, 231, 235, 1) !important; /* gray-200 */
border-color: rgba(0, 0, 0, 0.12) !important;
transform: translateY(-1px) !important;
}
.pagination-button.active, .pagination a.active, .pagination button.active {
background-color: #3b82f6 !important; /* blue-500 - light blue */
color: #ffffff !important;
border-color: #2563eb !important; /* blue-600 - darker light blue */
font-weight: 600 !important;
}
/* 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(34, 197, 94, 0.95); /* green-500 for success */
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;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.notification.show {
opacity: 1;
transform: translate(-50%, 0);
}
.notification.error {
background-color: rgba(239, 68, 68, 0.95); /* red-500 for error */
}
/* 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: #3b82f6; /* blue-500 - light blue */
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: #2563eb; /* blue-600 - darker light blue */
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 */
}
/* Global overrides for light theme consistency */
.text-gray-200, .text-gray-300, .text-gray-400 {
color: #6b7280 !important; /* gray-500 for better contrast */
}
/* Navigation and header improvements */
.bg-primary-600, .bg-primary-700 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
}
.text-primary-600, .text-primary-700 {
color: #3b82f6 !important; /* blue-500 - light blue */
}
.border-primary-500, .focus\\:border-primary-500 {
border-color: #3b82f6 !important; /* blue-500 */
}
.ring-primary-200, .focus\\:ring-primary-200 {
--tw-ring-color: rgba(59, 130, 246, 0.2) !important; /* blue-500 with opacity */
}
/* Global purple to blue conversion */
.bg-violet-50, .bg-violet-100, .bg-violet-200, .bg-violet-300, .bg-violet-400, .bg-violet-500, .bg-violet-600, .bg-violet-700, .bg-violet-800, .bg-violet-900 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
}
.text-violet-50, .text-violet-100, .text-violet-200, .text-violet-300, .text-violet-400, .text-violet-500, .text-violet-600, .text-violet-700, .text-violet-800, .text-violet-900 {
color: #3b82f6 !important; /* blue-500 - light blue */
}
.border-violet-50, .border-violet-100, .border-violet-200, .border-violet-300, .border-violet-400, .border-violet-500, .border-violet-600, .border-violet-700, .border-violet-800, .border-violet-900 {
border-color: #3b82f6 !important; /* blue-500 - light blue */
}
/* Global button color overrides */
/* Blue buttons to light blue */
.bg-blue-500, .bg-blue-600, .bg-blue-700, .bg-blue-800, .bg-blue-900,
button.bg-blue-500, button.bg-blue-600, button.bg-blue-700, button.bg-blue-800, button.bg-blue-900 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
}
.bg-blue-500:hover, .bg-blue-600:hover, .bg-blue-700:hover, .bg-blue-800:hover, .bg-blue-900:hover,
button.bg-blue-500:hover, button.bg-blue-600:hover, button.bg-blue-700:hover, button.bg-blue-800:hover, button.bg-blue-900:hover,
.hover\\:bg-blue-600:hover, .hover\\:bg-blue-700:hover, .hover\\:bg-blue-800:hover {
background-color: #2563eb !important; /* blue-600 - darker light blue */
}
/* Red buttons to bright light red */
.bg-red-500, .bg-red-600, .bg-red-700, .bg-red-800, .bg-red-900,
button.bg-red-500, button.bg-red-600, button.bg-red-700, button.bg-red-800, button.bg-red-900 {
background-color: #f87171 !important; /* red-400 - bright light red */
}
.bg-red-500:hover, .bg-red-600:hover, .bg-red-700:hover, .bg-red-800:hover, .bg-red-900:hover,
button.bg-red-500:hover, button.bg-red-600:hover, button.bg-red-700:hover, button.bg-red-800:hover, button.bg-red-900:hover,
.hover\\:bg-red-600:hover, .hover\\:bg-red-700:hover, .hover\\:bg-red-800:hover {
background-color: #ef4444 !important; /* red-500 - darker bright light red */
}
/* Gray buttons to light gray */
.bg-gray-500, .bg-gray-600, .bg-gray-700, .bg-gray-800, .bg-gray-900,
button.bg-gray-500, button.bg-gray-600, button.bg-gray-700, button.bg-gray-800, button.bg-gray-900 {
background-color: #e5e7eb !important; /* gray-200 - light gray */
color: #374151 !important; /* gray-700 - dark text for contrast */
}
.bg-gray-500:hover, .bg-gray-600:hover, .bg-gray-700:hover, .bg-gray-800:hover, .bg-gray-900:hover,
button.bg-gray-500:hover, button.bg-gray-600:hover, button.bg-gray-700:hover, button.bg-gray-800:hover, button.bg-gray-900:hover,
.hover\\:bg-gray-600:hover, .hover\\:bg-gray-700:hover, .hover\\:bg-gray-800:hover {
background-color: #d1d5db !important; /* gray-300 - darker light gray */
color: #374151 !important; /* gray-700 - dark text for contrast */
}
/* Ensure all text has proper contrast in light theme */
.text-white {
color: #374151 !important; /* gray-700 for better contrast on light backgrounds */
}
/* Fix dark button text - ensure white text on dark backgrounds */
.bg-blue-500, .bg-blue-600, .bg-blue-700, .bg-blue-800, .bg-blue-900,
.bg-red-500, .bg-red-600, .bg-red-700, .bg-red-800, .bg-red-900,
.bg-green-500, .bg-green-600, .bg-green-700, .bg-green-800, .bg-green-900,
.bg-purple-500, .bg-purple-600, .bg-purple-700, .bg-purple-800, .bg-purple-900,
.bg-indigo-500, .bg-indigo-600, .bg-indigo-700, .bg-indigo-800, .bg-indigo-900,
.bg-gray-700, .bg-gray-800, .bg-gray-900,
.bg-sky-500, .bg-sky-600, .bg-sky-700, .bg-sky-800, .bg-sky-900 {
color: #ffffff !important;
}
/* Ensure buttons with dark backgrounds have white text */
button.bg-blue-500, button.bg-blue-600, button.bg-blue-700,
button.bg-red-500, button.bg-red-600, button.bg-red-700,
button.bg-green-500, button.bg-green-600, button.bg-green-700,
button.bg-sky-500, button.bg-sky-600, button.bg-sky-700,
.btn-primary, .btn-danger, .btn-success, .btn-info {
color: #ffffff !important;
}
/* Override any nested text color rules for dark buttons */
.bg-blue-500 *, .bg-blue-600 *, .bg-blue-700 *,
.bg-red-500 *, .bg-red-600 *, .bg-red-700 *,
.bg-green-500 *, .bg-green-600 *, .bg-green-700 *,
.bg-sky-500 *, .bg-sky-600 *, .bg-sky-700 * {
color: inherit !important;
}
{% 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">
</head>
<body class="bg-white min-h-screen text-gray-900 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> 给作者加鸡腿
<div
class="fixed bottom-0 left-0 w-full py-3 bg-white bg-opacity-95 backdrop-blur-md text-sm text-gray-800 border-t border-gray-200 flex flex-col items-center space-y-1"
>
<!-- 第一行 -->
<div class="flex items-center justify-center space-x-2">
<span>© <span id="copyright-year"></span> by</span>
<a
href="https://linux.do/u/snaily"
target="_blank"
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
>
<img
src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif"
alt="snaily"
class="inline-block w-5 h-5 rounded-full align-middle mr-1"
/>snaily
</a>
<span class="mx-1">|</span>
<span class="text-xs text-yellow-600 font-semibold">
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
<span class="text-gray-400">|</span>
<a
href="https://github.com/snailyp/gemini-balance"
target="_blank"
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
>
<i class="fab fa-github mr-1"></i> GitHub
</a>
</div>
<!-- 第二行 -->
<div class="flex items-center justify-center space-x-2 text-xs">
<a
href="https://gb-docs.snaily.top/guide/supportme.html"
target="_blank"
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
>
<i class="fas fa-drumstick-bite text-yellow-600 mr-1"></i> 给作者加鸡腿
</a>
<span class="text-gray-400">|</span>
<a
href="https://gb-docs.snaily.top"
target="_blank"
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
>
<i class="fas fa-book mr-1"></i> 在线文档
</a>
<span class="text-gray-400">|</span>
<a
href="https://t.me/+soaHax5lyI0wZDVl"
target="_blank"
class="text-primary-600 hover:text-primary-800 transition duration-300 flex items-center"
>
<i class="fab fa-telegram-plane mr-1"></i> 交流群
</a>
<span class="text-gray-400">|</span>
<span class="text-yellow-600 font-semibold flex items-center">
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
</span>
<span id="version-info-container" class="inline-block">
<!-- Version info will be loaded here by JavaScript -->
<span id="version-info-container" class="inline-flex items-center">
<!-- Version info will be loaded here by JavaScript -->
</span>
</div>
</div>
<!-- 通用JS -->
<script>
// 设置版权年份
document.getElementById('copyright-year').textContent = new Date().getFullYear();
// 设置版权年份
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 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");
}
// 显示通知
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);
// Clear previous timeout if exists
if (notification.timeoutId) {
clearTimeout(notification.timeoutId);
}
// 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;
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
// 页面刷新带加载状态
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');
// --- 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
async function fetchVersionInfo() {
if (!versionInfoContainer) return;
versionInfoContainer.innerHTML =
'<span class="mx-1">|</span><span class="text-xs text-gray-700">检查更新中...</span>'; // Initial loading state
try {
const response = await fetch('/api/version/check');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
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 += `
let versionHtml = `<span class="mx-1">|</span><span class="text-xs text-gray-800">v${data.current_version}</span>`;
if (data.update_available) {
versionHtml += `
<span class="mx-1">|</span>
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
<i class="fas fa-arrow-up"></i> 新版本: v${data.latest_version}
</a>`;
} else if (data.error_message) {
versionHtml += `
} 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>`;
}
} 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
// 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>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,12 @@ import base64
import requests
from typing import Dict, Any, List, Optional, Tuple
from pathlib import Path
import logging # Import logging
import logging
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
# Define logger for helper functions if needed, or use specific loggers
helper_logger = logging.getLogger("app.utils") # Or use a more specific logger if available
helper_logger = logging.getLogger("app.utils")
# Define project root and version file path here for get_current_version
# Assuming this file is at app/utils/helpers.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
@@ -159,9 +156,8 @@ def is_valid_api_key(key: str) -> bool:
def get_current_version(default_version: str = "0.0.0") -> str:
"""Reads the current version from the VERSION file."""
version_file = VERSION_FILE_PATH # Use Path object defined above
version_file = VERSION_FILE_PATH
try:
# Use Path object's open method
with version_file.open('r', encoding='utf-8') as f:
version = f.read().strip()
if not version:

View File

@@ -261,18 +261,20 @@ class PicGoUploader(ImageUploader):
class CloudFlareImgBedUploader(ImageUploader):
"""CloudFlare图床上传器"""
def __init__(self, auth_code: str, api_url: str):
def __init__(self, auth_code: str, api_url: str, upload_folder: str = ""):
"""
初始化CloudFlare图床上传器
Args:
auth_code: 认证码
api_url: 上传API地址
upload_folder: 上传文件夹路径(可选)
"""
self.auth_code = auth_code
self.api_url = api_url
self.upload_folder = upload_folder
def upload(self, file: bytes, filename: str) -> UploadResponse:
"""
上传图片到CloudFlare图床
@@ -288,12 +290,16 @@ class CloudFlareImgBedUploader(ImageUploader):
UploadError: 上传失败时抛出异常
"""
try:
# 准备请求URL(添加认证码参数,如果存在)
# 准备请求URL参数
params = []
if self.upload_folder:
params.append(f"uploadFolder={self.upload_folder}")
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"
params.append(f"authCode={self.auth_code}")
params.append("uploadNameType=origin")
request_url = f"{self.api_url}?{'&'.join(params)}"
# 准备文件数据
files = {
"file": (filename, file)
@@ -388,6 +394,7 @@ class ImageUploaderFactory:
elif provider == "cloudflare_imgbed":
return CloudFlareImgBedUploader(
credentials["auth_code"],
credentials["base_url"]
credentials["base_url"],
credentials.get("upload_folder", ""),
)
raise ValueError(f"Unknown provider: {provider}")

71
files/dataocean.svg Normal file
View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 603 103" style="enable-background:new 0 0 603 103;" xml:space="preserve">
<style type="text/css">
.st0{fill:#0080FF;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#0080FF;}
</style>
<g id="XMLID_2369_">
<g id="XMLID_2638_">
<g id="XMLID_2639_">
<g>
<g id="XMLID_44_">
<g id="XMLID_48_">
<path id="XMLID_49_" class="st0" d="M52.1,102.1l0-19.6c20.8,0,36.8-20.6,28.9-42.4C78,32,71.6,25.5,63.5,22.6
c-21.8-7.9-42.4,8.1-42.4,28.9c0,0,0,0,0,0l-19.6,0c0-33.1,32-58.9,66.7-48.1c15.2,4.7,27.2,16.8,31.9,31.9
C110.9,70.1,85.2,102.1,52.1,102.1z"/>
</g>
<polygon id="XMLID_47_" class="st1" points="52.1,82.5 32.6,82.5 32.6,63 32.6,63 52.1,63 52.1,63 "/>
<polygon id="XMLID_46_" class="st1" points="32.6,97.5 17.6,97.5 17.6,97.5 17.6,82.5 32.6,82.5 32.6,97.5 "/>
<polygon id="XMLID_45_" class="st1" points="17.6,82.5 5,82.5 5,82.5 5,70 5,70 17.6,70 17.6,70 "/>
</g>
</g>
</g>
</g>
<g id="XMLID_2370_">
<path id="XMLID_2635_" class="st0" d="M181.5,30.2c-5.8-4-13-6.1-21.4-6.1h-18.3v58.1h18.3c8.4,0,15.6-2.1,21.4-6.4
c3.2-2.2,5.7-5.4,7.4-9.3c1.7-3.9,2.6-8.5,2.6-13.7c0-5.1-0.9-9.7-2.6-13.6C187.2,35.4,184.7,32.3,181.5,30.2z M152.5,34h5.8
c6.4,0,11.7,1.3,15.7,3.7c4.4,2.7,6.7,7.8,6.7,15.1c0,7.6-2.3,12.9-6.7,15.8h0c-3.8,2.5-9.1,3.8-15.6,3.8h-5.8V34z"/>
<path id="XMLID_2634_" class="st0" d="M204.3,23.4c-1.8,0-3.3,0.6-4.5,1.8c-1.2,1.2-1.9,2.7-1.9,4.4c0,1.8,0.6,3.3,1.9,4.5
c1.2,1.2,2.7,1.9,4.5,1.9c1.8,0,3.3-0.6,4.5-1.9c1.2-1.2,1.9-2.8,1.9-4.5c0-1.8-0.6-3.3-1.9-4.4C207.6,24,206,23.4,204.3,23.4z"/>
<rect id="XMLID_2564_" x="199" y="41.3" class="st0" width="10.3" height="41"/>
<path id="XMLID_2561_" class="st0" d="M246.8,44.7c-3.1-2.8-6.6-4.4-10.3-4.4c-5.7,0-10.4,2-14.1,5.8c-3.7,3.8-5.5,8.8-5.5,14.7
c0,5.8,1.8,10.7,5.5,14.7c3.7,3.8,8.4,5.8,14.1,5.8c4,0,7.4-1.1,10.2-3.3V79c0,3.4-0.9,6-2.7,7.9c-1.8,1.8-4.3,2.7-7.4,2.7
c-4.8,0-7.7-1.9-11.4-6.8l-7,6.7l0.2,0.3c1.5,2.1,3.8,4.2,6.9,6.2c3.1,2,6.9,3,11.5,3c6.1,0,11.1-1.9,14.7-5.6
c3.7-3.7,5.5-8.7,5.5-14.9V41.3h-10.1V44.7z M244.1,68.9c-1.8,2-4.1,3-7.1,3c-3,0-5.3-1-7-3c-1.8-2-2.7-4.7-2.7-8
c0-3.3,0.9-6.1,2.7-8.1c1.8-2,4.1-3.1,7-3.1c3,0,5.3,1,7.1,3.1c1.8,2,2.7,4.8,2.7,8.1C246.8,64.2,245.8,66.9,244.1,68.9z"/>
<rect id="XMLID_2560_" x="265.7" y="41.3" class="st0" width="10.3" height="41"/>
<path id="XMLID_2552_" class="st0" d="M271,23.4c-1.8,0-3.3,0.6-4.5,1.8c-1.2,1.2-1.9,2.7-1.9,4.4c0,1.8,0.6,3.3,1.9,4.5
c1.2,1.2,2.7,1.9,4.5,1.9c1.8,0,3.3-0.6,4.5-1.9c1.2-1.2,1.9-2.8,1.9-4.5c0-1.8-0.6-3.3-1.9-4.4C274.3,24,272.7,23.4,271,23.4z"/>
<path id="XMLID_2509_" class="st0" d="M298.6,30.3h-10.1v11.1h-5.9v9.4h5.9v17c0,5.3,1.1,9.1,3.2,11.3c2.1,2.2,5.8,3.3,11.1,3.3
c1.7,0,3.4-0.1,5-0.2l0.5,0v-9.4l-3.5,0.2c-2.5,0-4.1-0.4-4.9-1.3c-0.8-0.9-1.2-2.7-1.2-5.4V50.7h9.6v-9.4h-9.6V30.3z"/>
<rect id="XMLID_2508_" x="356.5" y="24.1" class="st0" width="10.3" height="58.1"/>
<path id="XMLID_2470_" class="st0" d="M470.9,67.6c-1.8,2.1-3.7,3.9-5.2,4.8v0c-1.4,0.9-3.2,1.4-5.3,1.4c-3,0-5.5-1.1-7.5-3.4
c-2-2.3-3-5.2-3-8.7s1-6.4,2.9-8.6c2-2.3,4.4-3.4,7.4-3.4c3.3,0,6.8,2.1,9.8,5.6l6.8-6.5l0,0c-4.4-5.8-10.1-8.5-16.9-8.5
c-5.7,0-10.6,2.1-14.6,6.1c-4,4-6,9.2-6,15.3s2,11.2,6,15.3c4,4.1,8.9,6.1,14.6,6.1c7.5,0,13.5-3.2,17.5-9.1L470.9,67.6z"/>
<path id="XMLID_2460_" class="st0" d="M513.2,47c-1.5-2-3.5-3.7-5.9-4.9c-2.5-1.2-5.3-1.8-8.5-1.8c-5.8,0-10.5,2.1-14,6.3
c-3.4,4.2-5.2,9.3-5.2,15.4c0,6.2,1.9,11.3,5.7,15.3c3.7,3.9,8.8,5.9,14.9,5.9c6.9,0,12.7-2.8,16.9-8.4l0.2-0.3l-6.7-6.5l0,0
c-0.6,0.8-1.5,1.6-2.3,2.4c-1,1-2,1.7-3,2.2c-1.5,0.8-3.3,1.1-5.2,1.1c-2.9,0-5.2-0.8-7-2.5c-1.7-1.5-2.7-3.6-2.9-6.2h27.3
l0.1-3.8c0-2.7-0.4-5.2-1.1-7.6C515.8,51.3,514.7,49.1,513.2,47z M490.7,56.7c0.5-2,1.4-3.6,2.7-4.9c1.4-1.4,3.2-2.1,5.4-2.1
c2.5,0,4.4,0.7,5.7,2.1c1.2,1.3,1.9,2.9,2.1,4.8H490.7z"/>
<path id="XMLID_2456_" class="st0" d="M552.8,44.4L552.8,44.4c-3.1-2.7-7.4-4-12.8-4c-3.4,0-6.6,0.8-9.5,2.2
c-2.7,1.4-5.3,3.6-7,6.6l0.1,0.1l6.6,6.3c2.7-4.3,5.7-5.8,9.7-5.8c2.2,0,3.9,0.6,5.3,1.7c1.4,1.1,2,2.6,2,4.4v2
c-2.6-0.8-5.1-1.2-7.6-1.2c-5.1,0-9.3,1.2-12.4,3.6c-3.1,2.4-4.7,5.9-4.7,10.2c0,3.8,1.3,7,4,9.3c2.7,2.2,6,3.4,9.9,3.4
c3.9,0,7.6-1.6,10.9-4.3v3.4h10.1V55.9C557.6,51,556,47.1,552.8,44.4z M534.5,66.6c1.2-0.8,2.8-1.2,4.9-1.2c2.5,0,5.1,0.5,7.8,1.5
v4C545,73,542,74,538.3,74c-1.8,0-3.2-0.4-4.1-1.2c-0.9-0.8-1.4-1.7-1.4-3C532.8,68.5,533.4,67.4,534.5,66.6z"/>
<path id="XMLID_2454_" class="st0" d="M597.2,45.2c-2.9-3.2-6.9-4.8-12-4.8c-4.1,0-7.4,1.2-9.9,3.5v-2.5h-10.1v41h10.3V59.7
c0-3.1,0.7-5.6,2.2-7.3c1.5-1.8,3.4-2.6,6.1-2.6c2.3,0,4.1,0.8,5.4,2.3c1.3,1.6,2,3.7,2,6.4v23.7h10.3V58.5
C601.5,52.9,600.1,48.4,597.2,45.2z"/>
<path id="XMLID_2450_" class="st0" d="M343.6,44.4L343.6,44.4c-3.1-2.7-7.4-4-12.8-4c-3.4,0-6.6,0.8-9.5,2.2
c-2.7,1.4-5.3,3.6-7,6.6l0.1,0.1l6.6,6.3c2.7-4.3,5.7-5.8,9.7-5.8c2.2,0,3.9,0.6,5.3,1.7c1.4,1.1,2,2.6,2,4.4v2
c-2.6-0.8-5.1-1.2-7.6-1.2c-5.1,0-9.3,1.2-12.4,3.6c-3.1,2.4-4.7,5.9-4.7,10.2c0,3.8,1.3,7,4,9.3c2.7,2.2,6,3.4,9.9,3.4
c3.9,0,7.6-1.6,10.9-4.3v3.4h10.1V55.9C348.3,51,346.7,47.1,343.6,44.4z M325.3,66.6c1.2-0.8,2.8-1.2,4.9-1.2
c2.5,0,5.1,0.5,7.8,1.5v4c-2.2,2.1-5.2,3.1-8.9,3.1c-1.8,0-3.2-0.4-4.1-1.2c-0.9-0.8-1.4-1.7-1.4-3
C323.6,68.5,324.1,67.4,325.3,66.6z"/>
<path id="XMLID_2371_" class="st0" d="M404.2,83.1c-16.5,0-30-13.4-30-30s13.4-30,30-30c16.5,0,30,13.4,30,30
S420.7,83.1,404.2,83.1z M404.2,33.8c-10.7,0-19.4,8.7-19.4,19.4s8.7,19.4,19.4,19.4c10.7,0,19.4-8.7,19.4-19.4
S414.9,33.8,404.2,33.8z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -9,11 +9,11 @@ uvicorn
google-genai
jinja2
python-multipart
cryptography # 支持 MySQL 8+ caching_sha2_password 验证
# 数据库相关依赖
cryptography
pymysql
sqlalchemy
aiomysql
aiosqlite
databases
python-dotenv
apscheduler