Compare commits

...

49 Commits

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

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

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

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

主要变更包括:

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

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

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

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

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

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

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

主要变更:

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

前端 (HTML/JavaScript):
- `error_logs.html`:
    - 调整了搜索/操作区域布局,添加了批量删除按钮。
    - ID 表头增加排序图标和点击事件。
    - 表格行操作列添加了删除按钮。
    - 新增了删除确认模态框。
- `error_logs.js`:
    - 添加了处理 ID 排序点击的逻辑,更新排序状态并重新加载数据。
    - 添加了处理单条和批量删除按钮点击的逻辑。
    - 实现了删除确认模态框的显示/隐藏及确认逻辑。
    - 修改 `loadErrorLogs` 以包含排序参数。
    - 修改 `renderErrorLogs` 以添加行删除按钮和必要的 `data-log-id` 属性。
    - 更新了全选/取消全选逻辑以同步批量删除按钮状态。
2025-04-26 02:39:55 +08:00
snaily
cd54650431 feat(keys): 实现密钥状态页面的客户端分页、搜索与筛选
- 在 keys_status.html 中:
  - 重新设计有效密钥列表头部,添加密钥搜索框、失败次数筛选器和每页显示数量选择器,并优化布局。
  - 为有效和无效密钥列表添加分页控件容器。
  - 更新 CSS 样式以支持新的筛选/分页控件、Grid 布局和改进的响应式设计。
  - 移除内联的 DOMContentLoaded 初始化脚本,相关逻辑已移至 keys_status.js。
  - 为显示/隐藏密钥按钮添加 `title` 属性以提升可访问性。
  - 调整批量操作栏布局,允许换行。
- 在 keys_status.js 中:
  - 修改 `verifyKey` 函数,在验证成功或失败后通过 `showResultModal` 关闭时强制刷新页面。
  - 调整 `verifyKey` 和 `resetKeyFailCount` 中的按钮状态恢复逻辑,以适应页面刷新行为。
  - 清理了部分冗余代码和空行。
2025-04-25 23:56:48 +08:00
snaily
a5602c602e refactor:Enhances key verification and management UI
Refactors bulk key verification for improved error handling and reporting.
The UI is updated to use checkboxes for key selection and batch actions.
Adds detailed verification results modal to display success and failure details.
Improves key filtering, selection and actions for both valid and invalid keys.
Fixes visual glitches with section collapsing/expanding animations.
2025-04-25 20:34:11 +08:00
snaily
dd70fd4c44 fix(verify-keys): 修复无效秘钥的批量验证 2025-04-25 10:38:25 +08:00
snaily
dbe50628b3 feat(error-logs): 增强错误日志功能和UI交互
- 新增错误码搜索功能,支持精确匹配错误码
- 重构复制功能,支持批量选择和复制密钥
- 优化UI布局和交互体验,添加悬停复制按钮
- 重构路由结构,将log_routes.py重命名为error_log_routes.py
2025-04-23 18:31:19 +08:00
50 changed files with 11664 additions and 4755 deletions

View File

@@ -1,12 +1,15 @@
# 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
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}
@@ -23,6 +26,9 @@ CHECK_INTERVAL_HOURS=1
TIMEZONE=Asia/Shanghai
# 请求超时时间(秒)
TIME_OUT=300
# 代理服务器配置 (支持 http 和 socks5)
# 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"]
PROXIES=[]
#########################image_generate 相关配置###########################
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
@@ -43,4 +49,20 @@ 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": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}]'

3
.gitignore vendored
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

@@ -67,6 +67,7 @@ app/
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
* **模型列表自动维护**: 支持openai和gemini模型列表获取与newapi自动获取模型列表完美兼容无需手动填写。
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
* **代理支持**: 支持配置 HTTP/SOCKS5 代理服务器 (`PROXIES`),用于访问 Gemini API方便在特殊网络环境下使用。支持批量添加代理。
## 🚀 快速开始
@@ -90,6 +91,12 @@ app/
* `-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. **拉取镜像**:
@@ -108,6 +115,12 @@ app/
* `-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` 是容器内的数据目录。
### 本地运行 (适用于开发和测试)
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作:
@@ -115,7 +128,7 @@ app/
1. **确保已完成准备工作**:
* 克隆仓库到本地。
* 安装 Python 3.9 或更高版本。
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的配置环境变量部分)。
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的"配置环境变量"部分)。
* 安装项目依赖:
```bash
@@ -142,15 +155,18 @@ app/
| 配置项 | 说明 | 默认值 |
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
| **数据库配置** | | |
| `MYSQL_HOST` | 必填MySQL 数据库主机地址 | `localhost` |
| `MYSQL_PORT` | 必填,MySQL 数据库端口 | `3306` |
| `MYSQL_USER` | 必填MySQL 数据库用户名 | `your_db_user` |
| `MYSQL_PASSWORD` | 必填MySQL 数据库密码 | `your_db_password` |
| `MYSQL_DATABASE` | 必填MySQL 数据库名称 | `defaultdb` |
| `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 的第一个 | `""` |
| `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"]` |
@@ -166,7 +182,13 @@ app/
| `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"}]` |
| **图像生成相关** | | |
| `PAID_KEY` | 可选付费版API Key用于图片生成等高级功能 | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
@@ -182,6 +204,9 @@ app/
| `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 端点
@@ -193,12 +218,16 @@ app/
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
### OpenAI API 相关 (`(/hf)/v1`)
### OpenAI API 相关
* `GET /v1/models`: 列出可用的 OpenAI 模型。
* `POST /v1/chat/completions`: 通过 OpenAI API 进行聊天补全。
* `POST /v1/images/generations`: 通过 OpenAI API 生成图像
* `POST /v1/embeddings`: 通过 OpenAI API 创建文本嵌入
* `GET (/hf)/v1/models`: 列出可用的模型 (底层用的gemini格式)
* `POST (/hf)/v1/chat/completions`: 进行聊天补全 (底层用的gemini格式, 支持流式传输)
* `POST (/hf)/v1/embeddings`: 创建文本嵌入 (底层用的gemini格式)
* `POST (/hf)/v1/images/generations`: 生成图像 (底层用的gemini格式)
* `GET /openai/v1/models`: 列出可用的模型 (底层用的openai格式)。
* `POST /openai/v1/chat/completions`: 进行聊天补全 (底层用的openai格式, 支持流式传输, 可防止截断,速度也快)。
* `POST /openai/v1/embeddings`: 创建文本嵌入 (底层用的openai格式)。
* `POST /openai/v1/images/generations`: 生成图像 (底层用的openai格式)。
## 🤝 贡献

View File

@@ -1 +1 @@
2.0.11
2.1.3

View File

@@ -1,26 +1,55 @@
"""
应用程序配置模块
"""
import datetime
import json
from typing import List, Any, Dict, Type
from typing import Any, Dict, List, Type
from pydantic import ValidationError
from pydantic import ValidationError, ValidationInfo, field_validator
from pydantic_settings import BaseSettings
from sqlalchemy import insert, update, select
from sqlalchemy import insert, select, update
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES
from app.core.constants import (
API_VERSION,
DEFAULT_CREATE_IMAGE_MODEL,
DEFAULT_FILTER_MODELS,
DEFAULT_MODEL,
DEFAULT_SAFETY_SETTINGS,
DEFAULT_STREAM_CHUNK_SIZE,
DEFAULT_STREAM_LONG_TEXT_THRESHOLD,
DEFAULT_STREAM_MAX_DELAY,
DEFAULT_STREAM_MIN_DELAY,
DEFAULT_STREAM_SHORT_TEXT_THRESHOLD,
DEFAULT_TIMEOUT,
MAX_RETRIES,
)
from app.log.logger import Logger
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,8 @@ class Settings(BaseSettings):
TEST_MODEL: str = DEFAULT_MODEL
TIME_OUT: int = DEFAULT_TIMEOUT
MAX_RETRIES: int = MAX_RETRIES
PROXIES: List[str] = [] # 新增:代理服务器列表
# 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
@@ -38,9 +68,9 @@ 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] = {} # 新增:模型对应的预算映射
# 图像生成相关配置
PAID_KEY: str = ""
CREATE_IMAGE_MODEL: str = DEFAULT_CREATE_IMAGE_MODEL
@@ -49,7 +79,7 @@ class Settings(BaseSettings):
PICGO_API_KEY: str = ""
CLOUDFLARE_IMGBED_URL: str = ""
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
# 流式输出优化器配置
STREAM_OPTIMIZER_ENABLED: bool = False
STREAM_MIN_DELAY: float = DEFAULT_STREAM_MIN_DELAY
@@ -58,16 +88,25 @@ 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" # 默认日志级别
LOG_LEVEL: str = "INFO" # 默认日志级别
AUTO_DELETE_ERROR_LOGS_ENABLED: bool = True # 是否开启自动删除错误日志
AUTO_DELETE_ERROR_LOGS_DAYS: int = 7 # 自动删除多少天前的错误日志 (1, 7, 30)
AUTO_DELETE_REQUEST_LOGS_ENABLED: bool = False # 是否开启自动删除请求日志
AUTO_DELETE_REQUEST_LOGS_DAYS: int = 30 # 自动删除多少天前的请求日志 (1, 7, 30)
SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS # 新增:安全设置
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -75,13 +114,16 @@ 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]:
@@ -90,9 +132,11 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
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()]
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 = {}
@@ -102,27 +146,71 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
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}")
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}")
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()}
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}")
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.")
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
logger.error(
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict."
)
return parsed_dict # Return the parsed dict or an empty one if all attempts fail
# 处理 List[Dict[str, str]]
elif target_type == List[Dict[str, str]]:
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
# 验证列表中的每个元素是否为字典,并且键和值都是字符串
valid = all(
isinstance(item, dict)
and all(isinstance(k, str) for k in item.keys())
and all(isinstance(v, str) for v in item.values())
for item in parsed
)
if valid:
return parsed
else:
logger.warning(
f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}"
)
return [] # 或者返回默认值?这里返回空列表
else:
logger.warning(
f"Parsed DB value for key '{key}' is not a list type. Value: {db_value}"
)
return []
except json.JSONDecodeError:
logger.error(
f"Could not parse '{db_value}' as JSON for List[Dict[str, str]] for key '{key}'. Returning empty list."
)
return []
except Exception as e:
logger.error(
f"Error parsing List[Dict[str, str]] for key '{key}': {e}. Value: {db_value}. Returning empty list."
)
return []
# 处理 bool
elif target_type == bool:
return db_value.lower() in ('true', '1', 'yes', 'on')
return db_value.lower() in ("true", "1", "yes", "on")
# 处理 int
elif target_type == int:
return int(db_value)
@@ -133,8 +221,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():
"""
@@ -143,8 +234,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
@@ -157,7 +249,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:
@@ -166,18 +260,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:
@@ -190,35 +296,52 @@ 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):
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):
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):
elif target_type not in (
List[str],
Dict[str, float],
) and isinstance(parsed_db_value, target_type):
type_match = True
if type_match:
setattr(settings, key, parsed_db_value)
logger.debug(f"Updated setting '{key}' in memory from database value ({target_type}).")
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()
@@ -229,21 +352,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
) # 使用 ensure_ascii=False 以支持非 ASCII 字符
elif isinstance(value, bool):
db_value = str(value).lower()
elif value is None: # 处理 None 值
db_value = "" # 或者根据需要设为 NULL 或其他标记
elif value is None: # 处理 None 值
db_value = "" # 或者根据需要设为 NULL 或其他标记
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:
@@ -252,7 +384,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)
# 在事务中执行批量插入和更新
@@ -261,48 +393,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

@@ -11,8 +11,9 @@ from app.exception.exceptions import setup_exception_handlers
from app.router.routes import setup_routers
from app.service.key.key_manager import get_key_manager_instance
from app.database.connection import connect_to_db, disconnect_from_db
from app.utils.helpers import get_current_version # Import from helpers
from app.database.initialization import initialize_database
from app.scheduler.key_checker import start_scheduler, stop_scheduler
from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler
from app.service.update.update_service import check_for_updates
logger = get_application_logger()
@@ -20,28 +21,11 @@ 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"
# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py
STATIC_DIR = PROJECT_ROOT / "app" / "static"
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
def _get_current_version(default_version: str = "0.0.0") -> str:
"""Reads the current version from the VERSION file."""
version_file = VERSION_FILE_PATH # Use Path object
try:
# Use Path object's open method
with version_file.open('r', encoding='utf-8') as f:
version = f.read().strip()
if not version:
logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
return default_version
return version
except FileNotFoundError:
logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
return default_version
except IOError as e:
logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
return default_version
# Removed _get_current_version function definition, moved to helpers.py
# 初始化模板引擎,并添加全局变量
templates = Jinja2Templates(directory="app/templates")
@@ -70,7 +54,6 @@ async def _setup_database_and_config(app_settings):
async def _shutdown_database():
"""Disconnects from the database."""
await disconnect_from_db()
logger.info("Disconnected from database.")
def _start_scheduler():
"""Starts the background scheduler."""
@@ -83,12 +66,11 @@ def _start_scheduler():
def _stop_scheduler():
"""Stops the background scheduler."""
stop_scheduler()
logger.info("Scheduler stopped.")
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() # Read from VERSION file
current_version = get_current_version() # Use imported function
update_info = {
"update_available": update_available,
"latest_version": latest_version,
@@ -119,7 +101,7 @@ async def lifespan(app: FastAPI):
await _setup_database_and_config(settings) # Pass settings object
# Perform update check after core components are ready
await _perform_update_check(app)
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
# Start the scheduler
_start_scheduler()
@@ -148,7 +130,7 @@ def create_app() -> FastAPI:
# 创建FastAPI应用
# Read version from file for consistency
current_version = _get_current_version()
current_version = get_current_version() # Use imported function
app = FastAPI(
title="Gemini Balance API",
description="Gemini API代理服务支持负载均衡和密钥管理",

View File

@@ -40,3 +40,40 @@ DEFAULT_STREAM_CHUNK_SIZE = 5
# 正则表达式模式
IMAGE_URL_PATTERN = r'!\[(.*?)\]\((.*?)\)'
DATA_URL_PATTERN = r'data:([^;]+);base64,(.+)'
# Audio/Video Settings
SUPPORTED_AUDIO_FORMATS = ["wav", "mp3", "flac", "ogg"]
SUPPORTED_VIDEO_FORMATS = ["mp4", "mov", "avi", "webm"]
MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024 # Example: 50MB limit for Base64 payload
MAX_VIDEO_SIZE_BYTES = 200 * 1024 * 1024 # Example: 200MB limit
# Optional: Define MIME type mappings if needed, or handle directly in converter
AUDIO_FORMAT_TO_MIMETYPE = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"flac": "audio/flac",
"ogg": "audio/ogg",
}
VIDEO_FORMAT_TO_MIMETYPE = {
"mp4": "video/mp4",
"mov": "video/quicktime",
"avi": "video/x-msvideo",
"webm": "video/webm",
}
GEMINI_2_FLASH_EXP_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
]
DEFAULT_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
]

View File

@@ -1,8 +1,10 @@
"""
数据库连接池模块
"""
from pathlib import Path
from databases import Database
from sqlalchemy import create_engine, MetaData
# from sqlalchemy.orm import sessionmaker # 不再需要
from sqlalchemy.ext.declarative import declarative_base
from app.config.config import settings
@@ -11,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}:{settings.MYSQL_PASSWORD}@/{settings.MYSQL_DATABASE}?unix_socket={settings.MYSQL_SOCKET}"
else:
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
else:
raise ValueError("Unsupported database type. Please set DATABASE_TYPE to 'sqlite' or 'mysql'.")
# 创建数据库引擎
# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效
@@ -23,22 +37,27 @@ 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) # Reduced recycle time to 30 mins
# 移除了 SessionLocal 和 get_db 函数
# --- Async connection functions for lifespan/async routes ---
async def connect_to_db():
"""
连接到数据库
"""
try:
await database.connect()
logger.info("Connected to database")
logger.info(f"Connected to {settings.DATABASE_TYPE}")
except Exception as e:
logger.error(f"Failed to connect to database: {str(e)}")
raise
@@ -50,6 +69,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

@@ -1,14 +1,12 @@
"""
数据库服务模块
"""
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
from sqlalchemy import func, desc, asc, select, insert, update, delete
import json
from typing import Dict, List, Optional, Any, Union
from datetime import datetime # Keep this import
from sqlalchemy import select, insert, update, func
from app.database.connection import database
from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog
from app.database.models import Settings, ErrorLog, RequestLog
from app.log.logger import get_database_logger
logger = get_database_logger()
@@ -157,19 +155,25 @@ async def get_error_logs(
offset: int = 0,
key_search: Optional[str] = None,
error_search: Optional[str] = None,
error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
end_date: Optional[datetime] = None,
sort_by: str = 'id', # 新增排序字段
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
) -> List[Dict[str, Any]]:
"""
获取错误日志,支持搜索日期过滤
获取错误日志,支持搜索日期过滤和排序
Args:
limit (int): 限制数量
offset (int): 偏移量
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
start_date (Optional[datetime]): 开始日期时间
end_date (Optional[datetime]): 结束日期时间
sort_by (str): 排序字段 (例如 'id', 'request_time')
sort_order (str): 排序顺序 ('asc' or 'desc')
Returns:
List[Dict[str, Any]]: 错误日志列表
@@ -198,10 +202,28 @@ async def get_error_logs(
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
# or handle as needed (e.g., return no results for invalid code format)
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force no results if the format is invalid:
# query = query.where(False) # This ensures no rows are returned
# 添加排序逻辑
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id
if sort_order.lower() == 'asc':
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply limit and offset
query = query.limit(limit).offset(offset)
# Apply ordering, limit, and offset
query = query.order_by(ErrorLog.id.desc()).limit(limit).offset(offset)
result = await database.fetch_all(query)
return [dict(row) for row in result]
except Exception as e:
@@ -212,6 +234,7 @@ async def get_error_logs(
async def get_error_logs_count(
key_search: Optional[str] = None,
error_search: Optional[str] = None,
error_code_search: Optional[str] = None, # Added error code search
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> int:
@@ -221,6 +244,7 @@ async def get_error_logs_count(
Args:
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
start_date (Optional[datetime]): 开始日期时间
end_date (Optional[datetime]): 结束日期时间
@@ -243,6 +267,16 @@ async def get_error_logs_count(
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force count to 0 if the format is invalid:
# return 0 # Or query = query.where(False) before fetching
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
@@ -281,6 +315,68 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise
# --- 异步删除函数 (使用 databases 库) ---
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
根据提供的 ID 列表批量删除错误日志 (异步)。
Args:
log_ids: 要删除的错误日志 ID 列表。
Returns:
int: 实际删除的日志数量。
"""
if not log_ids:
return 0
try:
# 使用 databases 执行删除
query = delete(ErrorLog).where(ErrorLog.id.in_(log_ids))
# execute 返回受影响的行数,但 databases 库的 execute 不直接返回 rowcount
# 我们需要先查询是否存在,或者依赖数据库约束/触发器(如果适用)
# 或者,我们可以执行删除并假设成功,除非抛出异常
# 为了简单起见,我们执行删除并记录日志,不精确返回删除数量
# 如果需要精确数量,需要先执行 SELECT COUNT(*)
await database.execute(query)
# 注意databases 的 execute 不返回 rowcount所以我们不能直接返回删除的数量
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
return len(log_ids) # 返回尝试删除的数量
except Exception as e:
# 数据库连接或执行错误
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
async def delete_error_log_by_id(log_id: int) -> bool:
"""
根据 ID 删除单个错误日志 (异步)。
Args:
log_id: 要删除的错误日志 ID。
Returns:
bool: 如果成功删除返回 True否则返回 False。
"""
try:
# 先检查是否存在 (可选,但更明确)
check_query = select(ErrorLog.id).where(ErrorLog.id == log_id)
exists = await database.fetch_one(check_query)
if not exists:
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
return False # 或者可以抛出 404 异常,由路由处理
# 执行删除
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
await database.execute(delete_query)
logger.info(f"Successfully deleted error log with ID: {log_id}")
return True
except Exception as e:
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
# --- RequestLog Services (保持异步) ---
# 新增函数:添加请求日志
async def add_request_log(
model_name: Optional[str],

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel
from typing import List, Optional, Union
from typing import Any, Dict, List, Optional, Union
from app.core.constants import DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
@@ -9,11 +9,14 @@ class ChatRequest(BaseModel):
model: str = DEFAULT_MODEL
temperature: Optional[float] = DEFAULT_TEMPERATURE
stream: Optional[bool] = False
tools: Optional[List[dict]] = []
max_tokens: Optional[int] = None
top_p: Optional[float] = DEFAULT_TOP_P
top_k: Optional[int] = DEFAULT_TOP_K
stop: Optional[List[str]] = []
stop: Optional[Union[List[str],str]] = None
reasoning_effort: Optional[str] = None
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
tool_choice: Optional[str] = None
response_format: Optional[dict] = None
class EmbeddingRequest(BaseModel):
@@ -23,10 +26,10 @@ class EmbeddingRequest(BaseModel):
class ImageGenerationRequest(BaseModel):
model: str = "DALL-E-3"
model: str = "imagen-3.0-generate-002"
prompt: str = ""
n: int = 1
size: Optional[str] = "1024x1024"
quality: Optional[str] = ""
style: Optional[str] = ""
quality: Optional[str] = None
style: Optional[str] = None
response_format: Optional[str] = "url"

View File

@@ -0,0 +1,32 @@
from contextlib import asynccontextmanager
from fastapi import HTTPException
import logging
@asynccontextmanager
async def handle_route_errors(logger: logging.Logger, operation_name: str, success_message: str = None, failure_message: str = None):
"""
一个异步上下文管理器,用于统一处理 FastAPI 路由中的常见错误和日志记录。
Args:
logger: 用于记录日志的 Logger 实例。
operation_name: 操作的名称,用于日志记录和错误详情。
success_message: 操作成功时记录的自定义消息 (可选)。
failure_message: 操作失败时记录的自定义消息 (可选)。
"""
default_success_msg = f"{operation_name} request successful"
default_failure_msg = f"{operation_name} request failed"
logger.info("-" * 50 + operation_name + "-" * 50)
try:
yield
logger.info(success_message or default_success_msg)
except HTTPException as http_exc:
# 如果已经是 HTTPException直接重新抛出保留原始状态码和详情
logger.error(f"{failure_message or default_failure_msg}: {http_exc.detail} (Status: {http_exc.status_code})")
raise http_exc
except Exception as e:
# 对于其他所有异常,记录错误并抛出标准的 500 错误
logger.error(f"{failure_message or default_failure_msg}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Internal server error during {operation_name}"
) from e

View File

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

View File

@@ -1,12 +1,12 @@
import base64
import json
import random
import string
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional
import time
import uuid
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from app.config.config import settings
from app.utils.uploader import ImageUploaderFactory
@@ -15,7 +15,9 @@ class ResponseHandler(ABC):
"""响应处理器基类"""
@abstractmethod
def handle_response(self, response: Dict[str, Any], model: str, stream: bool = False) -> Dict[str, Any]:
def handle_response(
self, response: Dict[str, Any], model: str, stream: bool = False
) -> Dict[str, Any]:
pass
@@ -26,32 +28,44 @@ class GeminiResponseHandler(ResponseHandler):
self.thinking_first = True
self.thinking_status = False
def handle_response(self, response: Dict[str, Any], model: str, stream: bool = False) -> Dict[str, Any]:
def handle_response(
self, response: Dict[str, Any], model: str, stream: bool = False, usage_metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
if stream:
return _handle_gemini_stream_response(response, model, stream)
return _handle_gemini_normal_response(response, model, stream)
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
def _handle_openai_stream_response(
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=True, gemini_format=False
)
if not text and not tool_calls:
delta = {}
else:
delta = {"content": text, "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) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
def _handle_openai_normal_response(
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=False, gemini_format=False
)
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
@@ -60,11 +74,15 @@ def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
"message": {
"role": "assistant",
"content": text,
"tool_calls": tool_calls,
},
"finish_reason": finish_reason,
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"usage": {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)},
}
@@ -77,59 +95,68 @@ class OpenAIResponseHandler(ResponseHandler):
self.thinking_status = False
def handle_response(
self,
response: Dict[str, Any],
model: str,
stream: bool = False,
finish_reason: str = None
self,
response: Dict[str, Any],
model: str,
stream: bool = False,
finish_reason: str = None,
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)
def handle_image_chat_response(self, image_str: str, model: str, stream=False, finish_reason="stop"):
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"
):
if stream:
return _handle_openai_stream_image_response(image_str,model,finish_reason)
return _handle_openai_normal_image_response(image_str,model,finish_reason)
def _handle_openai_stream_image_response(image_str: str,model: str,finish_reason: str) -> Dict[str, Any]:
return _handle_openai_stream_image_response(image_str, model, finish_reason)
return _handle_openai_normal_image_response(image_str, model, finish_reason)
def _handle_openai_stream_image_response(
image_str: str, model: str, finish_reason: str
) -> Dict[str, Any]:
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"delta": {"content": image_str} if image_str else {},
"finish_reason": finish_reason
}]
"choices": [
{
"index": 0,
"delta": {"content": image_str} if image_str else {},
"finish_reason": finish_reason,
}
],
}
def _handle_openai_normal_image_response(image_str: str,model: str,finish_reason: str) -> Dict[str, Any]:
def _handle_openai_normal_image_response(
image_str: str, model: str, finish_reason: str
) -> Dict[str, Any]:
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": image_str
},
"finish_reason": finish_reason
}],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": image_str},
"finish_reason": finish_reason,
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
def _extract_result(response: Dict[str, Any], model: str, stream: bool = False, gemini_format: bool = False) -> tuple[str, List[Dict[str, Any]]]:
def _extract_result(
response: Dict[str, Any],
model: str,
stream: bool = False,
gemini_format: bool = False,
) -> tuple[str, List[Dict[str, Any]]]:
text, tool_calls = "", []
if stream:
if response.get("candidates"):
@@ -145,13 +172,9 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
elif "codeExecution" in parts[0]:
text = _format_code_block(parts[0]["codeExecution"])
elif "executableCodeResult" in parts[0]:
text = _format_execution_result(
parts[0]["executableCodeResult"]
)
text = _format_execution_result(parts[0]["executableCodeResult"])
elif "codeExecutionResult" in parts[0]:
text = _format_execution_result(
parts[0]["codeExecutionResult"]
)
text = _format_execution_result(parts[0]["codeExecutionResult"])
elif "inlineData" in parts[0]:
text = _extract_image_data(parts[0])
else:
@@ -165,10 +188,10 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
if settings.SHOW_THINKING_PROCESS:
if len(candidate["content"]["parts"]) == 2:
text = (
"> thinking\n\n"
+ candidate["content"]["parts"][0]["text"]
+ "\n\n---\n> output\n\n"
+ candidate["content"]["parts"][1]["text"]
"> thinking\n\n"
+ candidate["content"]["parts"][0]["text"]
+ "\n\n---\n> output\n\n"
+ candidate["content"]["parts"][1]["text"]
)
else:
text = candidate["content"]["parts"][0]["text"]
@@ -186,34 +209,47 @@ def _extract_result(response: Dict[str, Any], model: str, stream: bool = False,
elif "inlineData" in part:
text += _extract_image_data(part)
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(candidate["content"]["parts"], gemini_format)
tool_calls = _extract_tool_calls(
candidate["content"]["parts"], gemini_format
)
else:
text = "暂无返回"
return text, tool_calls
def _extract_image_data(part: dict) -> str:
image_uploader = None
if settings.UPLOAD_PROVIDER == "smms":
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,api_key=settings.SMMS_SECRET_TOKEN)
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER, api_key=settings.SMMS_SECRET_TOKEN
)
elif settings.UPLOAD_PROVIDER == "picgo":
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,api_key=settings.PICGO_API_KEY)
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER, api_key=settings.PICGO_API_KEY
)
elif settings.UPLOAD_PROVIDER == "cloudflare_imgbed":
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,base_url=settings.CLOUDFLARE_IMGBED_URL,auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE)
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER,
base_url=settings.CLOUDFLARE_IMGBED_URL,
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
)
current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
base64_data = part["inlineData"]["data"]
#将base64_data转成bytes数组
# 将base64_data转成bytes数组
bytes_data = base64.b64decode(base64_data)
upload_response = image_uploader.upload(bytes_data,filename)
upload_response = image_uploader.upload(bytes_data, filename)
if upload_response.success:
text = f"\n\n![image]({upload_response.data.url})\n\n"
else:
text = ""
return text
def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> List[Dict[str, Any]]:
def _extract_tool_calls(
parts: List[Dict[str, Any]], gemini_format: bool
) -> List[Dict[str, Any]]:
"""提取工具调用信息"""
if not parts or not isinstance(parts, list):
return []
@@ -249,8 +285,12 @@ def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> Lis
return tool_calls
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
def _handle_gemini_stream_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=stream, gemini_format=True
)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
@@ -259,8 +299,12 @@ def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream:
return response
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
def _handle_gemini_normal_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls = _extract_result(
response, model, stream=stream, gemini_format=True
)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
@@ -278,10 +322,10 @@ def _format_code_block(code_data: dict) -> str:
def _add_search_link_text(model: str, candidate: dict, text: str) -> str:
if (
settings.SHOW_SEARCH_LINK
and model.endswith("-search")
and "groundingMetadata" in candidate
and "groundingChunks" in candidate["groundingMetadata"]
settings.SHOW_SEARCH_LINK
and model.endswith("-search")
and "groundingMetadata" in candidate
and "groundingChunks" in candidate["groundingMetadata"]
):
grounding_chunks = candidate["groundingMetadata"]["groundingChunks"]
text += "\n\n---\n\n"

View File

@@ -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

@@ -206,3 +206,20 @@ def get_update_logger():
def get_scheduler_routes():
return Logger.setup_logger("scheduler_routes")
def get_message_converter_logger():
return Logger.setup_logger("message_converter")
def get_api_client_logger():
return Logger.setup_logger("api_client")
def get_openai_compatible_logger():
return Logger.setup_logger("openai_compatible")
def get_error_log_logger():
return Logger.setup_logger("error_log")

View File

@@ -30,6 +30,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
and not request.url.path.startswith(f"/{API_VERSION}")
and not request.url.path.startswith("/health")
and not request.url.path.startswith("/hf")
and not request.url.path.startswith("/openai")
and not request.url.path.startswith("/api/version/check")
):
auth_token = request.cookies.get("auth_token")

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,90 @@ async def reset_config(request: Request):
return await ConfigService.reset_config()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# Pydantic model for bulk delete request
class DeleteKeysRequest(BaseModel):
keys: List[str] = Field(..., description="List of API keys to delete")
@router.delete("/keys/{key_to_delete}", response_model=Dict[str, Any])
async def delete_single_key(key_to_delete: str, request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized attempt to delete key: {key_to_delete}")
return RedirectResponse(url="/", status_code=302)
try:
logger.info(f"Attempting to delete key: {key_to_delete}")
result = await ConfigService.delete_key(key_to_delete)
if not result.get("success"):
# Optionally, translate specific errors to HTTP status codes
# For now, let's assume 400 for any failure from service if not found,
# or 500 if it was an unexpected error (though service should handle that)
raise HTTPException(
status_code=(
404 if "not found" in result.get("message", "").lower() else 400
),
detail=result.get("message"),
)
return result
except HTTPException as e:
# Re-raise HTTPExceptions directly
raise e
except Exception as e:
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Error deleting key: {str(e)}")
@router.post("/keys/delete-selected", response_model=Dict[str, Any])
async def delete_selected_keys_route(
delete_request: DeleteKeysRequest, request: Request
):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized attempt to bulk delete keys")
return RedirectResponse(url="/", status_code=302)
if not delete_request.keys:
logger.warning("Attempt to bulk delete keys with an empty list.")
raise HTTPException(status_code=400, detail="No keys provided for deletion.")
try:
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
result = await ConfigService.delete_selected_keys(delete_request.keys)
# Similar to single delete, we can check result["success"]
if not result.get("success") and result.get("deleted_count", 0) == 0:
# If no keys were actually deleted, it might be a client error (e.g., all keys not found)
# or an empty list was somehow passed despite the check above.
raise HTTPException(
status_code=400, detail=result.get("message", "Failed to delete keys.")
)
# If some keys were deleted but others not found, it's still a partial success, return 200 with details.
return result
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Error bulk deleting keys: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Error bulk deleting keys: {str(e)}"
)
@router.get("/ui/models")
async def get_ui_models(request: Request):
auth_token_cookie = request.cookies.get("auth_token")
if not auth_token_cookie or not verify_auth_token(auth_token_cookie):
logger.warning("Unauthorized access attempt to /api/config/ui/models")
raise HTTPException(status_code=403, detail="Not authenticated")
try:
models = await ConfigService.fetch_ui_models()
return models
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Unexpected error in /ui/models endpoint: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred while fetching UI models: {str(e)}",
)

View File

@@ -0,0 +1,211 @@
"""
日志路由模块
"""
from datetime import datetime
from typing import Dict, List, Optional
from fastapi import (
APIRouter,
Body,
HTTPException,
Path,
Query,
Request,
Response,
status,
)
from pydantic import BaseModel
from app.core.security import verify_auth_token
from app.log.logger import get_log_routes_logger
from app.service.error_log import error_log_service
router = APIRouter(prefix="/api/logs", tags=["logs"])
logger = get_log_routes_logger()
class ErrorLogListItem(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_code: Optional[int] = None
model_name: Optional[str] = None
request_time: Optional[datetime] = None
class ErrorLogListResponse(BaseModel):
logs: List[ErrorLogListItem]
total: int
@router.get("/errors", response_model=ErrorLogListResponse)
async def get_error_logs_api(
request: Request,
limit: int = Query(10, ge=1, le=1000),
offset: int = Query(0, ge=0),
key_search: Optional[str] = Query(
None, description="Search term for Gemini key (partial match)"
),
error_search: Optional[str] = Query(
None, description="Search term for error type or log message"
),
error_code_search: Optional[str] = Query(
None, description="Search term for error code"
),
start_date: Optional[datetime] = Query(
None, description="Start datetime for filtering"
),
end_date: Optional[datetime] = Query(
None, description="End datetime for filtering"
),
sort_by: str = Query(
"id", description="Field to sort by (e.g., 'id', 'request_time')"
),
sort_order: str = Query("desc", description="Sort order ('asc' or 'desc')"),
):
"""
获取错误日志列表 (返回错误码),支持过滤和排序
Args:
request: 请求对象
limit: 限制数量
offset: 偏移量
key_search: 密钥搜索
error_search: 错误搜索 (可能搜索类型或日志内容由DB层决定)
error_code_search: 错误码搜索
start_date: 开始日期
end_date: 结束日期
sort_by: 排序字段
sort_order: 排序顺序
Returns:
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to error logs list")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
result = await error_log_service.process_get_error_logs(
limit=limit,
offset=offset,
key_search=key_search,
error_search=error_search,
error_code_search=error_code_search,
start_date=start_date,
end_date=end_date,
sort_by=sort_by,
sort_order=sort_order,
)
logs_data = result["logs"]
total_count = result["total"]
validated_logs = [ErrorLogListItem(**log) for log in logs_data]
return ErrorLogListResponse(logs=validated_logs, total=total_count)
except Exception as e:
logger.exception(f"Failed to get error logs list: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Failed to get error logs list: {str(e)}"
)
class ErrorLogDetailResponse(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_log: Optional[str] = None
request_msg: Optional[str] = None
model_name: Optional[str] = None
request_time: Optional[datetime] = None
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=1)):
"""
根据日志 ID 获取错误日志的详细信息 (包括 error_log 和 request_msg)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(
f"Unauthorized access attempt to error log details for ID: {log_id}"
)
raise HTTPException(status_code=401, detail="Not authenticated")
try:
log_details = await error_log_service.process_get_error_log_details(
log_id=log_id
)
if not log_details:
raise HTTPException(status_code=404, detail="Error log not found")
return ErrorLogDetailResponse(**log_details)
except HTTPException as http_exc:
raise http_exc
except Exception as e:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Failed to get error log details: {str(e)}"
)
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_logs_bulk_api(
request: Request, payload: Dict[str, List[int]] = Body(...)
):
"""
批量删除错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to bulk delete error logs")
raise HTTPException(status_code=401, detail="Not authenticated")
log_ids = payload.get("ids")
if not log_ids:
raise HTTPException(status_code=400, detail="No log IDs provided for deletion.")
try:
deleted_count = await error_log_service.process_delete_error_logs_by_ids(
log_ids
)
# 注意:异步函数返回的是尝试删除的数量,可能不是精确值
logger.info(
f"Attempted bulk deletion for {deleted_count} error logs with IDs: {log_ids}"
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except Exception as e:
logger.exception(f"Error bulk deleting error logs with IDs {log_ids}: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error during bulk deletion"
)
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
"""
删除单个错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized access attempt to delete error log ID: {log_id}")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
success = await error_log_service.process_delete_error_log_by_id(log_id)
if not success:
# 服务层现在在未找到时返回 False我们在这里转换为 404
raise HTTPException(
status_code=404, detail=f"Error log with ID {log_id} not found"
)
logger.info(f"Successfully deleted error log with ID: {log_id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
except HTTPException as http_exc:
raise http_exc
except Exception as e:
logger.exception(f"Error deleting error log with ID {log_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error during deletion"
)

View File

@@ -1,23 +1,22 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from copy import deepcopy
import asyncio
from app.config.config import settings
from app.log.logger import get_gemini_logger
from app.core.security import SecurityService
import asyncio # 导入 asyncio
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest # 添加导入
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest
from app.service.chat.gemini_chat_service import GeminiChatService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.core.constants import API_VERSION
# 路由设置
router = APIRouter(prefix=f"/gemini/{API_VERSION}")
router_v1beta = APIRouter(prefix=f"/{API_VERSION}")
logger = get_gemini_logger()
# 初始化服务
security_service = SecurityService()
model_service = ModelService()
@@ -43,67 +42,60 @@ async def list_models(
_=Depends(security_service.verify_key_or_goog_api_key),
key_manager: KeyManager = Depends(get_key_manager)
):
"""获取可用的Gemini模型列表"""
logger.info("-" * 50 + "list_gemini_models" + "-" * 50)
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
operation_name = "list_gemini_models"
logger.info("-" * 50 + operation_name + "-" * 50)
logger.info("Handling Gemini models list request")
api_key = await key_manager.get_first_valid_key()
logger.info(f"Using API key: {api_key}")
models_json = model_service.get_gemini_models(api_key)
model_mapping = {x.get("name", "").split("/", maxsplit=1)[1]: x for x in models_json["models"]}
# 添加搜索模型
if settings.SEARCH_MODELS:
for name in settings.SEARCH_MODELS:
model = model_mapping.get(name)
try:
api_key = await key_manager.get_first_valid_key()
if not api_key:
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
logger.info(f"Using API key: {api_key}")
models_data = await model_service.get_gemini_models(api_key)
if not models_data or "models" not in models_data:
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
models_json = deepcopy(models_data)
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
def add_derived_model(base_name, suffix, display_suffix):
model = model_mapping.get(base_name)
if not model:
continue
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
return
item = deepcopy(model)
item["name"] = f"models/{name}-search"
display_name = f'{item.get("displayName")} For Search'
item["name"] = f"models/{base_name}{suffix}"
display_name = f'{item.get("displayName", base_name)}{display_suffix}'
item["displayName"] = display_name
item["description"] = display_name
models_json["models"].append(item)
# 添加图像生成模型
if settings.IMAGE_MODELS:
for name in settings.IMAGE_MODELS:
model = model_mapping.get(name)
if not model:
continue
item = deepcopy(model)
item["name"] = f"models/{name}-image"
display_name = f'{item.get("displayName")} For Image'
item["displayName"] = display_name
item["description"] = display_name
models_json["models"].append(item)
# 添加思考模型的非思考版本
if settings.THINKING_MODELS:
for name in settings.THINKING_MODELS:
model = model_mapping.get(name)
if not model:
continue
item = deepcopy(model)
item["name"] = f"models/{name}-non-thinking"
display_name = f'{item.get("displayName")} Non Thinking'
item["displayName"] = display_name
item["description"] = display_name
models_json["models"].append(item)
return models_json
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")
@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,
@@ -112,30 +104,27 @@ async def generate_content(
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""非流式生成内容"""
logger.info("-" * 50 + "gemini_generate_content" + "-" * 50)
logger.info(f"Handling Gemini content generation request for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
try:
"""处理 Gemini 非流式内容生成请求。"""
operation_name = "gemini_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
logger.info(f"Handling Gemini content generation request for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not await model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
response = await chat_service.generate_content(
model=model_name,
request=request,
api_key=api_key
)
return response
except Exception as e:
logger.error(f"Chat completion failed after retries: {str(e)}")
raise HTTPException(status_code=500, detail="Chat completion failed") from e
@router.post("/models/{model_name}:streamGenerateContent")
@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,
@@ -144,25 +133,23 @@ async def stream_generate_content(
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""流式生成内容"""
logger.info("-" * 50 + "gemini_stream_generate_content" + "-" * 50)
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
try:
"""处理 Gemini 流式内容生成请求。"""
operation_name = "gemini_stream_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not await model_service.check_model_support(model_name):
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
response_stream = chat_service.stream_generate_content(
model=model_name,
request=request,
api_key=api_key
)
return StreamingResponse(response_stream, media_type="text/event-stream")
except Exception as e:
logger.error(f"Streaming request failed: {str(e)}")
raise HTTPException(status_code=500, detail="Streaming request failed") from e
@router.post("/reset-all-fail-counts")
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
@@ -211,7 +198,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:
@@ -227,38 +214,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密钥的失败计数"""
@@ -274,6 +254,7 @@ async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(g
logger.error(f"Failed to reset key failure count: {str(e)}")
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密钥的有效性"""
@@ -281,14 +262,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(
@@ -302,7 +283,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
@@ -325,76 +305,70 @@ async def verify_selected_keys(
if not keys_to_verify:
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
valid_count = 0
invalid_count = 0
verification_errors = {} # 存储验证过程中的错误
successful_keys = []
failed_keys = {}
async def _verify_single_key(api_key: str):
"""内部函数,用于验证单个密钥并处理异常"""
nonlocal valid_count, invalid_count # 允许修改外部计数器
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
)
# 如果上面没有抛出异常,则认为密钥有效
valid_count += 1
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")
invalid_count += 1
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:
key, status, error = result
if status == "invalid" and error:
verification_errors[key] = error # 记录具体的验证错误信息
if not isinstance(result, Exception) and result:
key, status, error = result
elif isinstance(result, Exception):
logger.error(f"Task execution error during bulk verification: {result}")
valid_count = len(successful_keys)
invalid_count = len(failed_keys)
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
# 根据是否有错误决定最终消息和状态
if verification_errors or valid_count + invalid_count != len(keys_to_verify): # 检查是否有错误或任务异常
error_summary = "; ".join([f"{k}: {v}" for k, v in verification_errors.items()])
message = f"批量验证完成,但出现问题。有效: {valid_count}, 无效: {invalid_count}。错误详情: {error_summary or '任务执行异常'}"
return JSONResponse({
"success": False, # 标记为失败,因为有错误
"message": message,
"valid_count": valid_count,
"invalid_count": invalid_count,
"errors": verification_errors
}, status_code=207) # 207 Multi-Status 表示部分成功/失败
else:
# 完全成功
if failed_keys:
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}"
return JSONResponse({
"success": True,
"message": f"批量验证成功完成。有效: {valid_count}, 无效: {invalid_count}",
"message": message,
"successful_keys": successful_keys,
"failed_keys": failed_keys,
"valid_count": valid_count,
"invalid_count": invalid_count
})
else:
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
return JSONResponse({
"success": True,
"message": message,
"successful_keys": successful_keys,
"failed_keys": {},
"valid_count": valid_count,
"invalid_count": 0
})

View File

@@ -1,124 +0,0 @@
"""
日志路由模块
"""
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Request, Query, Path
from app.core.security import verify_auth_token
from app.log.logger import get_log_routes_logger
# 假设这些服务函数已更新或添加
from app.database.services import get_error_logs, get_error_logs_count, get_error_log_details
# 创建路由
router = APIRouter(prefix="/api/logs", tags=["logs"])
logger = get_log_routes_logger()
# Define a response model that includes the total count for pagination
# 用于列表响应的模型,假设 get_error_logs 返回包含 error_code 的字典
class ErrorLogListItem(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_code: Optional[int] = None # 列表显示错误码 (应为整数)
model_name: Optional[str] = None
request_time: Optional[datetime] = None
class ErrorLogListResponse(BaseModel):
logs: List[ErrorLogListItem] # 使用定义的模型列表
total: int
@router.get("/errors", response_model=ErrorLogListResponse)
async def get_error_logs_api(
request: Request,
limit: int = Query(10, ge=1, le=1000),
offset: int = Query(0, ge=0),
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
):
"""
获取错误日志列表 (返回错误码)
Args:
request: 请求对象
limit: 限制数量
offset: 偏移量
key_search: 密钥搜索
error_search: 错误搜索 (可能搜索类型或日志内容由DB层决定)
start_date: 开始日期
end_date: 结束日期
Returns:
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to error logs list")
# API 返回 401 更合适
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# 假设 get_error_logs 现在返回包含 error_code 的字典列表
# 并且可以接受 include_error_code 参数 (如果需要显式指定)
logs_data = await get_error_logs(
limit=limit,
offset=offset,
key_search=key_search,
error_search=error_search, # 数据库查询需要处理这个
start_date=start_date,
end_date=end_date,
)
# Fetch total count with the same search parameters
total_count = await get_error_logs_count(
key_search=key_search,
error_search=error_search,
start_date=start_date,
end_date=end_date
)
# 验证并转换数据以匹配 Pydantic 模型
validated_logs = [ErrorLogListItem(**log) for log in logs_data]
return ErrorLogListResponse(logs=validated_logs, total=total_count)
except Exception as e:
logger.exception(f"Failed to get error logs list: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error logs list: {str(e)}")
# 新增:获取错误日志详情的路由
class ErrorLogDetailResponse(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_log: Optional[str] = None # 详情接口返回完整的 error_log
request_msg: Optional[str] = None # 详情接口返回 request_msg
model_name: Optional[str] = None
request_time: Optional[datetime] = None
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=1)):
"""
根据日志 ID 获取错误日志的详细信息 (包括 error_log 和 request_msg)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized access attempt to error log details for ID: {log_id}")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# 假设存在一个函数 get_error_log_details(log_id) 来获取完整信息
log_details = await get_error_log_details(log_id=log_id)
if not log_details:
raise HTTPException(status_code=404, detail="Error log not found")
# 假设 get_error_log_details 返回一个字典或兼容 Pydantic 的对象
return ErrorLogDetailResponse(**log_details)
except HTTPException as http_exc:
# Re-raise HTTPException (like 404)
raise http_exc
except Exception as e:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error log details: {str(e)}")

View File

@@ -0,0 +1,113 @@
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from app.config.config import settings
from app.core.security import SecurityService
from app.domain.openai_models import (
ChatRequest,
EmbeddingRequest,
ImageGenerationRequest,
)
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.log.logger import get_openai_compatible_logger
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.openai_compatiable.openai_compatiable_service import OpenAICompatiableService
router = APIRouter()
logger = get_openai_compatible_logger()
security_service = SecurityService()
async def get_key_manager():
return await get_key_manager_instance()
async def get_next_working_key_wrapper(
key_manager: KeyManager = Depends(get_key_manager),
):
return await key_manager.get_next_working_key()
async def get_openai_service(key_manager: KeyManager = Depends(get_key_manager)):
"""获取OpenAI聊天服务实例"""
return OpenAICompatiableService(settings.BASE_URL, key_manager)
@router.get("/openai/v1/models")
async def list_models(
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""获取可用模型列表。"""
operation_name = "list_models"
async with handle_route_errors(logger, operation_name):
logger.info("Handling models list request")
api_key = await key_manager.get_first_valid_key()
logger.info(f"Using API key: {api_key}")
return await openai_service.get_models(api_key)
@router.post("/openai/v1/chat/completions")
@RetryHandler(key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),
api_key: str = Depends(get_next_working_key_wrapper),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""处理聊天补全请求,支持流式响应和特定模型切换。"""
operation_name = "chat_completion"
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
current_api_key = api_key
if is_image_chat:
current_api_key = await key_manager.get_paid_key()
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling chat completion request for model: {request.model}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {current_api_key}")
if is_image_chat:
response = await openai_service.create_image_chat_completion(request, current_api_key)
return response
else:
response = await openai_service.create_chat_completion(request, current_api_key)
if request.stream:
return StreamingResponse(response, media_type="text/event-stream")
return response
@router.post("/openai/v1/images/generations")
async def generate_image(
request: ImageGenerationRequest,
_=Depends(security_service.verify_authorization),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""处理图像生成请求。"""
operation_name = "generate_image"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling image generation request for prompt: {request.prompt}")
request.model = settings.CREATE_IMAGE_MODEL
return await openai_service.generate_images(request)
@router.post("/openai/v1/embeddings")
async def embedding(
request: EmbeddingRequest,
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
"""处理文本嵌入请求。"""
operation_name = "embedding"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling embedding request for model: {request.model}")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
return await openai_service.create_embeddings(
input_text=request.input, model=request.model, api_key=api_key
)

View File

@@ -9,6 +9,7 @@ from app.domain.openai_models import (
ImageGenerationRequest,
)
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.log.logger import get_openai_logger
from app.service.chat.openai_chat_service import OpenAIChatService
from app.service.embedding.embedding_service import EmbeddingService
@@ -19,7 +20,6 @@ from app.service.model.model_service import ModelService
router = APIRouter()
logger = get_openai_logger()
# 初始化服务
security_service = SecurityService()
model_service = ModelService()
embedding_service = EmbeddingService()
@@ -47,56 +47,52 @@ async def list_models(
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
):
logger.info("-" * 50 + "list_models" + "-" * 50)
logger.info("Handling models list request")
api_key = await key_manager.get_first_valid_key()
logger.info(f"Using API key: {api_key}")
try:
return model_service.get_gemini_openai_models(api_key)
except Exception as e:
logger.error(f"Error getting models list: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error while fetching models list"
) from e
"""获取可用的 OpenAI 模型列表 (兼容 Gemini 和 OpenAI)。"""
operation_name = "list_models"
async with handle_route_errors(logger, operation_name):
logger.info("Handling models list request")
api_key = await key_manager.get_first_valid_key()
logger.info(f"Using API key: {api_key}")
return await model_service.get_gemini_openai_models(api_key)
@router.post("/v1/chat/completions")
@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),
):
# 如果model是imagen3,使用paid_key
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
api_key = await key_manager.get_paid_key()
logger.info("-" * 50 + "chat_completion" + "-" * 50)
logger.info(f"Handling chat completion request for model: {request.model}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
"""处理 OpenAI 聊天补全请求,支持流式响应和特定模型切换。"""
operation_name = "chat_completion"
is_image_chat = request.model == f"{settings.CREATE_IMAGE_MODEL}-chat"
current_api_key = api_key
if is_image_chat:
current_api_key = await key_manager.get_paid_key()
if not model_service.check_model_support(request.model):
raise HTTPException(
status_code=400, detail=f"Model {request.model} is not supported"
)
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling chat completion request for model: {request.model}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {current_api_key}")
try:
# 如果model是imagen3,使用paid_key
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
response = await chat_service.create_image_chat_completion(request, api_key)
if not await model_service.check_model_support(request.model):
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)
if request.stream:
return StreamingResponse(response, media_type="text/event-stream")
return response
else:
response = await chat_service.create_chat_completion(request, api_key)
# 处理流式响应
if request.stream:
return StreamingResponse(response, media_type="text/event-stream")
logger.info("Chat completion request successful")
return response
except Exception as e:
logger.error(f"Chat completion failed after retries: {str(e)}")
raise HTTPException(status_code=500, detail="Chat completion failed") from e
response = await chat_service.create_chat_completion(request, current_api_key)
if request.stream:
return StreamingResponse(response, media_type="text/event-stream")
return response
@router.post("/v1/images/generations")
@@ -105,18 +101,12 @@ async def generate_image(
request: ImageGenerationRequest,
_=Depends(security_service.verify_authorization),
):
logger.info("-" * 50 + "generate_image" + "-" * 50)
logger.info(f"Handling image generation request for prompt: {request.prompt}")
try:
"""处理 OpenAI 图像生成请求。"""
operation_name = "generate_image"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling image generation request for prompt: {request.prompt}")
response = image_create_service.generate_images(request)
logger.info("Image generation request successful")
return response
except Exception as e:
logger.error(f"Image generation request failed: {str(e)}")
raise HTTPException(
status_code=500, detail="Image generation request failed"
) from e
@router.post("/v1/embeddings")
@@ -126,19 +116,16 @@ async def embedding(
_=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
):
logger.info("-" * 50 + "embedding" + "-" * 50)
logger.info(f"Handling embedding request for model: {request.model}")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
try:
"""处理 OpenAI 文本嵌入请求。"""
operation_name = "embedding"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling embedding request for model: {request.model}")
api_key = await key_manager.get_next_working_key()
logger.info(f"Using API key: {api_key}")
response = await embedding_service.create_embedding(
input_text=request.input, model=request.model, api_key=api_key
)
logger.info("Embedding request successful")
return response
except Exception as e:
logger.error(f"Embedding request failed: {str(e)}")
raise HTTPException(status_code=500, detail="Embedding request failed") from e
@router.get("/v1/keys/list")
@@ -147,10 +134,10 @@ async def get_keys_list(
_=Depends(security_service.verify_auth_token),
key_manager: KeyManager = Depends(get_key_manager),
):
"""获取有效和无效的API key列表"""
logger.info("-" * 50 + "get_keys_list" + "-" * 50)
logger.info("Handling keys list request")
try:
"""获取有效和无效的API key列表 (需要管理 Token 认证)。"""
operation_name = "get_keys_list"
async with handle_route_errors(logger, operation_name):
logger.info("Handling keys list request")
keys_status = await key_manager.get_keys_by_status()
return {
"status": "success",
@@ -160,8 +147,3 @@ async def get_keys_list(
},
"total": len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]),
}
except Exception as e:
logger.error(f"Error getting keys list: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error while fetching keys list"
) from e

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 gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes, stats_routes # 新增导入 stats_routes
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes
from app.service.key.key_manager import get_key_manager_instance
from app.service.stats_service import StatsService
from app.service.stats.stats_service import StatsService
logger = get_routes_logger()
# 配置Jinja2模板
templates = Jinja2Templates(directory="app/templates")
@@ -25,21 +24,20 @@ def setup_routers(app: FastAPI) -> None:
Args:
app: FastAPI应用程序实例
"""
# 包含API路由
app.include_router(openai_routes.router)
app.include_router(gemini_routes.router)
app.include_router(gemini_routes.router_v1beta)
app.include_router(config_routes.router)
app.include_router(log_routes.router)
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
app.include_router(stats_routes.router) # 包含 stats API 路由
app.include_router(error_log_routes.router)
app.include_router(scheduler_routes.router)
app.include_router(stats_routes.router)
app.include_router(version_routes.router)
app.include_router(openai_compatiable_routes.router)
# 添加页面路由
setup_page_routes(app)
# 添加健康检查路由
setup_health_routes(app)
setup_api_stats_routes(app) # Add API stats routes
setup_api_stats_routes(app)
def setup_page_routes(app: FastAPI) -> None:
@@ -104,16 +102,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)
@@ -173,16 +169,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

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from starlette import status
from app.core.security import verify_auth_token
from app.service.stats_service import StatsService
from app.service.stats.stats_service import StatsService
from app.log.logger import get_stats_logger
logger = get_stats_logger()
@@ -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

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

View File

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

View File

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

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()
@@ -73,20 +74,8 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
"""获取安全设置"""
if model == "gemini-2.0-flash-exp":
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
]
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
]
return GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return settings.SAFETY_SETTINGS
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
@@ -101,8 +90,8 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
"contents": request_dict.get("contents", []),
"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"),
}
if model.endswith("-image") or model.endswith("-image-generation"):

View File

@@ -1,13 +1,19 @@
# app/services/chat_service.py
import asyncio
import datetime
import json
import re
import datetime # Add datetime import
import time # Add time import
import time
from copy import deepcopy
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.database.services import (
add_error_log,
add_request_log,
)
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
from app.handler.message_converter import OpenAIMessageConverter
from app.handler.response_handler import OpenAIResponseHandler
@@ -16,17 +22,16 @@ from app.log.logger import get_openai_logger
from app.service.client.api_client import GeminiApiClient
from app.service.image.image_create_service import ImageCreateService
from app.service.key.key_manager import KeyManager
from app.database.services import add_error_log, add_request_log # Import add_request_log
logger = get_openai_logger()
def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片部分"""
def _has_media_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片、音频或视频部分 (inline_data)"""
for content in contents:
if "parts" in content:
if content and "parts" in content and isinstance(content["parts"], list):
for part in content["parts"]:
if "image_url" in part or "inline_data" in part:
if isinstance(part, dict) and "inline_data" in part:
return True
return False
@@ -46,9 +51,13 @@ def _build_tools(
or model.endswith("-image")
or model.endswith("-image-generation")
)
and not _has_image_parts(messages)
and not _has_media_parts(messages) # Use the updated check
):
tool["codeExecution"] = {}
logger.debug("Code execution tool enabled.")
elif _has_media_parts(messages):
logger.debug("Code execution tool disabled due to media parts presence.")
if model.endswith("-search"):
tool["googleSearch"] = {}
@@ -62,7 +71,9 @@ def _build_tools(
if item.get("type", "") == "function" and item.get("function"):
function = deepcopy(item.get("function"))
parameters = function.get("parameters", {})
if parameters.get("type") == "object" and not parameters.get("properties", {}):
if parameters.get("type") == "object" and not parameters.get(
"properties", {}
):
function.pop("parameters", None)
function_declarations.append(function)
@@ -93,20 +104,8 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
# and "gemini-2.0-pro-exp" not in model
# ):
if model == "gemini-2.0-flash-exp":
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
]
return [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
]
return GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
return settings.SAFETY_SETTINGS
def _build_payload(
@@ -131,9 +130,11 @@ def _build_payload(
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if request.model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
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)}
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
}
if (
instruction
@@ -204,10 +205,15 @@ 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 # Assume 200 on success
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
@@ -218,17 +224,17 @@ class OpenAIChatService:
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
status_code = 500
await add_error_log(
gemini_key=api_key, # Note: Parameter name is gemini_key in add_error_log
gemini_key=api_key,
model_name=model,
error_type="openai-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload,
)
raise e # Re-throw exception
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
@@ -238,13 +244,122 @@ class OpenAIChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def _fake_stream_logic_impl(
self, model: str, payload: Dict[str, Any], api_key: str
) -> AsyncGenerator[str, None]:
"""处理伪流式 (fake stream) 的核心逻辑"""
logger.info(
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
)
keep_sending_empty_data = True
async def send_empty_data_locally() -> AsyncGenerator[str, None]:
"""定期发送空数据以保持连接"""
while keep_sending_empty_data:
await asyncio.sleep(settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS)
if keep_sending_empty_data:
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
yield f"data: {json.dumps(empty_chunk)}\n\n"
logger.debug("Sent empty data chunk for fake stream heartbeat.")
empty_data_generator = send_empty_data_locally()
api_response_task = asyncio.create_task(
self.api_client.generate_content(payload, model, api_key)
)
try:
while not api_response_task.done():
try:
next_empty_chunk = await asyncio.wait_for(
empty_data_generator.__anext__(), timeout=0.1
)
yield next_empty_chunk
except asyncio.TimeoutError:
pass
except (
StopAsyncIteration
):
break
response = await api_response_task
finally:
keep_sending_empty_data = False
if response and response.get("candidates"):
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {}))
yield f"data: {json.dumps(response)}\n\n"
logger.info(f"Sent full response content for fake stream: {model}")
else:
error_message = "Failed to get response from model"
if (
response and isinstance(response, dict) and response.get("error")
):
error_details = response.get("error")
if isinstance(error_details, dict):
error_message = error_details.get("message", error_message)
logger.error(
f"No candidates or error in response for fake stream model {model}: {response}"
)
error_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
yield f"data: {json.dumps(error_chunk)}\n\n"
async def _real_stream_logic_impl(
self, model: str, payload: Dict[str, Any], api_key: str
) -> AsyncGenerator[str, None]:
"""处理真实流式 (real stream) 的核心逻辑"""
tool_call_flag = False
usage_metadata = None
async for line in self.api_client.stream_generate_content(
payload, model, api_key
):
if line.startswith("data:"):
chunk_str = line[6:]
if not chunk_str or chunk_str.isspace():
logger.debug(
f"Received empty data line for model {model}, skipping."
)
continue
try:
chunk = json.loads(chunk_str)
usage_metadata = chunk.get("usageMetadata", {})
except json.JSONDecodeError:
logger.error(
f"Failed to decode JSON from stream for model {model}: {chunk_str}"
)
continue
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None, usage_metadata=usage_metadata
)
if openai_chunk:
text = self._extract_text_from_openai_chunk(openai_chunk)
if text and settings.STREAM_OPTIMIZER_ENABLED:
async for (
optimized_chunk_data
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(openai_chunk, t),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk_data
else:
if openai_chunk.get("choices") and openai_chunk["choices"][0].get("delta", {}).get("tool_calls"):
tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n"
if tool_call_flag:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls', usage_metadata=usage_metadata))}\n\n"
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=usage_metadata))}\n\n"
async def _handle_stream_completion(
self, model: str, payload: Dict[str, Any], api_key: str
) -> AsyncGenerator[str, None]:
"""处理流式聊天完成,添加重试逻辑"""
"""处理流式聊天完成,添加重试逻辑和假流式支持"""
retries = 0
max_retries = settings.MAX_RETRIES
is_success = False
@@ -254,110 +369,107 @@ 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
):
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 # Assume 200 on success
break # 成功后退出循环
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}"
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 # Default if parsing fails
if isinstance(e, asyncio.TimeoutError):
status_code = 408
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
error_type="openai-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload,
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else:
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
new_api_key = await self.key_manager.handle_api_failure(
current_attempt_key, 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}"
)
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 # Exit loop if key manager is missing
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."
f"Max retries ({max_retries}) reached for streaming model {model}."
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
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
self, request: ChatRequest, api_key: str
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
image_generate_request = ImageGenerationRequest()
@@ -367,18 +479,22 @@ class OpenAIChatService:
)
if request.stream:
return self._handle_stream_image_completion(request.model, image_res, api_key)
return self._handle_stream_image_completion(
request.model, image_res, api_key
)
else:
return await self._handle_normal_image_completion(request.model, image_res, api_key)
return await self._handle_normal_image_completion(
request.model, image_res, api_key
)
async def _handle_stream_image_completion(
self, model: str, image_data: str, api_key:str
self, model: str, image_data: str, api_key: str
) -> AsyncGenerator[str, None]:
logger.info(f"Starting stream image completion for model: {model}")
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Although not used for DB log here
request_datetime = datetime.datetime.now()
is_success = False
status_code = None # Although not used for DB log here
status_code = None
try:
if image_data:
@@ -402,7 +518,9 @@ class OpenAIChatService:
# 如果没有文本内容如图片URL等整块输出
yield f"data: {json.dumps(openai_chunk)}\n\n"
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
logger.info(f"Stream image completion finished successfully for model: {model}")
logger.info(
f"Stream image completion finished successfully for model: {model}"
)
is_success = True
status_code = 200
yield "data: [DONE]\n\n"
@@ -410,46 +528,49 @@ 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 # Default error code
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-image-stream", # Specific error type
error_type="openai-image-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]} # Log truncated data
request_msg={"image_data_truncated": image_data[:1000]},
)
yield f"data: {json.dumps({'error': error_log_msg})}\n\n" # Send error to client
yield "data: [DONE]\n\n" # Still need DONE message
# Re-raising might break the stream, decide if needed
yield f"data: {json.dumps({'error': error_log_msg})}\n\n"
yield "data: [DONE]\n\n"
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
logger.info(f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}")
logger.info(
f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}"
)
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def _handle_normal_image_completion(
self, model: str, image_data: str, api_key: str # Add api_key parameter
self, model: str, image_data: str, api_key: str
) -> Dict[str, Any]:
logger.info(f"Starting normal image completion for model: {model}")
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Although not used for DB log here
request_datetime = datetime.datetime.now()
is_success = False
status_code = None # Although not used for DB log here
status_code = None
result = None
try:
result = self.response_handler.handle_image_chat_response(
image_data, model, stream=False, finish_reason="stop"
)
logger.info(f"Normal image completion finished successfully for model: {model}")
logger.info(
f"Normal image completion finished successfully for model: {model}"
)
is_success = True
status_code = 200
return result
@@ -457,26 +578,28 @@ class OpenAIChatService:
is_success = False
error_log_msg = f"Normal image completion failed for model {model}: {e}"
logger.error(error_log_msg)
status_code = 500 # Default error code
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-image-non-stream", # Specific error type
error_type="openai-image-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]} # Log truncated data
request_msg={"image_data_truncated": image_data[:1000]},
)
# Re-raise the exception so the caller knows about the failure
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
logger.info(f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}")
logger.info(
f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}"
)
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)

View File

@@ -1,11 +1,14 @@
# app/services/chat/api_client.py
from typing import Dict, Any, AsyncGenerator
from typing import Dict, Any, AsyncGenerator, Optional
import httpx
import random
from abc import ABC, abstractmethod
from app.config.config import settings
from app.log.logger import get_api_client_logger
from app.core.constants import DEFAULT_TIMEOUT
logger = get_api_client_logger()
class ApiClient(ABC):
"""API客户端基类"""
@@ -37,11 +40,41 @@ class GeminiApiClient(ApiClient):
model = model[:-20]
return model
async def get_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""获取可用的 Gemini 模型列表"""
timeout = httpx.Timeout(timeout=5)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models?key={api_key}"
try:
response = await client.get(url)
response.raise_for_status() # 如果状态码不是 2xx则引发 HTTPStatusError
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"获取模型列表失败: {e.response.status_code}")
logger.error(e.response.text)
# 返回 None 而不是抛出异常,以便上层处理
return None
except httpx.RequestError as e:
logger.error(f"请求模型列表失败: {e}")
# 返回 None 而不是抛出异常
return None
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
async with httpx.AsyncClient(timeout=timeout) as client:
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
response = await client.post(url, json=payload)
if response.status_code != 200:
@@ -53,7 +86,12 @@ class GeminiApiClient(ApiClient):
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
async with httpx.AsyncClient(timeout=timeout) as client:
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
async with client.stream(method="POST", url=url, json=payload) as response:
if response.status_code != 200:
@@ -62,3 +100,96 @@ class GeminiApiClient(ApiClient):
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
async for line in response.aiter_lines():
yield line
class OpenaiApiClient(ApiClient):
"""OpenAI API客户端"""
def __init__(self, base_url: str, timeout: int = DEFAULT_TIMEOUT):
self.base_url = base_url
self.timeout = timeout
async def get_models(self, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
async with httpx.AsyncClient(timeout=timeout) as client:
url = f"{self.base_url}/openai/models"
headers = {"Authorization": f"Bearer {api_key}"}
response = await client.get(url, headers=headers)
if response.status_code != 200:
error_content = response.text
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
return response.json()
async def generate_content(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/chat/completions"
headers = {"Authorization": f"Bearer {api_key}"}
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
return response.json()
async def stream_generate_content(self, payload: Dict[str, Any], api_key: str) -> AsyncGenerator[str, None]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/chat/completions"
headers = {"Authorization": f"Bearer {api_key}"}
async with client.stream(method="POST", url=url, json=payload, headers=headers) as response:
if response.status_code != 200:
error_content = await response.aread()
error_msg = error_content.decode("utf-8")
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
async for line in response.aiter_lines():
yield line
async def create_embeddings(self, input: str, model: str, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/embeddings"
headers = {"Authorization": f"Bearer {api_key}"}
payload = {
"input": input,
"model": model,
}
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
return response.json()
async def generate_images(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/images/generations"
headers = {"Authorization": f"Bearer {api_key}"}
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
return response.json()

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): # 新增对 dict 类型的处理
db_value = json.dumps(value)
elif isinstance(value, bool):
db_value = str(value).lower()
@@ -55,24 +63,26 @@ 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,17 +92,19 @@ 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)
@@ -112,7 +124,79 @@ class ConfigService:
# 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 # 更新内存中的 settings
await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
logger.info(
f"成功删除 {deleted_count} 个密钥。密钥: {keys_actually_removed}"
)
message = f"成功删除 {deleted_count} 个密钥。"
if not_found_keys:
message += f" {len(not_found_keys)} 个密钥未找到: {not_found_keys}"
return {
"success": True,
"message": message,
"deleted_count": deleted_count,
"not_found_keys": not_found_keys,
}
else:
message = "没有密钥被删除。"
if not_found_keys: # 如果提供了密钥但都未找到
message = f"所有 {len(not_found_keys)} 个指定的密钥均未找到: {not_found_keys}"
elif not keys_to_delete: # 如果 keys_to_delete 列表为空
message = "未指定要删除的密钥。"
logger.warning(message)
return {
"success": False,
"message": message,
"deleted_count": 0,
"not_found_keys": not_found_keys,
}
@staticmethod
async def reset_config() -> Dict[str, Any]:
"""
@@ -124,7 +208,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 +226,36 @@ 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:
# Re-raise HTTPExceptions directly if they are already specific
raise e
except Exception as e:
logger.error(
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
)
# Raise a generic HTTPException for other errors
raise HTTPException(
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
)
# 重新加载配置的函数
def _reload_settings():
"""重新加载环境变量并更新配置"""
@@ -147,4 +263,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

@@ -39,7 +39,7 @@ 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

View File

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

View File

@@ -2,7 +2,6 @@ import asyncio
from itertools import cycle
from typing import Dict
from app.config.config import settings
from app.log.logger import get_key_manager_logger
@@ -37,7 +36,7 @@ class KeyManager:
async with self.failure_count_lock:
for key in self.key_failure_counts:
self.key_failure_counts[key] = 0
async def reset_key_failure_count(self, key: str) -> bool:
"""重置指定key的失败计数"""
async with self.failure_count_lock:
@@ -45,7 +44,9 @@ 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 get_next_working_key(self) -> str:
@@ -62,7 +63,7 @@ class KeyManager:
# await self.reset_failure_counts() 取消重置
return current_key
async def handle_api_failure(self, api_key: str,retries: int) -> str:
async def handle_api_failure(self, api_key: str, retries: int) -> str:
"""处理API调用失败"""
async with self.failure_count_lock:
self.key_failure_counts[api_key] += 1
@@ -72,7 +73,7 @@ class KeyManager:
)
if retries < settings.MAX_RETRIES:
return await self.get_next_working_key()
else:
else:
return ""
def get_fail_count(self, key: str) -> int:
@@ -100,10 +101,32 @@ class KeyManager:
for key in self.key_failure_counts:
if self.key_failure_counts[key] < self.MAX_FAILURES:
return key
return self.api_keys[0]
# 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空)
# 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整
if self.api_keys:
return self.api_keys[0]
# 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。
# 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。
# 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。
# 根据现有代码如果api_keys为空self.api_keys[0]会报错。
# 如果没有有效key且列表不空返回第一个。若列表为空这里会出IndexError。
# 更安全的做法是:
if not self.api_keys:
logger.warning("API key list is empty, cannot get first valid key.")
# Depending on desired behavior, either raise error or return an indicator like "" or None
# For now, let's allow it to potentially fail if a key is expected by caller
# but it's better to be explicit. Let's return empty string for consistency with handle_api_failure
return ""
return self.api_keys[
0
] # Fallback to the first key if no key is "valid" but list is not empty
_singleton_instance = None
_singleton_lock = asyncio.Lock()
_preserved_failure_counts: Dict[str, int] | None = None
_preserved_old_api_keys_for_reset: list | None = None
_preserved_next_key_in_cycle: str | None = None
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
@@ -112,22 +135,174 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
如果在重置后调用,会尝试恢复之前的状态(失败计数、循环位置)。
"""
global _singleton_instance
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance is None:
if api_keys is None:
raise ValueError("API keys are required to initialize the KeyManager")
# This case needs careful handling. If it's the very first call, api_keys are required.
# If it's after a reset and no api_keys are provided, what should happen?
# The original ValueError was "API keys are required to initialize the KeyManager".
# Let's assume if api_keys is None here, it's an error unless we are restoring from non-None _preserved_old_api_keys_for_reset.
# However, the user's request implies new api_keys will be part of the reset flow.
# For now, stick to a strict requirement for api_keys if _singleton_instance is None.
raise ValueError(
"API keys are required to initialize or re-initialize the KeyManager instance."
)
if not api_keys: # Handle case where api_keys is an empty list
logger.warning(
"Initializing KeyManager with an empty list of API keys."
)
# Consider if this should be an error or allowed. Current KeyManager supports it.
_singleton_instance = KeyManager(api_keys)
logger.info("KeyManager instance created.")
logger.info(
f"KeyManager instance created/re-created with {len(api_keys)} API keys."
)
# 1. 恢复失败计数
if _preserved_failure_counts:
# Initialize new instance's failure_counts for all new keys to 0
current_failure_counts = {
key: 0 for key in _singleton_instance.api_keys
}
# Inherit counts for keys that exist in both old and new lists
for key, count in _preserved_failure_counts.items():
if key in current_failure_counts:
current_failure_counts[key] = count
_singleton_instance.key_failure_counts = current_failure_counts
logger.info("Inherited failure counts for applicable keys.")
_preserved_failure_counts = None # Clear after use
# 2. 调整 key_cycle 的起始点
start_key_for_new_cycle = None
if (
_preserved_old_api_keys_for_reset
and _preserved_next_key_in_cycle
and _singleton_instance.api_keys # Ensure new api_keys list is not empty
):
try:
# Find the index of the preserved next key in the *old* list
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
_preserved_next_key_in_cycle
)
# Iterate through the old key list (circularly) starting from _preserved_next_key_in_cycle
# Find the first key that also exists in the new api_keys list
for i in range(len(_preserved_old_api_keys_for_reset)):
current_old_key_idx = (start_idx_in_old + i) % len(
_preserved_old_api_keys_for_reset
)
key_candidate = _preserved_old_api_keys_for_reset[
current_old_key_idx
]
if key_candidate in _singleton_instance.api_keys:
start_key_for_new_cycle = key_candidate
break
except ValueError:
logger.warning(
f"Preserved next key '{_preserved_next_key_in_cycle}' not found in preserved old API keys. "
"New cycle will start from the beginning of the new list."
)
except Exception as e:
logger.error(
f"Error determining start key for new cycle from preserved state: {e}. "
"New cycle will start from the beginning."
)
if start_key_for_new_cycle and _singleton_instance.api_keys:
try:
# Find the index of the determined start_key in the new api_keys list
target_idx = _singleton_instance.api_keys.index(
start_key_for_new_cycle
)
# Advance the new cycle by calling next() target_idx times
# This positions the cycle so that the *next* call to next() will yield start_key_for_new_cycle
for _ in range(target_idx):
next(_singleton_instance.key_cycle)
logger.info(
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
)
except ValueError:
# This should not happen if start_key_for_new_cycle was correctly found in api_keys
logger.warning(
f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. "
"New cycle will start from the beginning."
)
except (
StopIteration
): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check
logger.error(
"StopIteration while advancing key cycle, implies empty new API key list previously missed."
)
except Exception as e:
logger.error(
f"Error advancing new key cycle: {e}. Cycle will start from beginning."
)
else:
if _singleton_instance.api_keys:
logger.info(
"New key cycle will start from the beginning of the new API key list (no specific start key determined or needed)."
)
else:
logger.info(
"New key cycle not applicable as the new API key list is empty."
)
# 清理所有保存的状态
_preserved_old_api_keys_for_reset = None
_preserved_next_key_in_cycle = None
# _preserved_failure_counts already cleared
return _singleton_instance
async def reset_key_manager_instance():
"""重置 KeyManager 单例实例"""
global _singleton_instance
"""
重置 KeyManager 单例实例。
将保存当前实例的状态(失败计数、旧 API keys、下一个 key 提示)
以供下一次 get_key_manager_instance 调用时恢复。
"""
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance:
# 1. 保存失败计数
_preserved_failure_counts = _singleton_instance.key_failure_counts.copy()
# 2. 保存旧的 API keys 列表
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
# 3. 保存 key_cycle 的下一个 key 提示
# This should be the key that get_next_key() would return next.
try:
if (
_singleton_instance.api_keys
): # Only if there are keys to cycle through
# Calling get_next_key() consumes one key and returns it. This is the key
# we want the new cycle to effectively start with.
_preserved_next_key_in_cycle = (
await _singleton_instance.get_next_key()
)
else:
_preserved_next_key_in_cycle = None # No keys, so no next key
except (
StopIteration
): # Should be caught by "if _singleton_instance.api_keys"
logger.warning(
"Could not preserve next key hint: key cycle was empty or exhausted in old instance."
)
_preserved_next_key_in_cycle = None
except Exception as e:
logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_next_key_in_cycle = None
_singleton_instance = None
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

@@ -1,50 +1,47 @@
from datetime import datetime, timezone
from typing import Any, Dict, Optional
import requests
from app.config.config import settings
from app.log.logger import get_model_logger
from app.service.client.api_client import GeminiApiClient
logger = get_model_logger()
class ModelService:
def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
url = f"{settings.BASE_URL}/models?key={api_key}"
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""使用 GeminiApiClient 获取并过滤模型列表"""
api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端
gemini_models = await api_client.get_models(api_key)
try:
response = requests.get(url)
if response.status_code == 200:
gemini_models = response.json()
filtered_models_list = []
for model in gemini_models.get("models", []):
model_id = model["name"].split("/")[-1]
if model_id not in settings.FILTERED_MODELS:
filtered_models_list.append(model)
else:
logger.debug(f"Filtered out model: {model_id}")
gemini_models["models"] = filtered_models_list
return gemini_models
else:
logger.error(f"Error: {response.status_code}")
logger.error(response.text)
return None
except requests.RequestException as e:
logger.error(f"Request failed: {e}")
if gemini_models is None:
logger.error("从 API 客户端获取模型列表失败。")
return None
def get_gemini_openai_models(self, api_key: str) -> Optional[Dict[str, Any]]:
try:
gemini_models = self.get_gemini_models(api_key)
return self.convert_to_openai_models_format(gemini_models)
except requests.RequestException as e:
logger.error(f"Request failed: {e}")
filtered_models_list = []
for model in gemini_models.get("models", []):
model_id = model["name"].split("/")[-1]
if model_id not in settings.FILTERED_MODELS:
filtered_models_list.append(model)
else:
logger.debug(f"Filtered out model: {model_id}")
gemini_models["models"] = filtered_models_list
return gemini_models
except Exception as e:
logger.error(f"处理模型列表时出错: {e}")
return None
def convert_to_openai_models_format(
async def get_gemini_openai_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""获取 Gemini 模型并转换为 OpenAI 格式"""
gemini_models = await self.get_gemini_models(api_key)
if gemini_models is None:
return None
return await self.convert_to_openai_models_format(gemini_models)
async def convert_to_openai_models_format(
self, gemini_models: Dict[str, Any]
) -> Dict[str, Any]:
openai_format = {"object": "list", "data": [], "success": True}
@@ -81,7 +78,7 @@ class ModelService:
openai_format["data"].append(image_model)
return openai_format
def check_model_support(self, model: str) -> bool:
async def check_model_support(self, model: str) -> bool:
if not model or not isinstance(model, str):
return False

View File

@@ -0,0 +1,197 @@
import datetime
import json
import re
import time
from typing import Any, AsyncGenerator, Dict, Union
from app.config.config import settings
from app.database.services import (
add_error_log,
add_request_log,
)
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
from app.service.client.api_client import OpenaiApiClient
from app.service.key.key_manager import KeyManager
from app.log.logger import get_openai_compatible_logger
logger = get_openai_compatible_logger()
class OpenAICompatiableService:
def __init__(self, base_url: str, key_manager: KeyManager = None):
self.key_manager = key_manager
self.base_url = base_url
self.api_client = OpenaiApiClient(base_url, settings.TIME_OUT)
async def get_models(self, api_key: str) -> Dict[str, Any]:
return await self.api_client.get_models(api_key)
async def create_chat_completion(
self,
request: ChatRequest,
api_key: str,
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
request_dict = request.model_dump()
# 移除值为null的
request_dict = {k: v for k, v in request_dict.items() if v is not None}
del request_dict["top_k"] # 删除top_k参数目前不支持该参数
if request.stream:
return self._handle_stream_completion(request.model, request_dict, api_key)
return await self._handle_normal_completion(request.model, request_dict, api_key)
async def generate_images(
self,
request: ImageGenerationRequest,
) -> Dict[str, Any]:
"""生成图片"""
request_dict = request.model_dump()
# 移除值为null的
request_dict = {k: v for k, v in request_dict.items() if v is not None}
api_key = settings.PAID_KEY
return await self.api_client.generate_images(request_dict, api_key)
async def create_embeddings(
self,
input_text: str,
model: str,
api_key: str,
) -> Dict[str, Any]:
"""创建嵌入"""
return await self.api_client.create_embeddings(input_text, model, api_key)
async def _handle_normal_completion(
self, model: str, request: dict, api_key: str
) -> Dict[str, Any]:
"""处理普通聊天完成"""
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
try:
response = await self.api_client.generate_content(request, api_key)
is_success = True
status_code = 200
return response
except Exception as e:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-compatiable-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=request,
)
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime,
)
async def _handle_stream_completion(
self, model: str, payload: dict, api_key: str
) -> AsyncGenerator[str, None]:
"""处理流式聊天完成,添加重试逻辑"""
retries = 0
max_retries = settings.MAX_RETRIES
is_success = False
status_code = None
final_api_key = api_key
while retries < max_retries:
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
current_attempt_key = api_key
final_api_key = current_attempt_key
try:
async for line in self.api_client.stream_generate_content(
payload, current_attempt_key
):
if line.startswith("data:"):
# print(line)
yield line + "\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200
break # 成功后退出循环
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
error_type="openai-compatiable-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else:
logger.error(
f"No valid API key available after {retries} retries."
)
break
else:
logger.error("KeyManager not available for retry logic.")
break
if retries >= max_retries:
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime,
)
# If the loop finished due to failure, yield error and DONE
if not is_success and retries >= max_retries:
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,19 +9,67 @@ function scrollToBottom() {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
// API 调用辅助函数
async function fetchAPI(url, options = {}) {
try {
const response = await fetch(url, options);
// Handle cases where response might be empty but still ok (e.g., 204 No Content for DELETE)
if (response.status === 204) {
return null; // Indicate success with no content
}
let responseData;
try {
responseData = await response.json();
} catch (e) {
// Handle non-JSON responses if necessary, or assume error if JSON expected
if (!response.ok) {
// If response is not ok and not JSON, use statusText
throw new Error(`HTTP error! status: ${response.status} - ${response.statusText}`);
}
// If response is ok but not JSON, maybe return raw text or handle differently
// For now, let's assume successful non-JSON is not expected or handled later
console.warn("Response was not JSON for URL:", url);
return await response.text(); // Or handle as needed
}
if (!response.ok) {
// Prefer error message from API response body if available
const message = responseData?.detail || `HTTP error! status: ${response.status} - ${response.statusText}`;
throw new Error(message);
}
return responseData; // Return parsed JSON data for successful responses
} catch (error) {
// Catch network errors or errors thrown from above
console.error('API Call Failed:', error.message, 'URL:', url, 'Options:', options);
// Re-throw the error so the calling function knows the operation failed
throw error;
}
}
// Refresh function removed as the buttons are gone.
// If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs().
// 全局变量
let currentPage = 1;
let pageSize = 10;
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
let errorLogs = []; // Store fetched logs for details view
let currentSearch = { // Store current search parameters
key: '',
error: '',
startDate: '',
endDate: ''
// 全局状态管理
let errorLogState = {
currentPage: 1,
pageSize: 10,
logs: [], // 存储获取的日志
sort: {
field: 'id', // 默认按 ID 排序
order: 'desc' // 默认降序
},
search: {
key: '',
error: '',
errorCode: '',
startDate: '',
endDate: ''
}
};
// DOM Elements Cache
@@ -36,64 +84,89 @@ let logDetailModal;
let modalCloseBtns; // Collection of close buttons for the modal
let keySearchInput;
let errorSearchInput;
let errorCodeSearchInput; // Added error code input
let startDateInput;
let endDateInput;
let searchBtn;
let pageInput; // 新增:页码输入框
let goToPageBtn; // 新增:跳转按钮
let pageInput;
let goToPageBtn;
let selectAllCheckbox; // 新增:全选复选框
let copySelectedKeysBtn; // 新增:复制选中按钮
let deleteSelectedBtn; // 新增:批量删除按钮
let sortByIdHeader; // 新增ID 排序表头
let sortIcon; // 新增:排序图标
let selectedCountSpan; // 新增:选中计数显示
let deleteConfirmModal; // 新增:删除确认模态框
let closeDeleteConfirmModalBtn; // 新增:关闭删除模态框按钮
let cancelDeleteBtn; // 新增:取消删除按钮
let confirmDeleteBtn; // 新增:确认删除按钮
let deleteConfirmMessage; // 新增:删除确认消息元素
let idsToDeleteGlobally = []; // 新增存储待删除的ID
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
// Cache DOM elements
// Helper functions for initialization
function cacheDOMElements() {
pageSizeSelector = document.getElementById('pageSize');
// refreshBtn = document.getElementById('refreshBtn'); // Removed
tableBody = document.getElementById('errorLogsTable');
paginationElement = document.getElementById('pagination');
loadingIndicator = document.getElementById('loadingIndicator');
noDataMessage = document.getElementById('noDataMessage');
errorMessage = document.getElementById('errorMessage');
logDetailModal = document.getElementById('logDetailModal');
// Get all elements that should close the modal
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
keySearchInput = document.getElementById('keySearch');
errorSearchInput = document.getElementById('errorSearch');
errorCodeSearchInput = document.getElementById('errorCodeSearch');
startDateInput = document.getElementById('startDate');
endDateInput = document.getElementById('endDate');
searchBtn = document.getElementById('searchBtn');
pageInput = document.getElementById('pageInput'); // 新增
goToPageBtn = document.getElementById('goToPageBtn'); // 新增
pageInput = document.getElementById('pageInput');
goToPageBtn = document.getElementById('goToPageBtn');
selectAllCheckbox = document.getElementById('selectAllCheckbox');
copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn');
deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
sortByIdHeader = document.getElementById('sortById');
if (sortByIdHeader) {
sortIcon = sortByIdHeader.querySelector('i');
}
selectedCountSpan = document.getElementById('selectedCount');
deleteConfirmModal = document.getElementById('deleteConfirmModal');
closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn');
cancelDeleteBtn = document.getElementById('cancelDeleteBtn');
confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
deleteConfirmMessage = document.getElementById('deleteConfirmMessage');
}
// Initialize page size selector
function initializePageSizeControls() {
if (pageSizeSelector) {
pageSizeSelector.value = pageSize;
pageSizeSelector.value = errorLogState.pageSize;
pageSizeSelector.addEventListener('change', function() {
pageSize = parseInt(this.value);
currentPage = 1; // Reset to first page
errorLogState.pageSize = parseInt(this.value);
errorLogState.currentPage = 1; // Reset to first page
loadErrorLogs();
});
}
}
// Refresh button event listener removed
// Initialize search button
function initializeSearchControls() {
if (searchBtn) {
searchBtn.addEventListener('click', function() {
// Update search parameters from input fields
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
currentSearch.startDate = startDateInput ? startDateInput.value : '';
currentSearch.endDate = endDateInput ? endDateInput.value : '';
currentPage = 1; // Reset to first page on new search
errorLogState.search.key = keySearchInput ? keySearchInput.value.trim() : '';
errorLogState.search.error = errorSearchInput ? errorSearchInput.value.trim() : '';
errorLogState.search.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : '';
errorLogState.search.startDate = startDateInput ? startDateInput.value : '';
errorLogState.search.endDate = endDateInput ? endDateInput.value : '';
errorLogState.currentPage = 1; // Reset to first page on new search
loadErrorLogs();
});
}
}
// Initialize modal close buttons
function initializeModalControls() {
// Log Detail Modal
if (logDetailModal && modalCloseBtns) {
modalCloseBtns.forEach(btn => {
btn.addEventListener('click', closeLogDetailModal);
});
// Optional: Close modal if clicking outside the content
logDetailModal.addEventListener('click', function(event) {
if (event.target === logDetailModal) {
closeLogDetailModal();
@@ -101,39 +174,100 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Initial load of error logs
loadErrorLogs();
// Add event listeners for copy buttons inside the modal
setupCopyButtons();
// 新增:为页码跳转按钮添加事件监听器
if (goToPageBtn && pageInput) {
goToPageBtn.addEventListener('click', function() {
const targetPage = parseInt(pageInput.value);
// 需要获取总页数来验证输入
// 暂时无法直接获取 totalPages需要在 updatePagination 中存储或重新计算
// 简单的验证:必须是正整数
if (!isNaN(targetPage) && targetPage >= 1) {
// 理想情况下,应检查 targetPage <= totalPages
// 但 totalPages 可能未知,所以暂时只跳转
currentPage = targetPage;
loadErrorLogs();
pageInput.value = ''; // 清空输入框
} else {
showNotification('请输入有效的页码', 'error', 2000);
pageInput.value = ''; // 清空无效输入
}
});
// 允许按 Enter 键跳转
pageInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
goToPageBtn.click(); // 触发按钮点击
// Delete Confirm Modal
if (closeDeleteConfirmModalBtn) {
closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal);
}
if (cancelDeleteBtn) {
cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal);
}
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', handleConfirmDelete);
}
if (deleteConfirmModal) {
deleteConfirmModal.addEventListener('click', function(event) {
if (event.target === deleteConfirmModal) {
hideDeleteConfirmModal();
}
});
}
}
function initializePaginationJumpControls() {
if (goToPageBtn && pageInput) {
goToPageBtn.addEventListener('click', function() {
const targetPage = parseInt(pageInput.value);
if (!isNaN(targetPage) && targetPage >= 1) {
errorLogState.currentPage = targetPage;
loadErrorLogs();
pageInput.value = '';
} else {
showNotification('请输入有效的页码', 'error', 2000);
pageInput.value = '';
}
});
pageInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
goToPageBtn.click();
}
});
}
}
function initializeActionControls() {
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
}
if (sortByIdHeader) {
sortByIdHeader.addEventListener('click', handleSortById);
}
// Bulk selection listeners are closely related to actions
setupBulkSelectionListeners();
}
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
cacheDOMElements();
initializePageSizeControls();
initializeSearchControls();
initializeModalControls();
initializePaginationJumpControls();
initializeActionControls();
// Initial load of error logs
loadErrorLogs();
// Add event listeners for copy buttons inside the modal and table
// This needs to be called after initial render and potentially after each render if content is dynamic
setupCopyButtons();
});
// 新增:显示删除确认模态框
function showDeleteConfirmModal(message) {
if (deleteConfirmModal && deleteConfirmMessage) {
deleteConfirmMessage.textContent = message;
deleteConfirmModal.classList.add('show');
document.body.style.overflow = 'hidden'; // Prevent body scrolling
}
}
// 新增:隐藏删除确认模态框
function hideDeleteConfirmModal() {
if (deleteConfirmModal) {
deleteConfirmModal.classList.remove('show');
document.body.style.overflow = ''; // Restore body scrolling
idsToDeleteGlobally = []; // 清空待删除ID
}
}
// 新增:处理确认删除按钮点击
function handleConfirmDelete() {
if (idsToDeleteGlobally.length > 0) {
performActualDelete(idsToDeleteGlobally);
}
hideDeleteConfirmModal(); // 关闭模态框
}
// Fallback copy function using document.execCommand
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement("textarea");
@@ -174,91 +308,334 @@ function handleCopyResult(buttonElement, success) {
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
}
// Function to set up copy button listeners (using modern API with fallback)
function setupCopyButtons() {
const copyButtons = document.querySelectorAll('.copy-btn');
copyButtons.forEach(button => {
button.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const targetElement = document.getElementById(targetId);
if (targetElement) {
const textToCopy = targetElement.textContent;
let copySuccess = false;
// Try modern clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(textToCopy).then(() => {
handleCopyResult(this, true); // Use helper for feedback
}).catch(err => {
console.error('Clipboard API failed, attempting fallback:', err);
// Attempt fallback if modern API fails
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(this, copySuccess); // Use helper for feedback
});
} else {
// Use fallback if modern API is not available or context is insecure
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(this, copySuccess); // Use helper for feedback
}
// 新的内部辅助函数,封装实际的复制操作和反馈
function _performCopy(text, buttonElement) {
let copySuccess = false;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
if (buttonElement) {
handleCopyResult(buttonElement, true);
} else {
console.error('Target element not found:', targetId);
showNotification('复制出错:找不到目标元素', 'error');
showNotification('已复制到剪贴板', 'success');
}
}).catch(err => {
console.error('Clipboard API failed, attempting fallback:', err);
copySuccess = fallbackCopyTextToClipboard(text);
if (buttonElement) {
handleCopyResult(buttonElement, copySuccess);
} else {
showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
}
});
} else {
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
copySuccess = fallbackCopyTextToClipboard(text);
if (buttonElement) {
handleCopyResult(buttonElement, copySuccess);
} else {
showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
}
}
}
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
function setupCopyButtons(containerSelector = 'body') {
// Find buttons within the specified container (defaults to body)
const container = document.querySelector(containerSelector);
if (!container) return;
const copyButtons = container.querySelectorAll('.copy-btn');
copyButtons.forEach(button => {
// Remove existing listener to prevent duplicates if called multiple times
button.removeEventListener('click', handleCopyButtonClick);
// Add the listener
button.addEventListener('click', handleCopyButtonClick);
});
}
// Extracted click handler logic for reusability and removing listeners
function handleCopyButtonClick() {
const button = this; // 'this' refers to the button clicked
const targetId = button.getAttribute('data-target');
const textToCopyDirect = button.getAttribute('data-copy-text'); // For direct text copy (e.g., table key)
let textToCopy = '';
if (textToCopyDirect) {
textToCopy = textToCopyDirect;
} else if (targetId) {
const targetElement = document.getElementById(targetId);
if (targetElement) {
textToCopy = targetElement.textContent;
} else {
console.error('Target element not found:', targetId);
showNotification('复制出错:找不到目标元素', 'error');
return; // Exit if target element not found
}
} else {
console.error('No data-target or data-copy-text attribute found on button:', button);
showNotification('复制出错:未指定复制内容', 'error');
return; // Exit if no source specified
}
if (textToCopy) {
_performCopy(textToCopy, button); // 使用新的辅助函数
} else {
console.warn('No text found to copy for target:', targetId || 'direct text');
showNotification('没有内容可复制', 'warning');
}
} // End of handleCopyButtonClick function
// 新增:设置批量选择相关的事件监听器
function setupBulkSelectionListeners() {
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', handleSelectAllChange);
}
if (tableBody) {
// 使用事件委托处理行复选框的点击
tableBody.addEventListener('change', handleRowCheckboxChange);
}
if (copySelectedKeysBtn) {
copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys);
}
// 新增:为批量删除按钮添加事件监听器 (如果尚未添加)
// 通常在 DOMContentLoaded 中添加一次即可
// if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) {
// deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
// deleteSelectedBtn.hasListener = true; // 标记已添加
// }
}
// 新增:处理“全选”复选框变化的函数
function handleSelectAllChange() {
const isChecked = selectAllCheckbox.checked;
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
rowCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
updateSelectedState();
}
// 新增:处理行复选框变化的函数 (事件委托)
function handleRowCheckboxChange(event) {
if (event.target.classList.contains('row-checkbox')) {
updateSelectedState();
}
}
// 新增:更新选中状态(计数、按钮状态、全选框状态)
function updateSelectedState() {
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
// 移除了数字显示不再更新selectedCountSpan
// 仍然更新复制按钮的禁用状态
if (copySelectedKeysBtn) {
copySelectedKeysBtn.disabled = selectedCount === 0;
// 可选:根据选中项数量更新按钮标题属性
copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`);
}
// 新增:更新批量删除按钮的禁用状态
if (deleteSelectedBtn) {
deleteSelectedBtn.disabled = selectedCount === 0;
deleteSelectedBtn.setAttribute('title', `删除${selectedCount}项选中日志`);
}
// 更新“全选”复选框的状态
if (selectAllCheckbox) {
if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else if (selectedCount > 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true; // 部分选中状态
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
}
}
// 新增:处理“复制选中密钥”按钮点击的函数
function handleCopySelectedKeys() {
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const keysToCopy = [];
selectedCheckboxes.forEach(checkbox => {
const key = checkbox.getAttribute('data-key');
if (key) {
keysToCopy.push(key);
}
});
if (keysToCopy.length > 0) {
const textToCopy = keysToCopy.join('\n'); // 每行一个密钥
_performCopy(textToCopy, copySelectedKeysBtn); // 使用新的辅助函数
} else {
showNotification('没有选中的密钥可复制', 'warning');
}
}
// 修改:处理批量删除按钮点击的函数 - 改为显示模态框
function handleDeleteSelected() {
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const logIdsToDelete = [];
selectedCheckboxes.forEach(checkbox => {
const logId = checkbox.getAttribute('data-log-id'); // 需要在渲染时添加 data-log-id
if (logId) {
logIdsToDelete.push(parseInt(logId));
}
});
if (logIdsToDelete.length === 0) {
showNotification('没有选中的日志可删除', 'warning');
return;
}
if (logIdsToDelete.length === 0) {
showNotification('没有选中的日志可删除', 'warning');
return;
}
// 存储待删除ID并显示模态框
idsToDeleteGlobally = logIdsToDelete;
const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`;
showDeleteConfirmModal(message);
}
// 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow
async function performActualDelete(logIds) {
if (!logIds || logIds.length === 0) return;
const isSingleDelete = logIds.length === 1;
const url = isSingleDelete ? `/api/logs/errors/${logIds[0]}` : '/api/logs/errors';
const method = 'DELETE';
const body = isSingleDelete ? null : JSON.stringify({ ids: logIds });
const headers = isSingleDelete ? {} : { 'Content-Type': 'application/json' };
const options = {
method: method,
headers: headers,
body: body, // fetchAPI handles null body correctly
};
try {
// Use fetchAPI for the delete request
await fetchAPI(url, options); // fetchAPI returns null for 204 No Content
// If fetchAPI doesn't throw, the request was successful
const successMessage = isSingleDelete ? `成功删除该日志` : `成功删除 ${logIds.length} 条日志`;
showNotification(successMessage, 'success');
// 取消全选
if (selectAllCheckbox) selectAllCheckbox.checked = false;
// 重新加载当前页数据
loadErrorLogs();
} catch (error) {
console.error('批量删除错误日志失败:', error);
showNotification(`批量删除失败: ${error.message}`, 'error', 5000);
}
}
// 修改:处理单行删除按钮点击的函数 - 改为显示模态框
function handleDeleteLogRow(logId) {
if (!logId) return;
// 存储待删除ID并显示模态框
idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组
// 使用通用确认消息不显示具体ID
const message = `确定要删除这条日志吗?此操作不可恢复!`;
showDeleteConfirmModal(message);
}
// 新增:处理 ID 排序点击的函数
function handleSortById() {
if (errorLogState.sort.field === 'id') {
// 如果当前是按 ID 排序,切换顺序
errorLogState.sort.order = errorLogState.sort.order === 'asc' ? 'desc' : 'asc';
} else {
// 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序
errorLogState.sort.field = 'id';
errorLogState.sort.order = 'desc';
}
// 更新图标
updateSortIcon();
// 重新加载第一页数据
errorLogState.currentPage = 1;
loadErrorLogs();
}
// 新增:更新排序图标的函数
function updateSortIcon() {
if (!sortIcon) return;
// 移除所有可能的排序类
sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600');
if (errorLogState.sort.field === 'id') {
sortIcon.classList.add(errorLogState.sort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
sortIcon.classList.add('text-primary-600'); // 高亮显示
} else {
// 如果不是按 ID 排序,显示默认图标
sortIcon.classList.add('fa-sort', 'text-gray-400');
}
}
// 加载错误日志数据
async function loadErrorLogs() {
// 重置选择状态
if (selectAllCheckbox) selectAllCheckbox.checked = false;
if (selectAllCheckbox) selectAllCheckbox.indeterminate = false;
updateSelectedState(); // 更新按钮状态和计数
showLoading(true);
showError(false);
showNoData(false);
const offset = (currentPage - 1) * pageSize;
const offset = (errorLogState.currentPage - 1) * errorLogState.pageSize;
try {
// Construct the API URL with search parameters
let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`;
if (currentSearch.key) {
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
// Construct the API URL with search and sort parameters
let apiUrl = `/api/logs/errors?limit=${errorLogState.pageSize}&offset=${offset}`;
// 添加排序参数
apiUrl += `&sort_by=${errorLogState.sort.field}&sort_order=${errorLogState.sort.order}`;
// 添加搜索参数
if (errorLogState.search.key) {
apiUrl += `&key_search=${encodeURIComponent(errorLogState.search.key)}`;
}
if (currentSearch.error) {
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
if (errorLogState.search.error) {
apiUrl += `&error_search=${encodeURIComponent(errorLogState.search.error)}`;
}
if (currentSearch.startDate) {
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
if (errorLogState.search.errorCode) { // Add error code to API request
apiUrl += `&error_code_search=${encodeURIComponent(errorLogState.search.errorCode)}`;
}
if (currentSearch.endDate) {
apiUrl += `&end_date=${encodeURIComponent(currentSearch.endDate)}`;
if (errorLogState.search.startDate) {
apiUrl += `&start_date=${encodeURIComponent(errorLogState.search.startDate)}`;
}
if (errorLogState.search.endDate) {
apiUrl += `&end_date=${encodeURIComponent(errorLogState.search.endDate)}`;
}
const response = await fetch(apiUrl);
if (!response.ok) {
// Try to get error message from response body
let errorData;
try {
errorData = await response.json();
} catch (e) {
// Ignore if response is not JSON
}
throw new Error(errorData?.detail || `网络响应异常: ${response.statusText}`);
}
const data = await response.json();
// Use fetchAPI to get logs
const data = await fetchAPI(apiUrl);
// API 现在返回 { logs: [], total: count }
// fetchAPI already parsed JSON
if (data && Array.isArray(data.logs)) {
errorLogs = data.logs; // Store the list data (contains error_code)
renderErrorLogs(errorLogs);
updatePagination(errorLogs.length, data.total || -1);
errorLogState.logs = data.logs; // Store the list data (contains error_code)
renderErrorLogs(errorLogState.logs);
updatePagination(errorLogState.logs.length, data.total || -1); // Use total from response
} else {
throw new Error('无法识别的API响应格式');
// Handle unexpected data format even after successful fetch
console.error('Unexpected API response format:', data);
throw new Error('无法识别的API响应格式');
}
showLoading(false);
if (errorLogs.length === 0) {
if (errorLogState.logs.length === 0) {
showNoData(true);
}
} catch (error) {
@@ -268,59 +645,77 @@ async function loadErrorLogs() {
}
}
// Helper function to create HTML for a single log row
function _createLogRowHtml(log, sequentialId) {
// Format date
let formattedTime = 'N/A';
try {
const requestTime = new Date(log.request_time);
if (!isNaN(requestTime)) {
formattedTime = requestTime.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
}
} catch (e) { console.error("Error formatting date:", e); }
const errorCodeContent = log.error_code || '无';
const maskKey = (key) => {
if (!key || key.length < 8) return key || '无';
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
const maskedKey = maskKey(log.gemini_key);
const fullKey = log.gemini_key || '';
return `
<td class="text-center px-3 py-3">
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}" data-log-id="${log.id}">
</td>
<td>${sequentialId}</td>
<td class="relative group" title="${fullKey}">
${maskedKey}
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${fullKey}" title="复制完整密钥">
<i class="far fa-copy"></i>
</button>
</td>
<td>${log.error_type || '未知'}</td>
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
<td>${log.model_name || '未知'}</td>
<td>${formattedTime}</td>
<td>
<button class="btn-view-details mr-2" data-log-id="${log.id}">
<i class="fas fa-eye mr-1"></i>详情
</button>
<button class="btn-delete-row text-danger-600 hover:text-danger-800" data-log-id="${log.id}" title="删除此日志">
<i class="fas fa-trash-alt"></i>
</button>
</td>
`;
}
// 渲染错误日志表格
function renderErrorLogs(logs) {
if (!tableBody) return;
tableBody.innerHTML = ''; // Clear previous entries
// 重置全选复选框状态(在清空表格后)
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
if (!logs || logs.length === 0) {
// Handled by showNoData
return;
}
const startIndex = (currentPage - 1) * pageSize; // Calculate starting index for the current page
const startIndex = (errorLogState.currentPage - 1) * errorLogState.pageSize;
logs.forEach((log, index) => { // Add index parameter to forEach
logs.forEach((log, index) => {
const sequentialId = startIndex + index + 1;
const row = document.createElement('tr');
const sequentialId = startIndex + index + 1; // Calculate sequential ID for the current page
// Format date
let formattedTime = 'N/A';
try {
const requestTime = new Date(log.request_time);
if (!isNaN(requestTime)) {
formattedTime = requestTime.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
}
} catch (e) { console.error("Error formatting date:", e); }
// Display error code instead of truncated log
const errorCodeContent = log.error_code || '无';
// Mask the Gemini key for display in the table
const maskKey = (key) => {
if (!key || key.length < 8) return key || '无'; // Don't mask short keys or null
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
const maskedKey = maskKey(log.gemini_key);
row.innerHTML = `
<td>${sequentialId}</td> <!-- Use sequential ID -->
<td title="${log.gemini_key || ''}">${maskedKey}</td>
<td>${log.error_type || '未知'}</td>
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
<td>${log.model_name || '未知'}</td>
<td>${formattedTime}</td>
<td>
<button class="btn-view-details" data-log-id="${log.id}">
查看详情
</button>
</td>
`;
row.innerHTML = _createLogRowHtml(log, sequentialId);
tableBody.appendChild(row);
});
@@ -331,6 +726,19 @@ function renderErrorLogs(logs) {
showLogDetails(logId);
});
});
// 新增:为新渲染的删除按钮添加事件监听器
document.querySelectorAll('.btn-delete-row').forEach(button => {
button.addEventListener('click', function() {
const logId = this.getAttribute('data-log-id');
handleDeleteLogRow(logId);
});
});
// Re-initialize copy buttons specifically for the newly rendered table rows
setupCopyButtons('#errorLogsTable');
// Update selected state after rendering
updateSelectedState();
}
// 显示错误日志详情 (从 API 获取)
@@ -350,15 +758,14 @@ async function showLogDetails(logId) {
document.body.style.overflow = 'hidden'; // Prevent body scrolling
try {
const response = await fetch(`/api/logs/errors/${logId}/details`);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) { /* ignore */ }
throw new Error(errorData?.detail || `获取日志详情失败: ${response.statusText}`);
// Use fetchAPI to get log details
const logDetails = await fetchAPI(`/api/logs/errors/${logId}/details`);
// fetchAPI handles response.ok check and JSON parsing
if (!logDetails) {
// Handle case where API returns success but no data (if possible)
throw new Error('未找到日志详情');
}
const logDetails = await response.json();
// Format date
let formattedTime = 'N/A';
@@ -403,6 +810,9 @@ async function showLogDetails(logId) {
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
document.getElementById('modalRequestTime').textContent = formattedTime;
// Re-initialize copy buttons specifically for the modal after content is loaded
setupCopyButtons('#logDetailModal');
} catch (error) {
console.error('获取日志详情失败:', error);
// Show error in modal
@@ -435,8 +845,8 @@ function updatePagination(currentItemCount, totalItems) {
// Calculate total pages only if totalItems is known and valid
let totalPages = 1;
if (totalItems >= 0) {
totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
} else if (currentItemCount < pageSize && currentPage === 1) {
totalPages = Math.max(1, Math.ceil(totalItems / errorLogState.pageSize));
} else if (currentItemCount < errorLogState.pageSize && errorLogState.currentPage === 1) {
// If less items than page size fetched on page 1, assume it's the only page
totalPages = 1;
} else {
@@ -444,15 +854,15 @@ function updatePagination(currentItemCount, totalItems) {
// We can show Prev/Next based on current page and if items were returned
console.warn("Total item count unknown, pagination will be limited.");
// Basic Prev/Next for unknown total
addPaginationLink(paginationElement, '&laquo;', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
addPaginationLink(paginationElement, currentPage.toString(), true, null, true); // Current page number (non-clickable)
addPaginationLink(paginationElement, '&raquo;', currentItemCount === pageSize, () => { currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned
addPaginationLink(paginationElement, '&laquo;', errorLogState.currentPage > 1, () => { errorLogState.currentPage--; loadErrorLogs(); });
addPaginationLink(paginationElement, errorLogState.currentPage.toString(), true, null, true); // Current page number (non-clickable)
addPaginationLink(paginationElement, '&raquo;', currentItemCount === errorLogState.pageSize, () => { errorLogState.currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned
return; // Exit here for limited pagination
}
const maxPagesToShow = 5; // Max number of page links to show
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let startPage = Math.max(1, errorLogState.currentPage - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
// Adjust startPage if endPage reaches the limit first
@@ -462,11 +872,11 @@ function updatePagination(currentItemCount, totalItems) {
// Previous Button
addPaginationLink(paginationElement, '&laquo;', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
addPaginationLink(paginationElement, '&laquo;', errorLogState.currentPage > 1, () => { errorLogState.currentPage--; loadErrorLogs(); });
// First Page Button
if (startPage > 1) {
addPaginationLink(paginationElement, '1', true, () => { currentPage = 1; loadErrorLogs(); });
addPaginationLink(paginationElement, '1', true, () => { errorLogState.currentPage = 1; loadErrorLogs(); });
if (startPage > 2) {
addPaginationLink(paginationElement, '...', false); // Ellipsis
}
@@ -474,7 +884,7 @@ function updatePagination(currentItemCount, totalItems) {
// Page Number Buttons
for (let i = startPage; i <= endPage; i++) {
addPaginationLink(paginationElement, i.toString(), true, () => { currentPage = i; loadErrorLogs(); }, i === currentPage);
addPaginationLink(paginationElement, i.toString(), true, () => { errorLogState.currentPage = i; loadErrorLogs(); }, i === errorLogState.currentPage);
}
// Last Page Button
@@ -482,12 +892,12 @@ function updatePagination(currentItemCount, totalItems) {
if (endPage < totalPages - 1) {
addPaginationLink(paginationElement, '...', false); // Ellipsis
}
addPaginationLink(paginationElement, totalPages.toString(), true, () => { currentPage = totalPages; loadErrorLogs(); });
addPaginationLink(paginationElement, totalPages.toString(), true, () => { errorLogState.currentPage = totalPages; loadErrorLogs(); });
}
// Next Button
addPaginationLink(paginationElement, '&raquo;', currentPage < totalPages, () => { currentPage++; loadErrorLogs(); });
addPaginationLink(paginationElement, '&raquo;', errorLogState.currentPage < totalPages, () => { errorLogState.currentPage++; loadErrorLogs(); });
}
// Helper function to add pagination links
@@ -547,10 +957,17 @@ function showError(show, message = '加载错误日志失败,请稍后重试
// Function to show temporary status notifications (like copy success)
function showNotification(message, type = 'success', duration = 3000) {
const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed
if (!notificationElement) return;
const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html
if (!notificationElement) {
console.error("Notification element with ID 'notification' not found.");
return;
}
// Set message and type class
notificationElement.textContent = message;
// Remove previous type classes before adding the new one
notificationElement.classList.remove('success', 'error', 'warning', 'info');
notificationElement.classList.add(type); // Add the type class for styling
notificationElement.className = `notification ${type} show`; // Add 'show' class
// Hide after duration

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,285 +1,369 @@
<!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: 50;
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.85); /* Slightly increased opacity for better readability */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.18); /* Subtle border */
}
.bg-gradient {
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(243, 244, 246, 0.8); /* bg-gray-100 with opacity */
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(79, 70, 229, 0.4); /* primary-600 with opacity */
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(79, 70, 229, 0.6); /* primary-600 with more opacity */
}
/* Basic modal styles */
.modal {
display: none;
position: fixed;
z-index: 50;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
backdrop-filter: blur(4px);
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
}
/* Loading spinner */
.loading-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Notification */
.notification {
position: fixed;
bottom: 5rem; /* Adjusted from bottom-20 */
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.25rem; /* px-5 py-3 */
border-radius: 0.5rem; /* rounded-lg */
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-weight: 500; /* font-medium */
z-index: 1000; /* Increased z-index */
opacity: 0;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}
.notification.show {
opacity: 1;
transform: translate(-50%, 0);
}
.notification.error {
background-color: rgba(220, 38, 38, 0.8); /* danger-600 with opacity */
}
/* Scroll buttons */
.scroll-buttons {
position: fixed;
right: 1.25rem; /* right-5 */
bottom: 5rem; /* bottom-20 */
display: flex;
flex-direction: column;
gap: 0.5rem; /* gap-2 */
z-index: 10;
}
.scroll-button {
width: 2.5rem; /* w-10 */
height: 2.5rem; /* h-10 */
background-color: #4f46e5; /* bg-primary-600 */
color: white;
border-radius: 9999px; /* rounded-full */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* shadow-md */
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease-in-out;
}
.scroll-button:hover {
background-color: #4338ca; /* hover:bg-primary-700 */
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* hover:shadow-lg */
}
{% block head_extra_styles %}
{% endblock %}
</style>
{% block head_extra_scripts %}{% endblock %}
</head>
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
</head>
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
{% block content %}{% endblock %}
<!-- 底部版权 -->
<div class="fixed bottom-0 left-0 w-full py-3 bg-white bg-opacity-80 backdrop-blur-md text-center text-sm text-gray-600 border-t border-gray-200">
© <span id="copyright-year"></span> by
<a href="https://linux.do/u/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily" class="inline-block w-5 h-5 rounded-full align-middle mr-1">snaily
</a> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fab fa-github"></i> GitHub
</a> |
<a href="https://afdian.com/a/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fas fa-drumstick-bite text-yellow-600"></i> 给作者加鸡腿
<div
class="fixed bottom-0 left-0 w-full py-3 bg-white bg-opacity-80 backdrop-blur-md text-sm text-gray-800 border-t border-gray-200 flex flex-col items-center space-y-1"
>
<!-- 第一行 -->
<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>
<span class="text-yellow-600 font-semibold flex items-center">
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
</span>
{% if request and request.app.state.update_info %}
{% set update_info = request.app.state.update_info %}
<span class="mx-1">|</span>
<span class="text-xs text-gray-500">v{{ update_info.current_version }}</span>
{% if update_info.update_available %}
<span class="mx-1">|</span>
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
<i class="fas fa-arrow-up"></i> 新版本: v{{ update_info.latest_version }}
</a>
{% elif update_info.error_message and update_info.error_message != 'Checking...' %}
<span class="mx-1">|</span>
<span class="text-xs text-red-500" title="{{ update_info.error_message }}">更新检查失败</span>
{% endif %}
{% endif %}
<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);
}
// 页面刷新带加载状态
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
// Helper to create notification element if it doesn't exist
function createNotificationElement() {
let notification = document.getElementById("notification");
if (!notification) {
notification = document.createElement("div");
notification.id = "notification";
notification.className = "notification";
document.body.appendChild(notification);
}
return notification;
}
// 页面刷新带加载状态
function refreshPage(button) {
if (button) {
const icon = button.querySelector("i");
if (icon) {
icon.classList.add("loading-spin");
}
}
setTimeout(() => {
window.location.reload();
}, 300); // Short delay to show spinner
}
// --- Version Check ---
const versionInfoContainer = document.getElementById(
"version-info-container"
);
async function fetchVersionInfo() {
if (!versionInfoContainer) return;
versionInfoContainer.innerHTML =
'<span class="mx-1">|</span><span class="text-xs text-gray-700">检查更新中...</span>'; // Initial loading state
try {
const response = await fetch("/api/version/check");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
let versionHtml = `<span class="mx-1">|</span><span class="text-xs text-gray-800">v${data.current_version}</span>`;
if (data.update_available) {
versionHtml += `
<span class="mx-1">|</span>
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
<i class="fas fa-arrow-up"></i> 新版本: v${data.latest_version}
</a>`;
} else if (data.error_message) {
versionHtml += `
<span class="mx-1">|</span>
<span class="text-xs text-red-500" title="${data.error_message}">更新检查失败</span>`;
} else {
versionHtml += `<span class="mx-1">|</span><span class="text-xs text-green-500">已是最新</span>`; // Indicate up-to-date
}
versionInfoContainer.innerHTML = versionHtml;
} catch (error) {
console.error("Error fetching version info:", error);
versionInfoContainer.innerHTML = `<span class="mx-1">|</span><span class="text-xs text-red-500" title="无法连接到服务器或解析响应">更新检查失败</span>`;
}
}
// Fetch immediately on load
fetchVersionInfo();
// Fetch periodically (e.g., every hour)
setInterval(fetchVersionInfo, 3600000); // 3600000 ms = 1 hour
</script>
{% block body_scripts %}{% endblock %}
</body>
</html>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,242 +1,636 @@
{% extends "base.html" %}
{% block title %}错误日志管理 - Gemini Balance{% endblock %}
{% block head_extra_styles %}
{% extends "base.html" %} {% block title %}错误日志管理 - Gemini Balance{%
endblock %} {% block head_extra_styles %}
<style>
/* error_logs.html specific styles */
/* Table styles */
.styled-table th {
position: sticky;
top: 0;
background: #f3f4f6; /* bg-gray-100 */
z-index: 10;
/* error_logs.html specific styles */
.styled-table th {
position: sticky;
top: 0;
background-color: rgba(80, 60, 160, 0.8); /* theming: table header bg */
color: #ffffff !important; /* theming: table header text, ensured light */
z-index: 10;
border-bottom: 1px solid rgba(120, 100, 200, 0.4);
}
.styled-table tbody tr:hover {
background-color: rgba(90, 70, 170, 0.4); /* theming: table row hover */
}
.styled-table td {
padding: 12px 20px;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
color: #d1d5db; /* theming: table cell text (gray-300) */
border-bottom: 1px solid rgba(120, 100, 200, 0.2); /* theming: cell border */
}
.styled-table td:nth-child(4) {
white-space: nowrap;
}
.btn-view-details {
background-color: rgba(107, 70, 193, 0.4); /* theming */
color: #c4b5fd; /* theming */
padding: 6px 12px;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s ease-in-out;
border: 1px solid rgba(120, 100, 200, 0.6); /* theming */
}
.btn-view-details:hover {
background-color: rgba(120, 100, 200, 0.6); /* theming */
color: #ede9fe; /* theming */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
@media (max-width: 768px) {
.search-container {
grid-template-columns: 1fr;
}
.styled-table tbody tr:hover {
background-color: #f9fafb; /* bg-gray-50 */
}
input[type="text"],
input[type="datetime-local"],
select,
button {
height: 36px !important;
}
.form-input-themed,
input[type="datetime-local"],
select#pageSize {
background-color: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(120, 100, 200, 0.5) !important;
color: #ffffff !important;
}
.form-input-themed::placeholder,
input[type="datetime-local"]::placeholder {
color: #a0aec0 !important;
}
.form-input-themed:focus,
input[type="datetime-local"]:focus,
select#pageSize:focus {
border-color: #a78bfa !important;
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.4) !important;
}
select#pageSize {
/* Styles from config_editor.html .form-select-themed, adapted for select#pageSize */
background-color: rgba(60, 40, 130, 0.6) !important;
border: 1px solid rgba(167, 139, 250, 0.7) !important;
color: #ffffff !important;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23d8b4fe' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 8l4 4 4-4'/%3e%3c/svg%3e") !important;
appearance: none !important;
padding: 0.6rem 2.5rem 0.6rem 0.8rem !important;
background-repeat: no-repeat !important;
background-position: right 0.6rem center !important;
background-size: 1.5em 1.5em !important;
border-radius: 0.5rem !important;
font-weight: 500 !important;
height: 36px !important; /* Retain original height or use auto */
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important;
cursor: pointer !important;
}
select#pageSize:focus {
border-color: #d8b4fe !important; /* violet-300 */
box-shadow: 0 0 0 3px rgba(216, 180, 254, 0.4) !important; /* ring-violet-300 */
outline: none !important;
}
select#pageSize option {
background-color: rgba(76, 29, 149, 0.95) !important; /* 暗紫色背景 */
color: #ffffff !important;
padding: 8px !important;
}
.date-range-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 640px) {
input[type="datetime-local"] {
min-width: 0;
width: 100%;
}
.styled-table td {
padding: 12px 20px;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
/* Ensure error log column does not wrap and remove max-width */
.styled-table td:nth-child(4) { /* Assuming error log is the 4th column */
/* max-width: none; */
white-space: nowrap;
}
.btn-view-details {
background-color: #eef2ff; /* primary-50 */
color: #4f46e5; /* primary-600 */
padding: 6px 12px;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s ease-in-out;
border: 1px solid #c7d2fe; /* primary-200 */
}
.btn-view-details:hover {
background-color: #c7d2fe; /* primary-200 */
color: #4338ca; /* primary-700 */
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
@media (max-width: 768px) {
.search-container {
grid-template-columns: 1fr;
}
}
/* Modal styles are in base.html */
}
label {
color: #e2e8f0 !important; /* Light gray/white for labels */
font-weight: 500;
}
/* 导航链接悬停样式 (从 config_editor.html 复制) */
.nav-link {
transition: all 0.2s ease-in-out;
}
.nav-link:hover {
background-color: rgba(120, 100, 200, 0.6) !important;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
/* Ensure text around pageSize select is light */
.pagination-text {
color: #e2e8f0 !important; /* Light gray/white for text */
font-weight: 500;
}
/* Pagination custom styles */
.pagination li a, .pagination li span { /* Assuming 'span' might be used for non-clickable items like '...' */
display: flex; /* For centering content if icons are used */
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem; /* Adjust padding as needed */
line-height: 1.25;
color: #e2e8f0; /* Light gray/white text */
background-color: rgba(107, 70, 193, 0.4); /* Consistent with other buttons */
border: 1px solid rgba(120, 100, 200, 0.6); /* Consistent with other buttons */
border-radius: 0.375rem; /* Tailwind's rounded-md */
transition: all 0.2s ease-in-out;
min-width: 36px; /* Ensure minimum width for small numbers */
text-align: center;
}
.pagination li a:hover, .pagination li span:hover:not(.disabled) { /* Avoid hover on disabled spans */
color: #ffffff;
background-color: rgba(120, 100, 200, 0.6); /* Consistent with other button hovers */
border-color: rgba(167, 139, 250, 0.8);
}
.pagination li.active a, .pagination li.active span { /* Assuming 'active' class for current page */
color: #ffffff !important;
background-color: #7c3aed !important; /* Violet-600, ensure it overrides */
border-color: #7c3aed !important;
font-weight: 600; /* Make active page number bolder */
}
.pagination li.disabled a, .pagination li.disabled span { /* Assuming 'disabled' class */
color: rgba(226, 232, 240, 0.6) !important;
background-color: rgba(80, 60, 160, 0.3) !important; /* Slightly more visible than pure disabled */
border-color: rgba(120, 100, 200, 0.4) !important;
cursor: not-allowed;
pointer-events: none;
}
</style>
{% endblock %}
{% endblock %} {% block content %}
<div class="container mx-auto px-4">
<div
class="rounded-2xl shadow-xl p-6 md:p-8"
style="
background-color: rgba(80, 60, 160, 0.3);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(150, 130, 230, 0.3);
"
>
<h1
class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-pink-400 mb-4"
>
<img
src="/static/icons/logo.png"
alt="Gemini Balance Logo"
class="h-9 inline-block align-middle mr-2"
/>
Gemini Balance - 错误日志
</h1>
{% block content %}
<div class="container mx-auto px-4"> <!-- Removed max-width-7xl for wider content -->
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
<!-- Removed refresh button from top right -->
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance - 错误日志
</h1>
<!-- Navigation Tabs -->
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
<a href="/config" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
<i class="fas fa-cog"></i> 配置编辑
</a>
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<!-- 主内容区域 -->
<div class="bg-white bg-opacity-70 rounded-xl p-6 shadow-lg animate-fade-in">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-bug text-primary-600"></i> 错误日志列表
</h2>
<!-- 控制区域 (Refresh button removed, page size moved below) -->
<!-- Removed the original controls div -->
<!-- 搜索控件 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
<div class="flex items-center gap-2 col-span-1 lg:col-span-2">
<input type="datetime-local" id="startDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
<span class="text-gray-700"></span>
<input type="datetime-local" id="endDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
</div>
<button id="searchBtn" class="flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 col-span-1">
<i class="fas fa-search"></i> 搜索
</button>
</div>
<!-- 表格容器 - Enhanced Styling -->
<div class="overflow-x-auto rounded-lg border border-gray-200 mb-6 bg-white"> <!-- Removed shadow, added border -->
<table class="styled-table w-full min-w-full text-sm"> <!-- Added text-sm -->
<thead>
<tr class="bg-primary-50 text-left text-primary-800"> <!-- Changed header background and text color -->
<th class="px-5 py-3 font-semibold rounded-tl-lg">ID</th> <!-- Increased padding, adjusted rounding -->
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
<th class="px-5 py-3 font-semibold">错误类型</th>
<th class="px-5 py-3 font-semibold">错误码</th>
<th class="px-5 py-3 font-semibold">模型名称</th>
<th class="px-5 py-3 font-semibold">请求时间</th>
<th class="px-5 py-3 font-semibold rounded-tr-lg">操作</th> <!-- Adjusted rounding -->
</tr>
</thead>
<tbody id="errorLogsTable" class="divide-y divide-gray-200">
<!-- 错误日志数据将通过JavaScript动态加载 -->
</tbody>
</table>
</div>
<!-- 状态指示器 -->
<div id="loadingIndicator" class="flex items-center justify-center p-8 hidden">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
<p class="ml-4 text-lg text-gray-700 font-medium">加载中,请稍候...</p>
</div>
<div id="noDataMessage" class="text-center py-12 text-gray-500 hidden">
<i class="fas fa-inbox text-5xl mb-3"></i>
<p class="text-lg">暂无错误日志数据</p>
</div>
<div id="errorMessage" class="bg-danger-50 text-danger-600 p-4 rounded-lg font-medium text-center hidden">
<i class="fas fa-exclamation-circle mr-2"></i>
加载错误日志失败,请稍后重试。
</div>
<!-- 分页与每页显示控件 -->
<div class="flex flex-col sm:flex-row justify-between items-center mt-6 gap-4">
<!-- 每页显示控件 (Moved here) -->
<div class="flex items-center gap-2 text-sm text-gray-700">
<label for="pageSize" class="font-medium">每页显示:</label>
<select id="pageSize" class="rounded-md border border-gray-300 focus:ring focus:ring-primary-200 focus:border-primary-500 px-2 py-1 bg-white text-sm">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span></span>
</div>
<!-- 分页控件 -->
<div class="flex items-center gap-4"> <!-- Wrapper for pagination and input -->
<ul class="pagination flex items-center gap-1" id="pagination">
<!-- 分页控件将通过JavaScript动态加载 -->
</ul>
<!-- 页码输入跳转 -->
<div class="flex items-center gap-1">
<input type="number" id="pageInput" min="1" class="w-16 px-2 py-1 rounded-md border border-gray-300 text-sm focus:ring focus:ring-primary-200 focus:border-primary-500" placeholder="页码">
<button id="goToPageBtn" class="px-3 py-1 bg-primary-600 hover:bg-primary-700 text-white text-sm rounded-md transition">跳转</button>
</div>
</div>
</div>
</div>
</div>
<!-- Navigation Tabs -->
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
<a
href="/config"
class="nav-link whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg text-gray-200 hover:text-white transition-all duration-200"
style="background-color: rgba(107, 70, 193, 0.4)"
>
<i class="fas fa-cog"></i> 配置编辑
</a>
<a
href="/keys"
class="nav-link whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg text-gray-200 hover:text-white transition-all duration-200"
style="background-color: rgba(107, 70, 193, 0.4)"
>
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<a
href="/logs"
class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-violet-600 text-white shadow-md"
>
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<!-- Scroll buttons are now in base.html -->
<div class="scroll-buttons">
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
<i class="fas fa-chevron-up"></i>
</button>
<button class="scroll-button" onclick="scrollToBottom()" title="滚动到底部">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<!-- Notification component is now in base.html (use id="notification") -->
<div id="notification" class="notification"></div>
<!-- Footer is now in base.html -->
<!-- 日志详情模态框 -->
<div id="logDetailModal" class="modal">
<div class="w-full max-w-6xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in"> <!-- Increased max-width to 6xl -->
<div class="p-6">
<div class="flex justify-between items-center border-b border-gray-200 pb-4 mb-4">
<h2 class="text-xl font-bold text-gray-800">错误日志详情</h2>
<button id="closeLogDetailModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
<div class="bg-gray-50 p-4 rounded-lg">
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
<p id="modalErrorType" class="text-danger-600 font-medium"></p>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
<pre id="modalRequestMsg" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestMsg" title="复制请求消息">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
<p id="modalModelName" class="font-medium"></p>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
<p id="modalRequestTime" class="font-medium"></p>
</div>
</div>
<div class="flex justify-end mt-6">
<button type="button" id="closeModalFooterBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg font-medium transition">关闭</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block body_scripts %}
<script src="/static/js/error_logs.js"></script>
<script>
// error_logs.html specific JS initialization (if any)
// e.g., initialize date pickers or other elements if needed
// The main logic is in error_logs.js
</script>
<!-- 主内容区域 -->
<div
class="rounded-xl p-6 shadow-lg animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.5);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
border: 1px solid rgba(120, 100, 200, 0.2);
"
>
<h2
class="text-xl font-bold mb-6 pb-3 border-b flex items-center gap-2 text-gray-100 border-violet-300 border-opacity-30"
>
<i class="fas fa-bug text-violet-400"></i> 错误日志列表
</h2>
<!-- 搜索与操作控件 -->
<div
class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"
>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"
>
<input
type="text"
id="keySearch"
placeholder="搜索密钥 (部分)"
class="px-3 py-1 rounded-lg border form-input-themed"
/>
<input
type="text"
id="errorSearch"
placeholder="搜索错误类型/日志"
class="px-3 py-1 rounded-lg border form-input-themed"
/>
<input
type="text"
id="errorCodeSearch"
placeholder="搜索错误码"
class="px-3 py-1 rounded-lg border form-input-themed"
/>
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-2 col-span-1 sm:col-span-2 lg:col-span-3 mt-2"
>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-300 whitespace-nowrap"
>开始时间:</label
>
<input
type="datetime-local"
id="startDate"
class="px-3 py-1 rounded-lg border text-sm w-full"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-300 whitespace-nowrap"
>结束时间:</label
>
<input
type="datetime-local"
id="endDate"
class="px-3 py-1 rounded-lg border text-sm w-full"
/>
</div>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<button
id="searchBtn"
class="flex items-center justify-center px-4 py-1.5 bg-violet-600 hover:bg-violet-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap"
>
<i class="fas fa-search mr-1.5"></i>搜索
</button>
<button
id="copySelectedKeysBtn"
class="flex items-center justify-center px-4 py-1.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap"
disabled
>
<i class="far fa-copy mr-1.5"></i>复制
</button>
<button
id="deleteSelectedBtn"
class="flex items-center justify-center px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap"
disabled
>
<i class="fas fa-trash-alt mr-1.5"></i>删除
</button>
</div>
</div>
<!-- 表格容器 -->
<div
class="overflow-x-auto rounded-lg border mb-6"
style="border-color: rgba(120, 100, 200, 0.3)"
>
<table class="styled-table w-full min-w-full text-sm">
<thead>
<tr class="text-left">
<th
class="px-3 py-3 font-semibold rounded-tl-lg w-12 text-center"
>
<input
type="checkbox"
id="selectAllCheckbox"
class="form-checkbox h-4 w-4 text-violet-500 border-gray-500 rounded focus:ring-violet-500 bg-transparent"
/>
</th>
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
ID <i class="fas fa-sort ml-1"></i>
</th>
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
<th class="px-5 py-3 font-semibold">错误类型</th>
<th class="px-5 py-3 font-semibold">错误码</th>
<th class="px-5 py-3 font-semibold">模型名称</th>
<th class="px-5 py-3 font-semibold">请求时间</th>
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">
操作
</th>
</tr>
</thead>
<tbody
id="errorLogsTable"
class="divide-y"
style="border-color: rgba(120, 100, 200, 0.2)"
>
<!-- 错误日志数据将通过JavaScript动态加载 -->
</tbody>
</table>
</div>
<!-- 状态指示器 -->
<div
id="loadingIndicator"
class="flex items-center justify-center p-8 hidden"
>
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-violet-400"
></div>
<p class="ml-4 text-lg text-gray-300 font-medium">加载中,请稍候...</p>
</div>
<div id="noDataMessage" class="text-center py-12 text-gray-400 hidden">
<i class="fas fa-inbox text-5xl mb-3"></i>
<p class="text-lg">暂无错误日志数据</p>
</div>
<div
id="errorMessage"
class="p-4 rounded-lg font-medium text-center hidden"
style="background-color: rgba(220, 38, 38, 0.2); color: #fca5a5"
>
<i class="fas fa-exclamation-circle mr-2"></i>
加载错误日志失败,请稍后重试。
</div>
<!-- 分页与每页显示控件 -->
<div
class="flex flex-col sm:flex-row justify-between items-center mt-6 gap-4"
>
<div class="flex items-center gap-2 text-sm text-gray-300">
<label for="pageSize" class="font-medium pagination-text"
>每页显示:</label
>
<select
id="pageSize"
class="rounded-md border focus:ring focus:border-violet-400 px-2 py-1 text-sm"
>
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="pagination-text"></span>
</div>
<div class="flex items-center gap-4">
<ul class="pagination flex items-center gap-1" id="pagination">
<!-- 分页控件将通过JavaScript动态加载 -->
</ul>
<div class="flex items-center gap-1">
<input
type="number"
id="pageInput"
min="1"
class="w-16 px-2 py-1 rounded-md border text-sm focus:ring focus:border-violet-400 form-input-themed"
placeholder="页码"
/>
<button
id="goToPageBtn"
class="px-3 py-1 bg-violet-600 hover:bg-violet-700 text-white text-sm rounded-md transition"
>
跳转
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scroll buttons are now in base.html -->
<div class="scroll-buttons">
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
<i class="fas fa-chevron-up"></i>
</button>
<button class="scroll-button" onclick="scrollToBottom()" title="滚动到底部">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<!-- Notification component is now in base.html (use id="notification") -->
<div id="notification" class="notification"></div>
<!-- Footer is now in base.html -->
<!-- 日志详情模态框 -->
<div id="logDetailModal" class="modal">
<div
class="w-full max-w-6xl mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border: 1px solid rgba(120, 100, 200, 0.4);
"
>
<div class="p-6">
<div
class="flex justify-between items-center pb-4 mb-4"
style="border-bottom: 1px solid rgba(120, 100, 200, 0.4)"
>
<h2 class="text-xl font-bold text-gray-100">错误日志详情</h2>
<button
id="closeLogDetailModalBtn"
class="text-gray-300 hover:text-gray-100 text-xl"
>
&times;
</button>
</div>
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
<div
class="p-4 rounded-lg relative group"
style="background-color: rgba(80, 60, 160, 0.3)"
>
<h6 class="text-sm font-semibold text-violet-200 mb-1">
Gemini密钥:
</h6>
<pre
id="modalGeminiKey"
class="font-mono text-sm p-3 rounded overflow-x-auto"
style="background-color: rgba(0, 0, 0, 0.2); color: #e5e7eb"
></pre>
<button
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
style="background-color: rgba(0, 0, 0, 0.3)"
data-target="modalGeminiKey"
title="复制密钥"
>
<i class="far fa-copy"></i>
</button>
</div>
<div
class="p-4 rounded-lg relative group"
style="background-color: rgba(80, 60, 160, 0.3)"
>
<h6 class="text-sm font-semibold text-violet-200 mb-1">错误类型:</h6>
<p id="modalErrorType" class="text-red-300 font-medium pr-8"></p>
<button
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
style="background-color: rgba(0, 0, 0, 0.3)"
data-target="modalErrorType"
title="复制错误类型"
>
<i class="far fa-copy"></i>
</button>
</div>
<div
class="p-4 rounded-lg relative group"
style="background-color: rgba(80, 60, 160, 0.3)"
>
<h6 class="text-sm font-semibold text-violet-200 mb-1">错误日志:</h6>
<pre
id="modalErrorLog"
class="font-mono text-sm p-3 rounded overflow-x-auto whitespace-pre-wrap"
style="background-color: rgba(0, 0, 0, 0.2); color: #e5e7eb"
></pre>
<button
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
style="background-color: rgba(0, 0, 0, 0.3)"
data-target="modalErrorLog"
title="复制错误日志"
>
<i class="far fa-copy"></i>
</button>
</div>
<div
class="p-4 rounded-lg relative group"
style="background-color: rgba(80, 60, 160, 0.3)"
>
<h6 class="text-sm font-semibold text-violet-200 mb-1">请求消息:</h6>
<pre
id="modalRequestMsg"
class="font-mono text-sm p-3 rounded overflow-x-auto whitespace-pre-wrap"
style="background-color: rgba(0, 0, 0, 0.2); color: #e5e7eb"
></pre>
<button
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
style="background-color: rgba(0, 0, 0, 0.3)"
data-target="modalRequestMsg"
title="复制请求消息"
>
<i class="far fa-copy"></i>
</button>
</div>
<div
class="p-4 rounded-lg relative group"
style="background-color: rgba(80, 60, 160, 0.3)"
>
<h6 class="text-sm font-semibold text-violet-200 mb-1">模型名称:</h6>
<p id="modalModelName" class="font-medium pr-8 text-gray-200"></p>
<button
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
style="background-color: rgba(0, 0, 0, 0.3)"
data-target="modalModelName"
title="复制模型名称"
>
<i class="far fa-copy"></i>
</button>
</div>
<div
class="p-4 rounded-lg relative group"
style="background-color: rgba(80, 60, 160, 0.3)"
>
<h6 class="text-sm font-semibold text-violet-200 mb-1">请求时间:</h6>
<p id="modalRequestTime" class="font-medium pr-8 text-gray-200"></p>
<button
class="copy-btn absolute top-2 right-2 hover:bg-gray-600 text-gray-300 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
style="background-color: rgba(0, 0, 0, 0.3)"
data-target="modalRequestTime"
title="复制请求时间"
>
<i class="far fa-copy"></i>
</button>
</div>
</div>
<div
class="flex justify-end mt-6 pt-4"
style="border-top: 1px solid rgba(120, 100, 200, 0.4)"
>
<button
type="button"
id="closeModalFooterBtn"
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-6 py-2 rounded-lg font-medium transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div id="deleteConfirmModal" class="modal">
<div
class="w-full max-w-md mx-auto rounded-xl shadow-xl overflow-hidden animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border: 1px solid rgba(120, 100, 200, 0.4);
"
>
<div class="p-6">
<div
class="flex justify-between items-center pb-3 mb-4"
style="border-bottom: 1px solid rgba(120, 100, 200, 0.4)"
>
<h2 class="text-lg font-semibold text-gray-100">确认删除</h2>
<button
id="closeDeleteConfirmModalBtn"
class="text-gray-300 hover:text-gray-100 text-xl"
>
&times;
</button>
</div>
<p id="deleteConfirmMessage" class="text-gray-300 mb-6">
你确定要删除选中的项目吗?此操作不可恢复!
</p>
<div class="flex justify-end gap-3">
<button
id="cancelDeleteBtn"
type="button"
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-5 py-2 rounded-lg font-medium transition"
>
取消
</button>
<button
id="confirmDeleteBtn"
type="button"
class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg font-medium transition"
>
确认删除
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block body_scripts %}
<script src="/static/js/error_logs.js"></script>
<script>
// error_logs.html specific JS initialization (if any)
// e.g., initialize date pickers or other elements if needed
// The main logic is in error_logs.js
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,19 @@ import re
import base64
import requests
from typing import Dict, Any, List, Optional, Tuple
from pathlib import Path
import logging # Import logging
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
# Define logger for helper functions if needed, or use specific loggers
helper_logger = logging.getLogger("app.utils") # Or use a more specific logger if available
# Define project root and version file path here for get_current_version
# Assuming this file is at app/utils/helpers.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
"""
@@ -146,3 +156,21 @@ def is_valid_api_key(key: str) -> bool:
return False
def get_current_version(default_version: str = "0.0.0") -> str:
"""Reads the current version from the VERSION file."""
version_file = VERSION_FILE_PATH # Use Path object defined above
try:
# Use Path object's open method
with version_file.open('r', encoding='utf-8') as f:
version = f.read().strip()
if not version:
helper_logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
return default_version
return version
except FileNotFoundError:
helper_logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
return default_version
except IOError as e:
helper_logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
return default_version

View File

@@ -1,5 +1,5 @@
fastapi
httpx
httpx[socks]
openai
pydantic
pydantic_settings
@@ -14,8 +14,8 @@ cryptography # 支持 MySQL 8+ caching_sha2_password 验证
pymysql
sqlalchemy
aiomysql
aiosqlite
databases
python-dotenv
apscheduler # 添加定时任务库
apscheduler
packaging