Compare commits

..

48 Commits

Author SHA1 Message Date
snaily
68b65814bc chore(version): 更新版本号至 2.2.6 2025-09-18 07:37:37 +08:00
snaily
88f5b33018 docs(config): 优化错误日志配置选项的说明文案
将错误日志记录请求体选项的提示文案从"关闭可避免敏感数据入库"
更新为"关闭可减少大量磁盘空间占用",更准确地描述该功能的作用
2025-09-18 06:41:09 +08:00
snaily
8c62c8121d feat(static): 实现静态资源版本化和模板全局变量支持
- 在Dockerfile中添加默认环境变量配置
- 新增静态资源URL版本化管理功能
- 更新所有模板文件使用static_url函数替代硬编码路径
- 优化错误日志页面移动端按钮布局和响应式设计
- 简化异常处理器返回格式

BREAKING CHANGE: 静态资源URL格式变更,需要重新部署以确保资源正确加载
2025-09-18 06:29:45 +08:00
snaily
05762cb6a5 feat(config):更新默认模型和相关配置
更新默认模型和相关配置:
- 将默认测试模型从 gemini-1.5-flash 更新为 gemini-2.5-flash-lite
- 更新思考模型列表至 gemini-2.5-flash 和 gemini-2.5-pro
- 添加新的图像模型 gemini-2.5-flash-image-preview
- 更新搜索模型配置以支持最新的 Gemini 2.5 系列
- 同步更新文档中的模型配置说明
2025-09-18 05:24:29 +08:00
snaily
78f38cc981 refactor(scheduler): 优化定时任务配置和时间处理
- 支持CHECK_INTERVAL_HOURS设置为0以禁用密钥检查任务
- 调整日志清理任务执行时间从凌晨3点改为0点
- 移除timezone依赖,使用本地时间处理
- 优化代码格式和导入顺序
- 为配置编辑器添加CHECK_INTERVAL_HOURS输入验证
- 改进UI布局,为关键配置项添加警告提示
2025-09-18 05:14:43 +08:00
snaily
79f47c315e style(ui): 重构配置编辑器字段描述显示方式
将所有配置字段的描述文本从底部小字说明改为标签旁的问号图标提示,提升界面简洁度和用户体验。同时优化了数组容器和独立输入框的边框样式区分。
2025-09-18 04:49:04 +08:00
snaily
708fb1604b feat(config): 新增错误日志请求体记录开关(默认关闭)
- 新增环境变量 ERROR_LOG_RECORD_REQUEST_BODY,默认 false
- Settings 增加该配置,并在各服务写入错误日志时按开关决定是否
  入库请求体,降低敏感信息泄露风险
- 配置编辑页新增对应开关,前端初始化默认值;.env.example、
  README/README_ZH 同步更新
- db: add_error_log 支持 None 请求体并更稳健解析字符串/字典
- perf(db): 将错误日志批量删除 batch_size 从 500 下调到 200,
  兼容 SQLite/MySQL 参数上限并提升稳定性
- docs: 补充 aliyun_oss 上传提供商与 OSS 配置示例
- style: 轻微代码格式化与导入顺序优化
2025-09-18 04:21:28 +08:00
snaily
7dbd3ad693 perf(db): 优化错误日志删除以支持大数据量
将 `delete_all_error_logs` 函数的实现从一次性删除所有记录改为分批删除。这可以防止在处理大量日志时因数据库事务过长而导致的超时或性能问题。

- 每次从数据库中获取一批日志ID,然后根据ID进行删除。
- 在每个批次处理后,使用 `asyncio.sleep(0)` 将控制权交还给事件循环,避免长时间阻塞。
- 批次大小设置为500,以兼容不同数据库(如SQLite)对SQL参数数量的限制。
- 函数现在返回实际删除的日志总数,而不是一个固定的成功指示符。
2025-09-18 03:33:59 +08:00
snaily
67dd1af583 refactor(error): 统一异常处理和响应格式
这次提交重构了整个应用的异常处理机制,保证了处理方式的一致性,还能提供更详细的错误信息。

主要改动包括:
- 修改了 `ApiClient`,现在抛出的异常会同时包含状态码和消息。这样上游服务就能传递准确的 HTTP 错误响应啦。
- 更新了所有服务层(`gemini`、`openai`、`vertex`、`embedding`),现在会捕获这些结构化的异常,不再从字符串里解析错误消息了。
- 增强了路由级别的错误处理,特别是针对流式端点,能正确捕获初始化错误,并返回结构化的 JSON 错误响应,而不是格式错误的 SSE 事件。
- 在所有 API 路由中添加了 `allowed_token` 的日志记录,方便追踪和调试授权问题。
- 还有一些常规的代码清理,比如调整了 import 顺序和格式化代码,提高了可读性和可维护性。
2025-09-18 03:11:45 +08:00
snaily
e104a50cf4 Merge pull request #347 from bbbugg:Add-final-SSE-error
Fix: Gemini streaming returns a structured error instead of empty responses
2025-09-17 23:58:53 +08:00
snaily
6b9647813b Merge pull request #360 from minguncle:feat-support-aliyunoss
Feat support aliyunoss
2025-09-17 20:43:27 +08:00
wanglinjie
f863e3065b Merge remote-tracking branch 'origin/main' 2025-09-03 09:38:15 +08:00
wanglinjie
1314e0ee09 feat(upload): add support for Aliyhun OSS 2025-09-03 09:38:01 +08:00
snaily
81d92370ad Merge pull request #351 from SquirrelJimmy/main 2025-09-03 03:27:45 +08:00
SquirrelJimmy
5f6eba62cc feat: 增加配置页面的picgo 自定义url, 处理自定义picgo的返回结果 2025-09-01 11:52:12 +08:00
snaily
a8a265c2a7 chore(docker): 注释掉 adminer 服务
暂时移除 Adminer 服务,因为它目前不是必需的,并且可以减少运行的容器数量,简化本地开发环境。
2025-08-31 22:00:36 +08:00
snaily
ee21e50305 Merge pull request #303 from vickyyd:main
Add adminer for convenient mysql database management
2025-08-31 21:58:34 +08:00
snaily
611559d298 feat(image): 支持多模态模型输入base64格式图片
- 在消息转换中,增加对 `data:image/png;base64,...` 格式图片的支持,允许用户直接在输入中提供base64编码的图片。
- 调整图片处理逻辑,使其能够根据模型名称判断是否启用多模态能力,避免非多模态模型错误处理图片链接。
- 当未配置图床时,模型输出的图片将回退为base64格式,确保图片内容始终可用。
- 优化了相关函数的参数传递和代码格式,提高了代码的可读性和健壮性。
2025-08-31 21:39:12 +08:00
snaily
b0127e6fc2 Merge pull request #344 from bbbugg/base64-fallback 2025-08-31 05:37:19 +08:00
bbbugg
1d15a21ce5 Remove upload folder check for Cloudflare imgbed 2025-08-31 00:33:33 +08:00
snaily
c206aa8e4a Merge pull request #316 from ConstasJ/modify-readme
docs: 在README里添加关于端点的说明
2025-08-31 00:24:53 +08:00
SquirrelJimmy
3f040b7075 feat: 增加自定义picgo api url 2025-08-29 12:49:12 +08:00
Copilot
1771555fe9 Add final SSE error events for streaming endpoints when retries are exhausted
* Initial plan

* Add final SSE error events for all streaming services

Co-authored-by: bbbugg <80089841+bbbugg@users.noreply.github.com>

* revert openai

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bbbugg <80089841+bbbugg@users.noreply.github.com>
Co-authored-by: bbbugg <daming20120101@163.com>

Enhance error handling by extracting nested JSON from error messages in SSE events

Enhance error handling for content generation and streaming endpoints

Enhance error handling for content generation and streaming endpoints

Enhance error handling for content generation and streaming endpoints

Enhance error handling for content generation and streaming endpoints

还原vertex和openai的更改,只保留gemini
2025-08-28 22:10:32 +08:00
Copilot
8711088ebc Fix circular import issue between config, logger, and helpers modules (#2)
* Initial plan

* Fix circular import by removing top-level settings import from helpers.py

Co-authored-by: bbbugg <80089841+bbbugg@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bbbugg <80089841+bbbugg@users.noreply.github.com>
2025-08-28 16:56:04 +08:00
Copilot
bb6c629aef Implement base64 fallback for image handling when no uploader is configured (#1)
* Initial plan

* Implement base64 fallback for image handling when no uploader configured

Co-authored-by: bbbugg <80089841+bbbugg@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bbbugg <80089841+bbbugg@users.noreply.github.com>
2025-08-28 16:56:04 +08:00
snaily
4af17ce55d chore: 更新版本号至2.2.5 2025-08-18 17:27:42 +08:00
snaily
2001bfdcd9 fix(api): 统一错误日志时间戳并传递 request_datetime
- 统一 add_error_log 的 request_time:优先使用 request_datetime,
  否则使用 datetime.now(),去除 timezone.utc,避免与请求日志时区不一致
- 在 Gemini/OpenAI/Vertex/Embedding 等服务的异常处理处补充传入
  request_datetime,使错误日志与请求日志可一一对应
- stats: 移除失败记录的错误日志时间窗匹配与 error_log_id 附带,降低查询开销
  与误关联风险;建议通过统一时间戳(key + request_time)或独立错误日志
  查询接口完成关联
- 调整部分导入顺序与长行换行等代码风格,无功能改动

BREAKING CHANGE: 统计详情接口不再返回 error_log_id 字段。需要关联错误日志的
客户端请改为基于 key 与 request_time 在错误日志接口中检索。
2025-08-18 17:26:53 +08:00
snaily
669123f348 feat(ui): 支持值得注意的Key多选、全选与批量操作
为“值得注意的Key”列表新增可见复选框和全选开关,提供
批量验证/复制/删除操作,并优化选择逻辑与样式。

- attentionKeysList 每行新增可见复选框并设置 data-key
- 新增“全选”和批量操作栏,实时显示已选数量
- getSelectedKeys/updateBatchActions/toggleSelectAll
  适配 attention 根节点,且仅作用于可见项
- initializeKeySelectionListeners 增加 attention 事件绑定
- fetchAndRenderAttentionKeys 阻止按钮冒泡、绑定复选框变更,
  在加载成功/失败后刷新批量栏状态
- attention 列表不与 valid/invalid 主列表同步勾选,避免交叉影响
- CSS 仅隐藏有效/无效列表复选框,新增 attention 列表
  hover/选中态样式
- 增强空值判断,避免批量栏或全选元素缺失时报错
2025-08-18 16:31:48 +08:00
ConstasJ
d06e418a61 docs: 修改README.md,添加关于端点的说明 2025-08-18 15:42:46 +08:00
snaily
fa6745454e chore: 更新版本号至2.2.4 2025-08-18 09:11:49 +08:00
snaily
1aa3d267bb feat(api,ui): 新增24h错误码最高Key统计与面板
- 新增 GET /api/stats/attention-keys 接口,统计最近24小时指定
  状态码(默认429)错误次数最多的 Key,仅统计内存中的 Key,
  支持 limit 与 status_code 参数
- StatsService 新增 get_attention_keys_last_24h,按 api_key 分组计数并
  降序返回
- UI 新增“值得注意的Key”卡片:支持 429/403/400 快捷切换、自定义状态码
  与数量限制,默认展示 429 前 10
- 列表项支持验证、查看 24h 详情、复制、删除等快捷操作
- 将 Chart.js 与页面脚本改为 defer,保证 DOM 就绪与执行顺序
- 修复:补充获取数量输入框引用,避免初始化未声明变量报错
- 其他:微调日志输出格式
2025-08-18 06:28:48 +08:00
snaily
e9601ca76c feat(api,ui): 新增按Key调用详情与错误日志查找并联动前端
引入按密钥维度的请求详情及错误日志关联,新增错误日志精确
查找接口,并扩展统计时间维度,提升故障定位与可观测性。

- 新增 /api/logs/errors/lookup 接口:支持按 gemini_key / timestamp /
  status_code 与时间窗口查找最接近的错误日志;ErrorLogDetailResponse
  增加 error_code 字段
- Stats 接口增强:get_api_call_details 返回 status_code、latency_ms,
  并在失败时尝试匹配 error_log_id;新增 /api/stats/key-details 获取指
  定密钥调用详情;新增 8h 时间段
- DB 层:add_error_log 支持传入 request_datetime(默认使用 UTC);新增
  find_error_log_by_info 封装按 key/时间窗口/状态码的查询
- 前端 keys_status:趋势图支持 8 小时区间;调用详情表新增状态码/耗时与
  失败详情按钮;可按 key 查看期内调用详情并查看匹配错误日志;优化统计
  摘要展示与模态层级(z-index)
- OpenAIChatService:错误记录携带请求时间;改进日志与健壮性处理
2025-08-18 05:19:29 +08:00
snaily
01312317a1 feat(ui): 添加 API 调用趋势图及时间区间切换
- 在 keys_status 页面引入 Chart.js(CDN),新增“调用趋势图”卡片
- 支持 1分钟/1小时/24小时切换,默认展示 1小时
- 前端从 /api/stats/details?period= 拉取数据,按时间桶聚合成功/失败并绘制
- 调整样式与布局:图表卡片跨列显示,固定容器高度并适配小屏
- 便于可视化监控调用成功/失败趋势,辅助排障与容量评估
2025-08-18 03:50:52 +08:00
snaily
7827283d0a fix(ui): 移除 keys_status 自动刷新开关及相关逻辑
移除 keys_status 页面的自动刷新开关与定时器逻辑,删除模板中的
开关控件,并移除 initializeAutoRefreshControls 函数及其调用。周
期性刷新会重置分页和搜索状态,影响使用体验;保留手动刷新按钮以
在需要时更新数据。
2025-08-18 03:19:59 +08:00
snaily
96c4b4fa50 fix: 移除API密钥分页按钮的onclick事件 2025-08-18 00:55:51 +08:00
snaily
892392742d chore: 更新版本号至2.2.3 2025-08-16 17:45:52 +08:00
snaily
380e6426ed - 添加API密钥分页显示功能,每页显示20个密钥
- 实现分页控件和搜索功能的集成
- 优化API密钥的数据处理逻辑,从DOM操作改为数组操作
- 修改登录成功后重定向路径从/config改为/keys
- 重构routes.py的import语句,按字母顺序排列
- 改进代码格式和缩进风格
2025-08-16 17:42:16 +08:00
snaily
d2906d89a6 style(router): 优化错误日志路由代码格式
- 移除多余的空白行
- 简化删除所有错误日志的日志记录逻辑
- 统一代码缩进和空行格式
2025-08-16 03:43:36 +08:00
snaily
13e1db7d69 style(database,static): 优化代码格式并本地化静态资源
- 重新组织 database/services.py 的导入语句,按照标准顺序排列
- 统一代码格式,包括函数参数对齐和尾随逗号
- 优化 delete_all_error_logs 函数,移除不必要的计数查询以提高性能
- 添加本地字体文件 fonts.css,包含 Inter 字体的多种字重和语言支持
- 本地化 Tailwind CSS 脚本,减少外部依赖
- 更新 base.html 模板以使用本地静态资源
2025-08-16 03:41:42 +08:00
snaily
40c9689eae Merge pull request #249 from sanjusss:fix_248
fix #248
2025-08-16 03:08:16 +08:00
snaily
548dcccf2f Merge pull request #286 from 4Crusaders:fix/gemini-structured-output-tools-conflict
fix: 修复Gemini模型不支持同时使用tools和结构化输出的问题
2025-08-16 01:13:18 +08:00
snaily
b52092a72b Merge pull request #300 from zenyanbo/main 2025-08-16 01:06:18 +08:00
snaily
67efd067c6 Merge pull request #270 from cxyfer/feature/gemini-embed-endpoints 2025-08-16 00:40:18 +08:00
kikii16
fd39c2c9cb Add adminer for convenient mysql database management 2025-08-13 02:21:23 +08:00
zenyanbo
f58ae2b340 feat: add support for the n parameter in OpenAI-compatible requests. Now, when you make a request to the /v1/chat/completions endpoint with the n parameter, it will be correctly mapped to candidateCount in the Gemini API request, allowing you to receive multiple completions. 2025-08-11 17:39:18 +08:00
4Crusaders
f51a4d20ad fix: 修复Gemini模型不支持同时使用tools和结构化输出的问题
- 添加_is_structured_output_request函数检测是否为结构化JSON输出请求
- 当检测到请求指定responseMimeType为application/json时,跳过gemini-balance主动添加的所有工具
- 仅在非结构化输出场景下,gemini-balance才会自动添加codeExecution、googleSearch、urlContext等工具
- 解决"Tool use with a response mime type: 'application/json' is unsupported"错误

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 14:07:33 +08:00
cxyfer
b89d3ea144 feat: Add Gemini API embeddings compatibility with embedContent and batchEmbedContents methods 2025-07-30 02:28:53 +08:00
sanjusss
3d6b5063d5 fix #248
修复部分情况下,假流式无法发出空白回复的问题。简化空白回复的发送逻辑。
2025-07-25 19:44:01 +08:00
45 changed files with 4305 additions and 1333 deletions

View File

@@ -14,11 +14,11 @@ AUTH_TOKEN=sk-123456
VERTEX_API_KEYS=["AQ.Abxxxxxxxxxxxxxxxxxxx"]
# For Vertex AI Platform Express API Base URL
VERTEX_EXPRESS_BASE_URL=https://aiplatform.googleapis.com/v1beta1/publishers/google
TEST_MODEL=gemini-1.5-flash
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
IMAGE_MODELS=["gemini-2.0-flash-exp"]
SEARCH_MODELS=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
TEST_MODEL=gemini-2.5-flash-lite
THINKING_MODELS=["gemini-2.5-flash","gemini-2.5-pro"]
THINKING_BUDGET_MAP={"gemini-2.5-flash": -1}
IMAGE_MODELS=["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]
SEARCH_MODELS=["gemini-2.5-flash","gemini-2.5-pro"]
FILTERED_MODELS=["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"]
# 是否启用网址上下文,默认启用
URL_CONTEXT_ENABLED=false
@@ -44,9 +44,17 @@ CREATE_IMAGE_MODEL=imagen-3.0-generate-002
UPLOAD_PROVIDER=smms
SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PICGO_API_KEY=xxxx
PICGO_API_URL=https://www.picgo.net/api/1/upload
CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
CLOUDFLARE_IMGBED_UPLOAD_FOLDER=
# 阿里云OSS配置
OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
OSS_ENDPOINT_INNER=oss-cn-shanghai-internal.aliyuncs.com
OSS_ACCESS_KEY=LTAI5txxxxxxxxxxxxxxxx
OSS_ACCESS_KEY_SECRET=yXxxxxxxxxxxxxxxxxxxxxx
OSS_BUCKET_NAME=your-bucket-name
OSS_REGION=cn-shanghai
##########################################################################
#########################stream_optimizer 相关配置########################
STREAM_OPTIMIZER_ENABLED=false
@@ -59,6 +67,8 @@ STREAM_CHUNK_SIZE=5
######################### 日志配置 #######################################
# 日志级别 (debug, info, warning, error, critical),默认为 info
LOG_LEVEL=info
# 是否记录错误日志的请求体(可能包含敏感信息),默认 false
ERROR_LOG_RECORD_REQUEST_BODY=false
# 是否开启自动删除错误日志
AUTO_DELETE_ERROR_LOGS_ENABLED=true
# 自动删除多少天前的错误日志 (1, 7, 30)

View File

@@ -8,6 +8,9 @@ COPY ./VERSION /app
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /app/app
ENV API_KEYS='["your_api_key_1"]'
ENV ALLOWED_TOKENS='["your_token_1"]'
ENV TZ='Asia/Shanghai'
# Expose port
EXPOSE 8000

View File

@@ -137,6 +137,8 @@ app/
### Gemini API Format (`/gemini/v1beta`)
This endpoint is directly forwarded to official Gemini API format endpoint, without advanced features.
* `GET /models`: List available Gemini models.
* `POST /models/{model_name}:generateContent`: Generate content.
* `POST /models/{model_name}:streamGenerateContent`: Stream content generation.
@@ -145,6 +147,8 @@ app/
#### Hugging Face (HF) Compatible
If you want to use advanced features, like fake streaming, please use this endpoint.
* `GET /hf/v1/models`: List models.
* `POST /hf/v1/chat/completions`: Chat completion.
* `POST /hf/v1/embeddings`: Create text embeddings.
@@ -152,6 +156,8 @@ app/
#### Standard OpenAI
This endpoint is directly forwarded to official OpenAI Compatible API format endpoint, without advanced features.
* `GET /openai/v1/models`: List models.
* `POST /openai/v1/chat/completions`: Chat completion (Recommended).
* `POST /openai/v1/embeddings`: Create text embeddings.
@@ -178,9 +184,9 @@ app/
| `ALLOWED_TOKENS` | **Required**, list of access tokens | `[]` |
| `AUTH_TOKEN` | Super admin token, defaults to the first of `ALLOWED_TOKENS` | `sk-123456` |
| `ADMIN_SESSION_EXPIRE` | Admin session expiration time in seconds (5 minutes to 24 hours) | `3600` |
| `TEST_MODEL` | Model for testing key validity | `gemini-1.5-flash` |
| `IMAGE_MODELS` | Models supporting image generation | `["gemini-2.0-flash-exp"]` |
| `SEARCH_MODELS` | Models supporting web search | `["gemini-2.0-flash-exp"]` |
| `TEST_MODEL` | Model for testing key validity | `gemini-2.5-flash-lite` |
| `IMAGE_MODELS` | Models supporting image generation | `["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]` |
| `SEARCH_MODELS` | Models supporting web search | `["gemini-2.5-flash","gemini-2.5-pro"]` |
| `FILTERED_MODELS` | Disabled models | `[]` |
| `TOOLS_CODE_EXECUTION_ENABLED` | Enable code execution tool | `false` |
| `SHOW_SEARCH_LINK` | Display search result links in response | `true` |
@@ -199,6 +205,7 @@ app/
| `PROXIES` | List of proxy servers | `[]` |
| **Logging & Security** | | |
| `LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
| `ERROR_LOG_RECORD_REQUEST_BODY` | Record request body in error logs (may contain sensitive information) | `false` |
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | Auto-delete error logs | `true` |
| `AUTO_DELETE_ERROR_LOGS_DAYS` | Error log retention period (days) | `7` |
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| Auto-delete request logs | `false` |
@@ -211,9 +218,16 @@ app/
| **Image Generation** | | |
| `PAID_KEY` | Paid API Key for advanced features | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | Image generation model | `imagen-3.0-generate-002` |
| `UPLOAD_PROVIDER` | Image upload provider: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
| `UPLOAD_PROVIDER` | Image upload provider: `smms`, `picgo`, `cloudflare_imgbed`, `aliyun_oss` | `smms` |
| `OSS_ENDPOINT` | Aliyun OSS public endpoint | `oss-cn-shanghai.aliyuncs.com` |
| `OSS_ENDPOINT_INNER` | Aliyun OSS internal endpoint (intra-VPC) | `oss-cn-shanghai-internal.aliyuncs.com` |
| `OSS_ACCESS_KEY` | Aliyun AccessKey ID | `LTAI5txxxxxxxxxxxxxxxx` |
| `OSS_ACCESS_KEY_SECRET` | Aliyun AccessKey Secret | `yXxxxxxxxxxxxxxxxxxxxxx` |
| `OSS_BUCKET_NAME` | Aliyun OSS bucket name | `your-bucket-name` |
| `OSS_REGION` | Aliyun OSS region | `cn-shanghai` |
| `SMMS_SECRET_TOKEN` | SM.MS API Token | `your-smms-token` |
| `PICGO_API_KEY` | PicoGo API Key | `your-picogo-apikey` |
| `PICGO_API_URL` | PicoGo API Server URL | `https://www.picgo.net/api/1/upload` |
| `CLOUDFLARE_IMGBED_URL` | CloudFlare ImgBed upload URL | `https://xxxxxxx.pages.dev/upload` |
| `CLOUDFLARE_IMGBED_AUTH_CODE`| CloudFlare ImgBed auth key | `your-cloudflare-imgber-auth-code` |
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| CloudFlare ImgBed upload folder | `""` |

View File

@@ -138,6 +138,8 @@ app/
### Gemini API 格式 (`/gemini/v1beta`)
此端点将请求直接转发到官方 Gemini API 格式的端点,不包含高级功能。
* `GET /models`: 列出可用的 Gemini 模型。
* `POST /models/{model_name}:generateContent`: 生成内容。
* `POST /models/{model_name}:streamGenerateContent`: 流式生成内容。
@@ -146,6 +148,8 @@ app/
#### 兼容 huggingface (HF) 格式
如果您需要使用高级功能(例如假流式输出),请使用此端点。
* `GET /hf/v1/models`: 列出模型。
* `POST /hf/v1/chat/completions`: 聊天补全。
* `POST /hf/v1/embeddings`: 创建文本嵌入。
@@ -153,6 +157,8 @@ app/
#### 标准 OpenAI 格式
此端点直接转发至官方的 OpenAI 兼容 API 格式端点,不包含高级功能。
* `GET /openai/v1/models`: 列出模型。
* `POST /openai/v1/chat/completions`: 聊天补全 (推荐,速度更快,防截断)。
* `POST /openai/v1/embeddings`: 创建文本嵌入。
@@ -178,9 +184,9 @@ app/
| `API_KEYS` | **必填**, Gemini API 密钥列表,用于负载均衡 | `[]` |
| `ALLOWED_TOKENS` | **必填**, 允许访问的 Token 列表 | `[]` |
| `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"]` |
| `TEST_MODEL` | 用于测试密钥可用性的模型 | `gemini-2.5-flash-lite` |
| `IMAGE_MODELS` | 支持绘图功能的模型列表 | `["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]` |
| `SEARCH_MODELS` | 支持搜索功能的模型列表 | `["gemini-2.5-flash","gemini-2.5-pro"]` |
| `FILTERED_MODELS` | 被禁用的模型列表 | `[]` |
| `TOOLS_CODE_EXECUTION_ENABLED` | 是否启用代码执行工具 | `false` |
| `SHOW_SEARCH_LINK` | 是否在响应中显示搜索结果链接 | `true` |
@@ -199,6 +205,7 @@ app/
| `PROXIES` | 代理服务器列表 (例如 `http://user:pass@host:port`) | `[]` |
| **日志与安全** | | |
| `LOG_LEVEL` | 日志级别: `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
| `ERROR_LOG_RECORD_REQUEST_BODY` | 是否记录错误日志的请求体(可能包含敏感信息) | `false` |
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | 是否自动删除错误日志 | `true` |
| `AUTO_DELETE_ERROR_LOGS_DAYS` | 错误日志保留天数 | `7` |
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 是否自动删除请求日志 | `false` |
@@ -211,9 +218,16 @@ app/
| **图像生成相关** | | |
| `PAID_KEY` | 付费版API Key用于图片生成等高级功能 | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | 图片生成模型 | `imagen-3.0-generate-002` |
| `UPLOAD_PROVIDER` | 图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
| `UPLOAD_PROVIDER` | 图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed`, `aliyun_oss` | `smms` |
| `OSS_ENDPOINT` | 阿里云 OSS 公网 Endpoint | `oss-cn-shanghai.aliyuncs.com` |
| `OSS_ENDPOINT_INNER` | 阿里云 OSS 内网 Endpoint同 VPC 内网访问) | `oss-cn-shanghai-internal.aliyuncs.com` |
| `OSS_ACCESS_KEY` | 阿里云 AccessKey ID | `LTAI5txxxxxxxxxxxxxxxx` |
| `OSS_ACCESS_KEY_SECRET` | 阿里云 AccessKey Secret | `yXxxxxxxxxxxxxxxxxxxxxx` |
| `OSS_BUCKET_NAME` | 阿里云 OSS Bucket 名称 | `your-bucket-name` |
| `OSS_REGION` | 阿里云 OSS 区域 Region | `cn-shanghai` |
| `SMMS_SECRET_TOKEN` | SM.MS图床的API Token | `your-smms-token` |
| `PICGO_API_KEY` | [PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
| `PICGO_API_URL` | [PicoGo](https://www.picgo.net/)图床的API服务器地址 | `https://www.picgo.net/api/1/upload` |
| `CLOUDFLARE_IMGBED_URL` | [CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
| `CLOUDFLARE_IMGBED_AUTH_CODE`| CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| CloudFlare图床的上传文件夹路径 | `""` |

View File

@@ -1 +1 @@
2.2.2
2.2.6

View File

@@ -6,7 +6,7 @@ import datetime
import json
from typing import Any, Dict, List, Type, get_args, get_origin
from pydantic import ValidationError, ValidationInfo, field_validator, Field
from pydantic import Field, ValidationError, ValidationInfo, field_validator
from pydantic_settings import BaseSettings
from sqlalchemy import insert, select, update
@@ -51,8 +51,8 @@ class Settings(BaseSettings):
return v
# API相关配置
API_KEYS: List[str]=[]
ALLOWED_TOKENS: List[str]=[]
API_KEYS: List[str] = []
ALLOWED_TOKENS: List[str] = []
BASE_URL: str = f"https://generativelanguage.googleapis.com/{API_VERSION}"
AUTH_TOKEN: str = ""
MAX_FAILURES: int = 3
@@ -62,7 +62,9 @@ class Settings(BaseSettings):
PROXIES: List[str] = []
PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: bool = True # 是否使用一致性哈希来选择代理
VERTEX_API_KEYS: List[str] = []
VERTEX_EXPRESS_BASE_URL: str = "https://aiplatform.googleapis.com/v1beta1/publishers/google"
VERTEX_EXPRESS_BASE_URL: str = (
"https://aiplatform.googleapis.com/v1beta1/publishers/google"
)
# 智能路由配置
URL_NORMALIZATION_ENABLED: bool = False # 是否启用智能路由映射功能
@@ -71,13 +73,19 @@ class Settings(BaseSettings):
CUSTOM_HEADERS: Dict[str, str] = {}
# 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
SEARCH_MODELS: List[str] = ["gemini-2.5-flash", "gemini-2.5-pro"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]
FILTERED_MODELS: List[str] = DEFAULT_FILTER_MODELS
TOOLS_CODE_EXECUTION_ENABLED: bool = False
# 是否启用网址上下文
URL_CONTEXT_ENABLED: bool = False
URL_CONTEXT_MODELS: List[str] = ["gemini-2.5-pro","gemini-2.5-flash","gemini-2.5-flash-lite","gemini-2.0-flash","gemini-2.0-flash-live-001"]
URL_CONTEXT_MODELS: List[str] = [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.0-flash",
"gemini-2.0-flash-live-001",
]
SHOW_SEARCH_LINK: bool = True
SHOW_THINKING_PROCESS: bool = True
THINKING_MODELS: List[str] = []
@@ -94,9 +102,17 @@ class Settings(BaseSettings):
UPLOAD_PROVIDER: str = "smms"
SMMS_SECRET_TOKEN: str = ""
PICGO_API_KEY: str = ""
PICGO_API_URL: str = "https://www.picgo.net/api/1/upload"
CLOUDFLARE_IMGBED_URL: str = ""
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
CLOUDFLARE_IMGBED_UPLOAD_FOLDER: str = ""
# 阿里云OSS配置
OSS_ENDPOINT: str = ""
OSS_ENDPOINT_INNER: str = ""
OSS_ACCESS_KEY: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET_NAME: str = ""
OSS_REGION: str = ""
# 流式输出优化器配置
STREAM_OPTIMIZER_ENABLED: bool = False
@@ -120,6 +136,7 @@ class Settings(BaseSettings):
# 日志配置
LOG_LEVEL: str = "INFO"
ERROR_LOG_RECORD_REQUEST_BODY: bool = False
AUTO_DELETE_ERROR_LOGS_ENABLED: bool = True
AUTO_DELETE_ERROR_LOGS_DAYS: int = 7
AUTO_DELETE_REQUEST_LOGS_ENABLED: bool = False
@@ -136,7 +153,7 @@ class Settings(BaseSettings):
default=3600,
ge=300,
le=86400,
description="Admin session expiration time in seconds (5 minutes to 24 hours)"
description="Admin session expiration time in seconds (5 minutes to 24 hours)",
)
def __init__(self, **kwargs):
@@ -168,7 +185,9 @@ 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()]
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."
)
@@ -220,7 +239,9 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}"
)
except json.JSONDecodeError:
logger.error(f"Could not parse '{db_value}' as Dict[str, str] for key '{key}'. Returning empty dict.")
logger.error(
f"Could not parse '{db_value}' as Dict[str, str] for key '{key}'. Returning empty dict."
)
return parsed_dict
# 处理 Dict[str, float]
elif args and args == (str, float):
@@ -242,7 +263,9 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
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}"
@@ -403,9 +426,7 @@ async def sync_initial_settings():
# 序列化值为字符串或 JSON 字符串
if isinstance(value, (list, dict)):
db_value = json.dumps(
value, ensure_ascii=False
)
db_value = json.dumps(value, ensure_ascii=False)
elif isinstance(value, bool):
db_value = str(value).lower()
elif value is None:

View File

@@ -9,7 +9,7 @@ MAX_RETRIES = 3 # 最大重试次数
# 模型相关常量
SUPPORTED_ROLES = ["user", "model", "system"]
DEFAULT_MODEL = "gemini-1.5-flash"
DEFAULT_MODEL = "gemini-2.5-flash-lite"
DEFAULT_TEMPERATURE = 0.7
DEFAULT_MAX_TOKENS = 8192
DEFAULT_TOP_P = 0.9
@@ -27,7 +27,7 @@ DEFAULT_CREATE_IMAGE_MODEL = "imagen-3.0-generate-002"
VALID_IMAGE_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"]
# 上传提供商
UPLOAD_PROVIDERS = ["smms", "picgo", "cloudflare_imgbed"]
UPLOAD_PROVIDERS = ["smms", "picgo", "cloudflare_imgbed", "aliyun_oss"]
DEFAULT_UPLOAD_PROVIDER = "smms"
# 流式输出相关常量

View File

@@ -1,12 +1,16 @@
"""
数据库服务模块
"""
from typing import List, Optional, Dict, Any, Union
from datetime import datetime, timezone
from sqlalchemy import func, desc, asc, select, insert, update, delete
import asyncio
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Union
from sqlalchemy import asc, delete, desc, func, insert, select, update
from app.database.connection import database
from app.database.models import Settings, ErrorLog, RequestLog, FileRecord, FileState
from app.database.models import ErrorLog, FileRecord, FileState, RequestLog, Settings
from app.log.logger import get_database_logger
from app.utils.helpers import redact_key_for_logging
@@ -16,7 +20,7 @@ logger = get_database_logger()
async def get_all_settings() -> List[Dict[str, Any]]:
"""
获取所有设置
Returns:
List[Dict[str, Any]]: 设置列表
"""
@@ -32,10 +36,10 @@ async def get_all_settings() -> List[Dict[str, Any]]:
async def get_setting(key: str) -> Optional[Dict[str, Any]]:
"""
获取指定键的设置
Args:
key: 设置键名
Returns:
Optional[Dict[str, Any]]: 设置信息如果不存在则返回None
"""
@@ -48,22 +52,24 @@ async def get_setting(key: str) -> Optional[Dict[str, Any]]:
raise
async def update_setting(key: str, value: str, description: Optional[str] = None) -> bool:
async def update_setting(
key: str, value: str, description: Optional[str] = None
) -> bool:
"""
更新设置
Args:
key: 设置键名
value: 设置值
description: 设置描述
Returns:
bool: 是否更新成功
"""
try:
# 检查设置是否存在
setting = await get_setting(key)
if setting:
# 更新设置
query = (
@@ -72,7 +78,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
.values(
value=value,
description=description if description else setting["description"],
updated_at=datetime.now()
updated_at=datetime.now(),
)
)
await database.execute(query)
@@ -80,15 +86,12 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
return True
else:
# 插入设置
query = (
insert(Settings)
.values(
key=key,
value=value,
description=description,
created_at=datetime.now(),
updated_at=datetime.now()
)
query = insert(Settings).values(
key=key,
value=value,
description=description,
created_at=datetime.now(),
updated_at=datetime.now(),
)
await database.execute(query)
logger.info(f"Inserted setting: {key}")
@@ -104,44 +107,45 @@ async def add_error_log(
error_type: Optional[str] = None,
error_log: Optional[str] = None,
error_code: Optional[int] = None,
request_msg: Optional[Union[Dict[str, Any], str]] = None
request_msg: Optional[Union[Dict[str, Any], str]] = None,
request_datetime: Optional[datetime] = None,
) -> bool:
"""
添加错误日志
Args:
gemini_key: Gemini API密钥
error_log: 错误日志
error_code: 错误代码 (例如 HTTP 状态码)
request_msg: 请求消息
Returns:
bool: 是否添加成功
"""
try:
# 如果request_msg是字典则转换为JSON字符串
if isinstance(request_msg, dict):
request_msg_json = request_msg
elif isinstance(request_msg, str):
try:
request_msg_json = json.loads(request_msg)
except json.JSONDecodeError:
request_msg_json = {"message": request_msg}
else:
if request_msg is None:
request_msg_json = None
else:
# 如果request_msg是字典则转换为JSON字符串
if isinstance(request_msg, dict):
request_msg_json = request_msg
elif isinstance(request_msg, str):
try:
request_msg_json = json.loads(request_msg)
except json.JSONDecodeError:
request_msg_json = {"message": request_msg}
else:
request_msg_json = None
# 插入错误日志
query = (
insert(ErrorLog)
.values(
gemini_key=gemini_key,
error_type=error_type,
error_log=error_log,
model_name=model_name,
error_code=error_code,
request_msg=request_msg_json,
request_time=datetime.now()
)
query = insert(ErrorLog).values(
gemini_key=gemini_key,
error_type=error_type,
error_log=error_log,
model_name=model_name,
error_code=error_code,
request_msg=request_msg_json,
request_time=(request_datetime if request_datetime else datetime.now()),
)
await database.execute(query)
logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}")
@@ -159,8 +163,8 @@ async def get_error_logs(
error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
sort_by: str = 'id',
sort_order: str = 'desc'
sort_by: str = "id",
sort_order: str = "desc",
) -> List[Dict[str, Any]]:
"""
获取错误日志,支持搜索、日期过滤和排序
@@ -187,15 +191,15 @@ async def get_error_logs(
ErrorLog.error_type,
ErrorLog.error_log,
ErrorLog.error_code,
ErrorLog.request_time
ErrorLog.request_time,
)
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
query = query.where(
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
(ErrorLog.error_log.ilike(f"%{error_search}%"))
(ErrorLog.error_type.ilike(f"%{error_search}%"))
| (ErrorLog.error_log.ilike(f"%{error_search}%"))
)
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
@@ -206,10 +210,12 @@ async def get_error_logs(
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
logger.warning(
f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter."
)
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id)
if sort_order.lower() == 'asc':
if sort_order.lower() == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
@@ -228,7 +234,7 @@ async def get_error_logs_count(
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,
) -> int:
"""
获取符合条件的错误日志总数
@@ -250,8 +256,8 @@ async def get_error_logs_count(
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
query = query.where(
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
(ErrorLog.error_log.ilike(f"%{error_search}%"))
(ErrorLog.error_type.ilike(f"%{error_search}%"))
| (ErrorLog.error_log.ilike(f"%{error_search}%"))
)
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
@@ -262,8 +268,9 @@ async def get_error_logs_count(
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
logger.warning(
f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter."
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
@@ -289,12 +296,14 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
if result:
# 将 request_msg (JSONB) 转换为字符串以便在 API 中返回
log_dict = dict(result)
if 'request_msg' in log_dict and log_dict['request_msg'] is not None:
if "request_msg" in log_dict and log_dict["request_msg"] is not None:
# 确保即使是 None 或非 JSON 数据也能处理
try:
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
log_dict["request_msg"] = json.dumps(
log_dict["request_msg"], ensure_ascii=False, indent=2
)
except TypeError:
log_dict['request_msg'] = str(log_dict['request_msg'])
log_dict["request_msg"] = str(log_dict["request_msg"])
return log_dict
else:
return None
@@ -303,6 +312,78 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
raise
# 新增函数:通过 gemini_key / error_code / 时间窗口 查找最接近的错误日志
async def find_error_log_by_info(
gemini_key: str,
timestamp: datetime,
status_code: Optional[int] = None,
window_seconds: int = 1,
) -> Optional[Dict[str, Any]]:
"""
在给定时间窗口内,根据 gemini_key精确匹配及可选的 status_code 查找最接近 timestamp 的错误日志。
假设错误日志的 error_code 存储的是 HTTP 状态码或等价错误码。
Args:
gemini_key: 完整的 Gemini key 字符串。
timestamp: 目标时间UTC 或本地,与存储一致)。
status_code: 可选的错误码,若提供则优先匹配该错误码。
window_seconds: 允许的时间偏差窗口,单位秒,默认为 1 秒。
Returns:
Optional[Dict[str, Any]]: 最匹配的一条错误日志的完整详情(字段与 get_error_log_details 一致),若未找到则返回 None。
"""
try:
start_time = timestamp - timedelta(seconds=window_seconds)
end_time = timestamp + timedelta(seconds=window_seconds)
base_query = select(ErrorLog).where(
ErrorLog.gemini_key == gemini_key,
ErrorLog.request_time >= start_time,
ErrorLog.request_time <= end_time,
)
# 若提供了状态码,先尝试按状态码过滤
if status_code is not None:
query = base_query.where(ErrorLog.error_code == status_code).order_by(
ErrorLog.request_time.desc()
)
candidates = await database.fetch_all(query)
if not candidates:
# 回退:不按状态码,仅按时间窗口
query2 = base_query.order_by(ErrorLog.request_time.desc())
candidates = await database.fetch_all(query2)
else:
query = base_query.order_by(ErrorLog.request_time.desc())
candidates = await database.fetch_all(query)
if not candidates:
return None
# 在 Python 中选择与 timestamp 最接近的一条
def _to_dict(row: Any) -> Dict[str, Any]:
d = dict(row)
if "request_msg" in d and d["request_msg"] is not None:
try:
d["request_msg"] = json.dumps(
d["request_msg"], ensure_ascii=False, indent=2
)
except TypeError:
d["request_msg"] = str(d["request_msg"])
return d
best = min(
candidates,
key=lambda r: abs((r["request_time"] - timestamp).total_seconds()),
)
return _to_dict(best)
except Exception as e:
logger.exception(
f"Failed to find error log by info (key=***{gemini_key[-4:] if gemini_key else ''}, code={status_code}, ts={timestamp}, window={window_seconds}s): {str(e)}"
)
raise
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
根据提供的 ID 列表批量删除错误日志 (异步)。
@@ -327,12 +408,15 @@ async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
# 注意databases 的 execute 不返回 rowcount所以我们不能直接返回删除的数量
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
return len(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)
logger.error(
f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True
)
raise
async def delete_error_log_by_id(log_id: int) -> bool:
"""
根据 ID 删除单个错误日志 (异步)。
@@ -349,7 +433,9 @@ async def delete_error_log_by_id(log_id: int) -> bool:
exists = await database.fetch_one(check_query)
if not exists:
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
logger.warning(
f"Attempted to delete non-existent error log with ID: {log_id}"
)
return False
# 执行删除
@@ -360,35 +446,57 @@ async def delete_error_log_by_id(log_id: int) -> bool:
except Exception as e:
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
raise
async def delete_all_error_logs() -> int:
"""
删除所有错误日志条目
分批删除所有错误日志,以避免大数据量下的超时和性能问题
Returns:
int: 被删除的错误日志数
int: 被删除的错误日志数。
"""
total_deleted_count = 0
# SQLite 对 SQL 参数数量有上限(常见为 999IN 子句中过多参数会报错
# 统一使用 500兼容 SQLite/MySQL必要时可在配置中暴露该值
batch_size = 200
try:
# 1. 获取删除前的总数
count_query = select(func.count()).select_from(ErrorLog)
total_to_delete = await database.fetch_val(count_query)
if total_to_delete == 0:
logger.info("No error logs found to delete.")
return 0
# 2. 执行删除操作
delete_query = delete(ErrorLog)
await database.execute(delete_query)
logger.info(f"Successfully deleted all {total_to_delete} error logs.")
return total_to_delete
while True:
# 1) 读取一批待删除的ID仅选择ID列以提升效率
id_query = select(ErrorLog.id).order_by(ErrorLog.id).limit(batch_size)
rows = await database.fetch_all(id_query)
if not rows:
break
ids = [row["id"] for row in rows]
# 2) 按ID批量删除
delete_query = delete(ErrorLog).where(ErrorLog.id.in_(ids))
await database.execute(delete_query)
deleted_in_batch = len(ids)
total_deleted_count += deleted_in_batch
logger.debug(f"Deleted a batch of {deleted_in_batch} error logs.")
# 若不足一个批次,说明已删除完成
if deleted_in_batch < batch_size:
break
# 3) 将控制权交还事件循环,缓解长时间占用
await asyncio.sleep(0)
logger.info(
f"Successfully deleted all error logs in batches. Total deleted: {total_deleted_count}"
)
return total_deleted_count
except Exception as e:
logger.error(f"Failed to delete all error logs: {str(e)}", exc_info=True)
logger.error(
f"Failed to delete all error logs in batches: {str(e)}", exc_info=True
)
raise
# 新增函数:添加请求日志
async def add_request_log(
model_name: Optional[str],
@@ -396,7 +504,7 @@ async def add_request_log(
is_success: bool,
status_code: Optional[int] = None,
latency_ms: Optional[int] = None,
request_time: Optional[datetime] = None
request_time: Optional[datetime] = None,
) -> bool:
"""
添加 API 请求日志
@@ -421,7 +529,7 @@ async def add_request_log(
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms
latency_ms=latency_ms,
)
await database.execute(query)
return True
@@ -432,6 +540,7 @@ async def add_request_log(
# ==================== 文件记录相关函数 ====================
async def create_file_record(
name: str,
mime_type: str,
@@ -445,11 +554,11 @@ async def create_file_record(
display_name: Optional[str] = None,
sha256_hash: Optional[str] = None,
upload_url: Optional[str] = None,
user_token: Optional[str] = None
user_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
创建文件记录
Args:
name: 文件名称(格式: files/{file_id}
mime_type: MIME 类型
@@ -463,7 +572,7 @@ async def create_file_record(
sha256_hash: SHA256 哈希值
upload_url: 临时上传 URL
user_token: 上传用户的 token
Returns:
Dict[str, Any]: 创建的文件记录
"""
@@ -481,10 +590,10 @@ async def create_file_record(
uri=uri,
api_key=api_key,
upload_url=upload_url,
user_token=user_token
user_token=user_token,
)
await database.execute(query)
# 返回创建的记录
return await get_file_record_by_name(name)
except Exception as e:
@@ -495,10 +604,10 @@ async def create_file_record(
async def get_file_record_by_name(name: str) -> Optional[Dict[str, Any]]:
"""
根据文件名获取文件记录
Args:
name: 文件名称(格式: files/{file_id}
Returns:
Optional[Dict[str, Any]]: 文件记录,如果不存在则返回 None
"""
@@ -511,24 +620,23 @@ async def get_file_record_by_name(name: str) -> Optional[Dict[str, Any]]:
raise
async def update_file_record_state(
file_name: str,
state: FileState,
update_time: Optional[datetime] = None,
upload_completed: Optional[datetime] = None,
sha256_hash: Optional[str] = None
sha256_hash: Optional[str] = None,
) -> bool:
"""
更新文件记录状态
Args:
file_name: 文件名
state: 新状态
update_time: 更新时间
upload_completed: 上传完成时间
sha256_hash: SHA256 哈希值
Returns:
bool: 是否更新成功
"""
@@ -540,14 +648,14 @@ async def update_file_record_state(
values["upload_completed"] = upload_completed
if sha256_hash:
values["sha256_hash"] = sha256_hash
query = update(FileRecord).where(FileRecord.name == file_name).values(**values)
result = await database.execute(query)
if result:
logger.info(f"Updated file record state for {file_name} to {state}")
return True
logger.warning(f"File record not found for update: {file_name}")
return False
except Exception as e:
@@ -559,31 +667,33 @@ async def list_file_records(
user_token: Optional[str] = None,
api_key: Optional[str] = None,
page_size: int = 10,
page_token: Optional[str] = None
page_token: Optional[str] = None,
) -> tuple[List[Dict[str, Any]], Optional[str]]:
"""
列出文件记录
Args:
user_token: 用户 token如果提供只返回该用户的文件
api_key: API Key如果提供只返回使用该 key 的文件)
page_size: 每页大小
page_token: 分页标记(偏移量)
Returns:
tuple[List[Dict[str, Any]], Optional[str]]: (文件列表, 下一页标记)
"""
try:
logger.debug(f"list_file_records called with page_size={page_size}, page_token={page_token}")
logger.debug(
f"list_file_records called with page_size={page_size}, page_token={page_token}"
)
query = select(FileRecord).where(
FileRecord.expiration_time > datetime.now(timezone.utc)
)
if user_token:
query = query.where(FileRecord.user_token == user_token)
if api_key:
query = query.where(FileRecord.api_key == api_key)
# 使用偏移量进行分页
offset = 0
if page_token:
@@ -592,16 +702,18 @@ async def list_file_records(
except ValueError:
logger.warning(f"Invalid page token: {page_token}")
offset = 0
# 按ID升序排列使用 OFFSET 和 LIMIT
query = query.order_by(FileRecord.id).offset(offset).limit(page_size + 1)
results = await database.fetch_all(query)
logger.debug(f"Query returned {len(results)} records")
if results:
logger.debug(f"First record ID: {results[0]['id']}, Last record ID: {results[-1]['id']}")
logger.debug(
f"First record ID: {results[0]['id']}, Last record ID: {results[-1]['id']}"
)
# 处理分页
has_next = len(results) > page_size
if has_next:
@@ -609,11 +721,13 @@ async def list_file_records(
# 下一页的偏移量是当前偏移量加上本页返回的记录数
next_offset = offset + page_size
next_page_token = str(next_offset)
logger.debug(f"Has next page, offset={offset}, page_size={page_size}, next_page_token={next_page_token}")
logger.debug(
f"Has next page, offset={offset}, page_size={page_size}, next_page_token={next_page_token}"
)
else:
next_page_token = None
logger.debug(f"No next page, returning {len(results)} results")
return [dict(row) for row in results], next_page_token
except Exception as e:
logger.error(f"Failed to list file records: {str(e)}")
@@ -623,10 +737,10 @@ async def list_file_records(
async def delete_file_record(name: str) -> bool:
"""
删除文件记录
Args:
name: 文件名称
Returns:
bool: 是否删除成功
"""
@@ -642,7 +756,7 @@ async def delete_file_record(name: str) -> bool:
async def delete_expired_file_records() -> List[Dict[str, Any]]:
"""
删除已过期的文件记录
Returns:
List[Dict[str, Any]]: 删除的记录列表
"""
@@ -652,16 +766,16 @@ async def delete_expired_file_records() -> List[Dict[str, Any]]:
FileRecord.expiration_time <= datetime.now(timezone.utc)
)
expired_records = await database.fetch_all(query)
if not expired_records:
return []
# 执行删除
delete_query = delete(FileRecord).where(
FileRecord.expiration_time <= datetime.now(timezone.utc)
)
await database.execute(delete_query)
logger.info(f"Deleted {len(expired_records)} expired file records")
return [dict(record) for record in expired_records]
except Exception as e:
@@ -672,17 +786,17 @@ async def delete_expired_file_records() -> List[Dict[str, Any]]:
async def get_file_api_key(name: str) -> Optional[str]:
"""
获取文件对应的 API Key
Args:
name: 文件名称
Returns:
Optional[str]: API Key如果文件不存在或已过期则返回 None
"""
try:
query = select(FileRecord.api_key).where(
(FileRecord.name == name) &
(FileRecord.expiration_time > datetime.now(timezone.utc))
(FileRecord.name == name)
& (FileRecord.expiration_time > datetime.now(timezone.utc))
)
result = await database.fetch_one(query)
return result["api_key"] if result else None

View File

@@ -80,3 +80,36 @@ class ResetSelectedKeysRequest(BaseModel):
class VerifySelectedKeysRequest(BaseModel):
keys: List[str]
class GeminiEmbedContent(BaseModel):
"""嵌入内容模型"""
parts: List[Dict[str, str]]
class GeminiEmbedRequest(BaseModel):
"""单一嵌入请求模型"""
content: GeminiEmbedContent
taskType: Optional[
Literal[
"TASK_TYPE_UNSPECIFIED",
"RETRIEVAL_QUERY",
"RETRIEVAL_DOCUMENT",
"SEMANTIC_SIMILARITY",
"CLASSIFICATION",
"CLUSTERING",
"QUESTION_ANSWERING",
"FACT_VERIFICATION",
"CODE_RETRIEVAL_QUERY",
]
] = None
title: Optional[str] = None
outputDimensionality: Optional[int] = None
class GeminiBatchEmbedRequest(BaseModel):
"""批量嵌入请求模型"""
requests: List[GeminiEmbedRequest]

View File

@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
max_tokens: Optional[int] = None
top_p: Optional[float] = DEFAULT_TOP_P
top_k: Optional[int] = DEFAULT_TOP_K
n: Optional[int] = 1
stop: Optional[Union[List[str],str]] = None
reasoning_effort: Optional[str] = None
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []

View File

@@ -131,10 +131,5 @@ def setup_exception_handlers(app: FastAPI) -> None:
logger.exception(f"Unhandled Exception: {str(exc)}")
return JSONResponse(
status_code=500,
content={
"error": {
"code": "internal_server_error",
"message": "An unexpected error occurred",
}
},
content=str(exc),
)

View File

@@ -27,7 +27,7 @@ class MessageConverter(ABC):
@abstractmethod
def convert(
self, messages: List[Dict[str, Any]]
self, messages: List[Dict[str, Any]], model: str
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
pass
@@ -84,7 +84,7 @@ def _convert_image_to_base64(url: str) -> str:
raise Exception(f"Failed to fetch image: {response.status_code}")
def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
def _process_text_with_image(text: str, model: str) -> List[Dict[str, Any]]:
"""
处理可能包含图片URL的文本提取图片并转换为base64
@@ -94,17 +94,31 @@ def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
Returns:
List[Dict[str, Any]]: 包含文本和图片的部分列表
"""
# 如果模型名中没有包含image当作普通文本处理
if "image" not in model:
return [{"text": text}]
parts = []
img_url_match = re.search(IMAGE_URL_PATTERN, text)
if img_url_match:
# 提取URL
img_url = img_url_match.group(2)
# 将URL对应的图片转换为base64
# 先判断是否是base64url如果是直接用不过不是将URL对应的图片转换为base64
try:
base64_data = _convert_image_to_base64(img_url)
parts.append(
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
)
base64_url_match = re.search(DATA_URL_PATTERN, img_url)
if base64_url_match:
parts.append(
{
"inline_data": {
"mimeType": base64_url_match.group(1),
"data": base64_url_match.group(2),
}
}
)
else:
base64_data = _convert_image_to_base64(img_url)
parts.append(
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
)
except Exception:
# 如果转换失败,回退到文本模式
parts.append({"text": text})
@@ -145,7 +159,7 @@ class OpenAIMessageConverter(MessageConverter):
raise
def convert(
self, messages: List[Dict[str, Any]]
self, messages: List[Dict[str, Any]], model: str
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
converted_messages = []
system_instruction_parts = []
@@ -296,7 +310,7 @@ class OpenAIMessageConverter(MessageConverter):
elif (
"content" in msg and isinstance(msg["content"], str) and msg["content"]
):
parts.extend(_process_text_with_image(msg["content"]))
parts.extend(_process_text_with_image(msg["content"], model))
elif "tool_calls" in msg and isinstance(msg["tool_calls"], list):
# Keep existing tool call processing
for tool_call in msg["tool_calls"]:

View File

@@ -8,8 +8,9 @@ from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from app.config.config import settings
from app.utils.uploader import ImageUploaderFactory
from app.log.logger import get_openai_logger
from app.utils.helpers import is_image_upload_configured
from app.utils.uploader import ImageUploaderFactory
logger = get_openai_logger()
@@ -32,7 +33,11 @@ class GeminiResponseHandler(ResponseHandler):
self.thinking_status = False
def handle_response(
self, response: Dict[str, Any], model: str, stream: bool = False, usage_metadata: Optional[Dict[str, Any]] = None
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)
@@ -40,53 +45,86 @@ class GeminiResponseHandler(ResponseHandler):
def _handle_openai_stream_response(
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
response: Dict[str, Any],
model: str,
finish_reason: str,
usage_metadata: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
text, reasoning_content, tool_calls, _ = _extract_result(
response, model, stream=True, gemini_format=False
)
if not text and not tool_calls and not reasoning_content:
delta = {}
else:
delta = {"content": text, "reasoning_content": reasoning_content, "role": "assistant"}
if tool_calls:
delta["tool_calls"] = tool_calls
choices = []
candidates = response.get("candidates", [])
for candidate in candidates:
index = candidate.get("index", 0)
text, reasoning_content, tool_calls, _ = _extract_result(
{"candidates": [candidate]}, model, stream=True, gemini_format=False
)
if not text and not tool_calls and not reasoning_content:
delta = {}
else:
delta = {
"content": text,
"reasoning_content": reasoning_content,
"role": "assistant",
}
if tool_calls:
delta["tool_calls"] = tool_calls
choice = {"index": index, "delta": delta, "finish_reason": finish_reason}
choices.append(choice)
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}],
"choices": choices,
}
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)}
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, usage_metadata: Optional[Dict[str, Any]]
response: Dict[str, Any],
model: str,
finish_reason: str,
usage_metadata: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
text, reasoning_content, tool_calls, _ = _extract_result(
response, model, stream=False, gemini_format=False
)
choices = []
candidates = response.get("candidates", [])
for i, candidate in enumerate(candidates):
text, reasoning_content, tool_calls, _ = _extract_result(
{"candidates": [candidate]}, model, stream=False, gemini_format=False
)
choice = {
"index": i,
"message": {
"role": "assistant",
"content": text,
"reasoning_content": reasoning_content,
"tool_calls": tool_calls,
},
"finish_reason": finish_reason,
}
choices.append(choice)
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": text,
"reasoning_content": reasoning_content,
"tool_calls": tool_calls,
},
"finish_reason": finish_reason,
}
],
"usage": {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)},
"choices": choices,
"usage": {
"prompt_tokens": usage_metadata.get("promptTokenCount", 0),
"completion_tokens": usage_metadata.get("candidatesTokenCount", 0),
"total_tokens": usage_metadata.get("totalTokenCount", 0),
},
}
@@ -107,8 +145,12 @@ class OpenAIResponseHandler(ResponseHandler):
usage_metadata: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if stream:
return _handle_openai_stream_response(response, model, finish_reason, usage_metadata)
return _handle_openai_normal_response(response, model, finish_reason, usage_metadata)
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"
@@ -162,7 +204,7 @@ def _extract_result(
gemini_format: bool = False,
) -> tuple[str, Optional[str], List[Dict[str, Any]], Optional[bool]]:
text, reasoning_content, tool_calls, thought = "", "", [], None
if stream:
if response.get("candidates"):
candidate = response["candidates"][0]
@@ -171,7 +213,7 @@ def _extract_result(
if not parts:
logger.warning("No parts found in stream response")
return "", None, [], None
if "text" in parts[0]:
text = parts[0].get("text")
if "thought" in parts[0]:
@@ -197,13 +239,13 @@ def _extract_result(
if response.get("candidates"):
candidate = response["candidates"][0]
text, reasoning_content = "", ""
# 使用安全的访问方式
content = candidate.get("content", {})
if content and isinstance(content, dict):
parts = content.get("parts", [])
if parts:
for part in parts:
if "text" in part:
@@ -221,17 +263,28 @@ def _extract_result(
logger.error(f"Invalid content structure for model: {model}")
text = _add_search_link_text(model, candidate, text)
# 安全地获取 parts 用于工具调用提取
parts = candidate.get("content", {}).get("parts", [])
tool_calls = _extract_tool_calls(parts, gemini_format)
else:
logger.warning(f"No candidates found in response for model: {model}")
text = "暂无返回"
return text, reasoning_content, tool_calls, thought
def _has_inline_image_part(response: Dict[str, Any]) -> bool:
try:
for c in response.get("candidates", []):
for p in c.get("content", {}).get("parts", []):
if isinstance(p, dict) and ("inlineData" in p):
return True
except Exception:
return False
return False
def _extract_image_data(part: dict) -> str:
image_uploader = None
if settings.UPLOAD_PROVIDER == "smms":
@@ -240,7 +293,9 @@ def _extract_image_data(part: dict) -> str:
)
elif settings.UPLOAD_PROVIDER == "picgo":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER, api_key=settings.PICGO_API_KEY
provider=settings.UPLOAD_PROVIDER,
api_key=settings.PICGO_API_KEY,
api_url=settings.PICGO_API_URL
)
elif settings.UPLOAD_PROVIDER == "cloudflare_imgbed":
image_uploader = ImageUploaderFactory.create(
@@ -249,16 +304,30 @@ def _extract_image_data(part: dict) -> str:
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
)
elif settings.UPLOAD_PROVIDER == "aliyun_oss":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER,
access_key=settings.OSS_ACCESS_KEY,
access_key_secret=settings.OSS_ACCESS_KEY_SECRET,
bucket_name=settings.OSS_BUCKET_NAME,
endpoint=settings.OSS_ENDPOINT,
region=settings.OSS_REGION,
use_internal=False
)
current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
base64_data = part["inlineData"]["data"]
mime_type = part["inlineData"]["mimeType"]
# 将base64_data转成bytes数组
# Return empty string if no uploader is configured
if not is_image_upload_configured(settings):
return f"\n\n![image](data:{mime_type};base64,{base64_data})\n\n"
bytes_data = base64.b64decode(base64_data)
upload_response = image_uploader.upload(bytes_data, filename)
if upload_response.success:
text = f"\n\n![image]({upload_response.data.url})\n\n"
else:
text = ""
text = f"\n\n![image](data:{mime_type};base64,{base64_data})\n\n"
return text
@@ -271,7 +340,7 @@ def _extract_tool_calls(
letters = string.ascii_lowercase + string.digits
tool_calls = list()
for i in range(len(parts)):
part = parts[i]
if not part or not isinstance(part, dict):
@@ -280,7 +349,7 @@ def _extract_tool_calls(
item = part.get("functionCall", {})
if not item or not isinstance(item, dict):
continue
if gemini_format:
tool_calls.append(part)
else:
@@ -303,6 +372,10 @@ def _extract_tool_calls(
def _handle_gemini_stream_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
# Early return raw Gemini response if no uploader configured and contains inline images
if not is_image_upload_configured(settings) and _has_inline_image_part(response):
return response
text, reasoning_content, tool_calls, thought = _extract_result(
response, model, stream=stream, gemini_format=True
)
@@ -320,6 +393,10 @@ def _handle_gemini_stream_response(
def _handle_gemini_normal_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
# Early return raw Gemini response if no uploader configured and contains inline images
if not is_image_upload_configured(settings) and _has_inline_image_part(response):
return response
text, reasoning_content, tool_calls, thought = _extract_result(
response, model, stream=stream, gemini_format=True
)
@@ -328,7 +405,7 @@ def _handle_gemini_normal_response(
parts = tool_calls
else:
if thought is not None:
parts.append({"text": reasoning_content,"thought": thought})
parts.append({"text": reasoning_content, "thought": thought})
part = {"text": text}
parts.append(part)
content = {"parts": parts, "role": "model"}

View File

@@ -1,9 +1,8 @@
import logging
import platform
import sys
import re
import sys
from typing import Dict, Optional
from app.utils.helpers import redact_key_for_logging as _redact_key_for_logging
# ANSI转义序列颜色代码
COLORS = {
@@ -15,7 +14,6 @@ COLORS = {
}
# Windows系统启用ANSI支持
if platform.system() == "Windows":
import ctypes
@@ -46,14 +44,16 @@ class AccessLogFormatter(logging.Formatter):
# API key patterns to match in URLs
API_KEY_PATTERNS = [
r'\bAIza[0-9A-Za-z_-]{35}', # Google API keys (like Gemini)
r'\bsk-[0-9A-Za-z_-]{20,}', # OpenAI and general sk- prefixed keys
r"\bAIza[0-9A-Za-z_-]{35}", # Google API keys (like Gemini)
r"\bsk-[0-9A-Za-z_-]{20,}", # OpenAI and general sk- prefixed keys
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Compile regex patterns for better performance
self.compiled_patterns = [re.compile(pattern) for pattern in self.API_KEY_PATTERNS]
self.compiled_patterns = [
re.compile(pattern) for pattern in self.API_KEY_PATTERNS
]
def format(self, record):
# Format the record normally first
@@ -68,9 +68,10 @@ class AccessLogFormatter(logging.Formatter):
"""
try:
for pattern in self.compiled_patterns:
def replace_key(match):
key = match.group(0)
return _redact_key_for_logging(key)
return redact_key_for_logging(key)
message = pattern.sub(replace_key, message)
@@ -78,11 +79,31 @@ class AccessLogFormatter(logging.Formatter):
except Exception as e:
# Log the error but don't expose the original message in case it contains keys
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error redacting API keys in access log: {e}")
return "[LOG_REDACTION_ERROR]"
def redact_key_for_logging(key: str) -> str:
"""
Redacts API key for secure logging by showing only first and last 6 characters.
Args:
key: API key to redact
Returns:
str: Redacted key in format "first6...last6" or descriptive placeholder for edge cases
"""
if not key:
return key
if len(key) <= 12:
return f"{key[:3]}...{key[-3:]}"
else:
return f"{key[:6]}...{key[-6:]}"
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
FORMATTER = ColoredFormatter(
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
@@ -284,6 +305,10 @@ def get_vertex_express_logger():
return Logger.setup_logger("vertex_express")
def get_gemini_embedding_logger():
return Logger.setup_logger("gemini_embedding")
def setup_access_logging():
"""
Configure uvicorn access logging with API key redaction
@@ -322,4 +347,3 @@ def setup_access_logging():
access_logger.propagate = False
return access_logger

View File

@@ -120,6 +120,7 @@ class ErrorLogDetailResponse(BaseModel):
request_msg: Optional[str] = None
model_name: Optional[str] = None
request_time: Optional[datetime] = None
error_code: Optional[int] = None
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
@@ -151,6 +152,43 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=
)
@router.get("/errors/lookup", response_model=ErrorLogDetailResponse)
async def lookup_error_log_by_info(
request: Request,
gemini_key: str = Query(..., description="完整的 Gemini key"),
timestamp: datetime = Query(..., description="请求时间 (ISO8601)"),
status_code: Optional[int] = Query(None, description="错误码 (可选)"),
window_seconds: int = Query(
100, ge=1, le=300, description="时间窗口(秒), 默认100秒"
),
):
"""
通过 key / 错误码 / 时间窗口 查找最匹配的一条错误日志详情。
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to lookup error log by info")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
detail = await error_log_service.process_find_error_log_by_info(
gemini_key=gemini_key,
timestamp=timestamp,
status_code=status_code,
window_seconds=window_seconds,
)
if not detail:
raise HTTPException(status_code=404, detail="No matching error log found")
return ErrorLogDetailResponse(**detail)
except HTTPException as http_exc:
raise http_exc
except Exception as e:
logger.exception(
f"Failed to lookup error log by info for key=***{gemini_key[-4:] if gemini_key else ''}: {str(e)}"
)
raise HTTPException(status_code=500, detail="Internal server error")
@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(...)
@@ -192,10 +230,10 @@ async def delete_all_error_logs_api(request: Request):
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to delete all error logs")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
deleted_count = await error_log_service.process_delete_all_error_logs()
logger.info(f"Successfully deleted all {deleted_count} error logs.")
await error_log_service.process_delete_all_error_logs()
logger.info("Successfully deleted all error logs.")
# No body needed for 204 response
return Response(status_code=status.HTTP_204_NO_CONTENT)
except Exception as e:
@@ -203,8 +241,8 @@ async def delete_all_error_logs_api(request: Request):
raise HTTPException(
status_code=500, detail="Internal server error during deletion of all logs"
)
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
"""
@@ -214,7 +252,7 @@ async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
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:

View File

@@ -1,18 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from copy import deepcopy
import asyncio
from copy import deepcopy
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from app.config.config import settings
from app.log.logger import get_gemini_logger
from app.core.security import SecurityService
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest
from app.service.chat.gemini_chat_service import GeminiChatService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.tts.native.tts_routes import get_tts_chat_service
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
from app.core.security import SecurityService
from app.domain.gemini_models import (
GeminiBatchEmbedRequest,
GeminiContent,
GeminiEmbedRequest,
GeminiRequest,
ResetSelectedKeysRequest,
VerifySelectedKeysRequest,
)
from app.handler.error_handler import handle_route_errors
from app.handler.retry_handler import RetryHandler
from app.log.logger import get_gemini_logger
from app.service.chat.gemini_chat_service import GeminiChatService
from app.service.embedding.gemini_embedding_service import GeminiEmbeddingService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
from app.service.tts.native.tts_routes import get_tts_chat_service
from app.utils.helpers import redact_key_for_logging
router = APIRouter(prefix=f"/gemini/{API_VERSION}")
@@ -38,11 +48,16 @@ async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
return GeminiChatService(settings.BASE_URL, key_manager)
async def get_embedding_service(key_manager: KeyManager = Depends(get_key_manager)):
"""获取Gemini嵌入服务实例"""
return GeminiEmbeddingService(settings.BASE_URL, key_manager)
@router.get("/models")
@router_v1beta.get("/models")
async def list_models(
_=Depends(security_service.verify_key_or_goog_api_key),
key_manager: KeyManager = Depends(get_key_manager)
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
key_manager: KeyManager = Depends(get_key_manager),
):
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
operation_name = "list_gemini_models"
@@ -52,20 +67,30 @@ async def list_models(
try:
api_key = await key_manager.get_random_valid_key()
if not api_key:
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
raise HTTPException(
status_code=503, detail="No valid API keys available to fetch models."
)
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
models_data = await model_service.get_gemini_models(api_key)
if not models_data or "models" not in models_data:
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
raise HTTPException(
status_code=500, detail="Failed to fetch base models list."
)
models_json = deepcopy(models_data)
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
model_mapping = {
x.get("name", "").split("/", maxsplit=1)[-1]: x
for x in models_json.get("models", [])
}
def add_derived_model(base_name, suffix, display_suffix):
model = model_mapping.get(base_name)
if not model:
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
logger.warning(
f"Base model '{base_name}' not found for derived model '{suffix}'."
)
return
item = deepcopy(model)
item["name"] = f"models/{base_name}{suffix}"
@@ -79,7 +104,7 @@ async def list_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")
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")
@@ -91,7 +116,8 @@ async def list_models(
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"
status_code=500,
detail="Internal server error while fetching Gemini models list",
) from e
@@ -101,15 +127,19 @@ async def list_models(
async def generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
chat_service: GeminiChatService = Depends(get_chat_service),
):
"""处理 Gemini 非流式内容生成请求。"""
operation_name = "gemini_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
logger.info(f"Handling Gemini content generation request for model: {model_name}")
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)}")
# 检测是否为原生Gemini TTS请求
@@ -126,10 +156,13 @@ async def generate_content(
logger.info(f"TTS responseModalities: {response_modalities}")
logger.info(f"TTS speechConfig: {speech_config}")
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(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")
raise HTTPException(
status_code=400, detail=f"Model {model_name} is not supported"
)
# 所有原生TTS请求都使用TTS增强服务
if is_native_tts:
@@ -137,19 +170,17 @@ async def generate_content(
logger.info("Using native TTS enhanced service")
tts_service = await get_tts_chat_service(key_manager)
response = await tts_service.generate_content(
model=model_name,
request=request,
api_key=api_key
model=model_name, request=request, api_key=api_key
)
return response
except Exception as e:
logger.warning(f"Native TTS processing failed, falling back to standard service: {e}")
logger.warning(
f"Native TTS processing failed, falling back to standard service: {e}"
)
# 使用标准服务处理所有其他请求非TTS
response = await chat_service.generate_content(
model=model_name,
request=request,
api_key=api_key
model=model_name, request=request, api_key=api_key
)
return response
@@ -160,27 +191,53 @@ async def generate_content(
async def stream_generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
chat_service: GeminiChatService = Depends(get_chat_service),
):
"""处理 Gemini 流式内容生成请求。"""
operation_name = "gemini_stream_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
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 allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(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")
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
raw_stream = chat_service.stream_generate_content(
model=model_name, request=request, api_key=api_key
)
return StreamingResponse(response_stream, media_type="text/event-stream")
try:
# 尝试获取第一条数据,判断是正常 SSEdata: 前缀)还是错误 JSON
first_chunk = await raw_stream.__anext__()
except StopAsyncIteration:
# 如果流直接结束,退回标准 SSE 输出
return StreamingResponse(raw_stream, media_type="text/event-stream")
except Exception as e:
# 初始化流异常,直接返回 500 错误
return JSONResponse(
content={"error": {"code": e.args[0], "message": e.args[1]}},
status_code=e.args[0],
)
# 如果以 "data:" 开头,代表正常 SSE将首块和后续块一起发送
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
async def combined():
yield first_chunk
async for chunk in raw_stream:
yield chunk
return StreamingResponse(combined(), media_type="text/event-stream")
@router.post("/models/{model_name}:countTokens")
@@ -189,41 +246,112 @@ async def stream_generate_content(
async def count_tokens(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
chat_service: GeminiChatService = Depends(get_chat_service),
):
"""处理 Gemini token 计数请求。"""
operation_name = "gemini_count_tokens"
async with handle_route_errors(logger, operation_name, failure_message="Token counting failed"):
async with handle_route_errors(
logger, operation_name, failure_message="Token counting failed"
):
logger.info(f"Handling Gemini token count request for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(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")
raise HTTPException(
status_code=400, detail=f"Model {model_name} is not supported"
)
response = await chat_service.count_tokens(
model=model_name,
request=request,
api_key=api_key
model=model_name, request=request, api_key=api_key
)
return response
@router.post("/models/{model_name}:embedContent")
@router_v1beta.post("/models/{model_name}:embedContent")
@RetryHandler(key_arg="api_key")
async def embed_content(
model_name: str,
request: GeminiEmbedRequest,
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
embedding_service: GeminiEmbeddingService = Depends(get_embedding_service),
):
"""处理 Gemini 单一嵌入请求"""
operation_name = "gemini_embed_content"
async with handle_route_errors(
logger, operation_name, failure_message="Embedding content generation failed"
):
logger.info(f"Handling Gemini embedding request for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(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 embedding_service.embed_content(
model=model_name, request=request, api_key=api_key
)
return response
@router.post("/models/{model_name}:batchEmbedContents")
@router_v1beta.post("/models/{model_name}:batchEmbedContents")
@RetryHandler(key_arg="api_key")
async def batch_embed_contents(
model_name: str,
request: GeminiBatchEmbedRequest,
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
embedding_service: GeminiEmbeddingService = Depends(get_embedding_service),
):
"""处理 Gemini 批量嵌入请求"""
operation_name = "gemini_batch_embed_contents"
async with handle_route_errors(
logger,
operation_name,
failure_message="Batch embedding content generation failed",
):
logger.info(f"Handling Gemini batch embedding request for model: {model_name}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(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 embedding_service.batch_embed_contents(
model=model_name, request=request, api_key=api_key
)
return response
@router.post("/reset-all-fail-counts")
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
async def reset_all_key_fail_counts(
key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)
):
"""批量重置Gemini API密钥的失败计数可选择性地仅重置有效或无效密钥"""
logger.info("-" * 50 + "reset_all_gemini_key_fail_counts" + "-" * 50)
logger.info(f"Received reset request with key_type: {key_type}")
try:
# 获取分类后的密钥
keys_by_status = await key_manager.get_keys_by_status()
valid_keys = keys_by_status.get("valid_keys", {})
invalid_keys = keys_by_status.get("invalid_keys", {})
# 根据类型选择要重置的密钥
keys_to_reset = []
if key_type == "valid":
@@ -235,35 +363,45 @@ async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManage
else:
# 重置所有密钥
await key_manager.reset_failure_counts()
return JSONResponse({"success": True, "message": "所有密钥的失败计数已重置"})
return JSONResponse(
{"success": True, "message": "所有密钥的失败计数已重置"}
)
# 批量重置指定类型的密钥
for key in keys_to_reset:
await key_manager.reset_key_failure_count(key)
return JSONResponse({
"success": True,
"message": f"{key_type}密钥的失败计数已重置",
"reset_count": len(keys_to_reset)
})
return JSONResponse(
{
"success": True,
"message": f"{key_type}密钥的失败计数已重置",
"reset_count": len(keys_to_reset),
}
)
except Exception as e:
logger.error(f"Failed to reset key failure counts: {str(e)}")
return JSONResponse({"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500)
return JSONResponse(
{"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500
)
@router.post("/reset-selected-fail-counts")
async def reset_selected_key_fail_counts(
request: ResetSelectedKeysRequest,
key_manager: KeyManager = Depends(get_key_manager)
key_manager: KeyManager = Depends(get_key_manager),
):
"""批量重置选定Gemini API密钥的失败计数"""
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
keys_to_reset = request.keys
key_type = request.key_type
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
logger.info(
f"Received reset request for {len(keys_to_reset)} selected {key_type} keys."
)
if not keys_to_reset:
return JSONResponse({"success": False, "message": "没有提供需要重置的密钥"}, status_code=400)
return JSONResponse(
{"success": False, "message": "没有提供需要重置的密钥"}, status_code=400
)
reset_count = 0
errors = []
@@ -275,53 +413,79 @@ async def reset_selected_key_fail_counts(
if result:
reset_count += 1
else:
logger.warning(f"Key not found during selective reset: {redact_key_for_logging(key)}")
logger.warning(
f"Key not found during selective reset: {redact_key_for_logging(key)}"
)
except Exception as key_error:
logger.error(f"Error resetting key {redact_key_for_logging(key)}: {str(key_error)}")
logger.error(
f"Error resetting key {redact_key_for_logging(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
return JSONResponse({
"success": final_success,
"message": error_message,
"reset_count": reset_count
}, status_code=status_code)
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
final_success = reset_count > 0
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
})
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)
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)):
async def reset_key_fail_count(
api_key: str, key_manager: KeyManager = Depends(get_key_manager)
):
"""重置指定Gemini API密钥的失败计数"""
logger.info("-" * 50 + "reset_gemini_key_fail_count" + "-" * 50)
logger.info(f"Resetting failure count for API key: {redact_key_for_logging(api_key)}")
logger.info(
f"Resetting failure count for API key: {redact_key_for_logging(api_key)}"
)
try:
result = await key_manager.reset_key_failure_count(api_key)
if result:
return JSONResponse({"success": True, "message": "失败计数已重置"})
return JSONResponse({"success": False, "message": "未找到指定密钥"}, status_code=404)
return JSONResponse(
{"success": False, "message": "未找到指定密钥"}, status_code=404
)
except Exception as e:
logger.error(f"Failed to reset key failure count: {str(e)}")
return JSONResponse({"success": False, "message": f"重置失败: {str(e)}"}, status_code=500)
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)):
async def verify_key(
api_key: str,
chat_service: GeminiChatService = Depends(get_chat_service),
key_manager: KeyManager = Depends(get_key_manager),
):
"""验证Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
logger.info("Verifying API key validity")
try:
gemini_request = GeminiRequest(
contents=[
@@ -330,27 +494,27 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
parts=[{"text": "hi"}],
)
],
generation_config={"temperature": 0.7, "topP": 1.0, "maxOutputTokens": 10}
generation_config={"temperature": 0.7, "topP": 1.0, "maxOutputTokens": 10},
)
response = await chat_service.generate_content(
settings.TEST_MODEL,
gemini_request,
api_key
settings.TEST_MODEL, gemini_request, api_key
)
if response:
# 如果密钥验证成功,则重置其失败计数
await key_manager.reset_key_failure_count(api_key)
return JSONResponse({"status": "valid"})
except Exception as e:
logger.error(f"Key verification failed: {str(e)}")
async with key_manager.failure_count_lock:
if api_key in key_manager.key_failure_counts:
key_manager.key_failure_counts[api_key] += 1
logger.warning(f"Verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count")
logger.warning(
f"Verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count"
)
return JSONResponse({"status": "invalid", "error": str(e)})
@@ -358,15 +522,19 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
async def verify_selected_keys(
request: VerifySelectedKeysRequest,
chat_service: GeminiChatService = Depends(get_chat_service),
key_manager: KeyManager = Depends(get_key_manager)
key_manager: KeyManager = Depends(get_key_manager),
):
"""批量验证选定Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_selected_gemini_keys" + "-" * 50)
keys_to_verify = request.keys
logger.info(f"Received verification request for {len(keys_to_verify)} selected keys.")
logger.info(
f"Received verification request for {len(keys_to_verify)} selected keys."
)
if not keys_to_verify:
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
return JSONResponse(
{"success": False, "message": "没有提供需要验证的密钥"}, status_code=400
)
successful_keys = []
failed_keys = {}
@@ -377,12 +545,14 @@ async def verify_selected_keys(
try:
gemini_request = GeminiRequest(
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])],
generation_config={"temperature": 0.7, "topP": 1.0, "maxOutputTokens": 10}
generation_config={
"temperature": 0.7,
"topP": 1.0,
"maxOutputTokens": 10,
},
)
await chat_service.generate_content(
settings.TEST_MODEL,
gemini_request,
api_key
settings.TEST_MODEL, gemini_request, api_key
)
successful_keys.append(api_key)
# 如果密钥验证成功,则重置其失败计数
@@ -390,14 +560,20 @@ async def verify_selected_keys(
return api_key, "valid", None
except Exception as e:
error_message = str(e)
logger.warning(f"Key verification failed for {redact_key_for_logging(api_key)}: {error_message}")
logger.warning(
f"Key verification failed for {redact_key_for_logging(api_key)}: {error_message}"
)
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: {redact_key_for_logging(api_key)}, incrementing failure count")
logger.warning(
f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count"
)
else:
key_manager.key_failure_counts[api_key] = 1
logger.warning(f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, initializing failure count to 1")
key_manager.key_failure_counts[api_key] = 1
logger.warning(
f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, initializing failure count to 1"
)
failed_keys[api_key] = error_message
return api_key, "invalid", error_message
@@ -406,34 +582,42 @@ async def verify_selected_keys(
for result in results:
if isinstance(result, Exception):
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
logger.error(
f"An unexpected error occurred during bulk verification task: {result}"
)
elif result:
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}")
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}")
logger.info(
f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}"
)
if failed_keys:
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}"
return JSONResponse({
"success": True,
"message": message,
"successful_keys": successful_keys,
"failed_keys": failed_keys,
"valid_count": valid_count,
"invalid_count": invalid_count
})
return JSONResponse(
{
"success": True,
"message": message,
"successful_keys": successful_keys,
"failed_keys": failed_keys,
"valid_count": valid_count,
"invalid_count": invalid_count,
}
)
else:
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
return JSONResponse({
"success": True,
"message": message,
"successful_keys": successful_keys,
"failed_keys": {},
"valid_count": valid_count,
"invalid_count": 0
})
return JSONResponse(
{
"success": True,
"message": message,
"successful_keys": successful_keys,
"failed_keys": {},
"valid_count": valid_count,
"invalid_count": 0,
}
)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse
from app.config.config import settings
from app.core.security import SecurityService
@@ -8,19 +8,21 @@ from app.domain.openai_models import (
EmbeddingRequest,
ImageGenerationRequest,
)
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.handler.retry_handler import RetryHandler
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
from app.service.openai_compatiable.openai_compatiable_service import (
OpenAICompatiableService,
)
from app.utils.helpers import redact_key_for_logging
router = APIRouter()
logger = get_openai_compatible_logger()
security_service = SecurityService()
async def get_key_manager():
return await get_key_manager_instance()
@@ -38,7 +40,7 @@ async def get_openai_service(key_manager: KeyManager = Depends(get_key_manager))
@router.get("/openai/v1/models")
async def list_models(
_=Depends(security_service.verify_authorization),
allowed_token=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
@@ -47,6 +49,7 @@ async def list_models(
async with handle_route_errors(logger, operation_name):
logger.info("Handling models list request")
api_key = await key_manager.get_random_valid_key()
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
return await openai_service.get_models(api_key)
@@ -55,7 +58,7 @@ async def list_models(
@RetryHandler(key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),
allowed_token=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),
@@ -70,28 +73,56 @@ async def chat_completion(
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 allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(current_api_key)}")
raw_response = None
if is_image_chat:
response = await openai_service.create_image_chat_completion(request, current_api_key)
return response
raw_response = await openai_service.create_image_chat_completion(
request, current_api_key
)
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
raw_response = await openai_service.create_chat_completion(
request, current_api_key
)
if request.stream:
try:
# 尝试获取第一条数据,判断是正常 SSEdata: 前缀)还是错误 JSON
first_chunk = await raw_response.__anext__()
except StopAsyncIteration:
# 如果流直接结束,退回标准 SSE 输出
return StreamingResponse(raw_response, media_type="text/event-stream")
except Exception as e:
# 初始化流异常,直接返回 500 错误
return JSONResponse(
content={"error": {"code": e.args[0], "message": e.args[1]}},
status_code=e.args[0],
)
# 如果以 "data:" 开头,代表正常 SSE将首块和后续块一起发送
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
async def combined():
yield first_chunk
async for chunk in raw_response:
yield chunk
return StreamingResponse(combined(), media_type="text/event-stream")
else:
return raw_response
@router.post("/openai/v1/images/generations")
async def generate_image(
request: ImageGenerationRequest,
_=Depends(security_service.verify_authorization),
allowed_token=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}")
logger.info(f"Using allowed token: {allowed_token}")
request.model = settings.CREATE_IMAGE_MODEL
return await openai_service.generate_images(request)
@@ -99,7 +130,7 @@ async def generate_image(
@router.post("/openai/v1/embeddings")
async def embedding(
request: EmbeddingRequest,
_=Depends(security_service.verify_authorization),
allowed_token=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
openai_service: OpenAICompatiableService = Depends(get_openai_service),
):
@@ -108,6 +139,7 @@ async def 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 allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
return await openai_service.create_embeddings(
input_text=request.input, model=request.model, api_key=api_key

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse
from app.config.config import settings
from app.core.security import SecurityService
@@ -9,15 +9,15 @@ from app.domain.openai_models import (
ImageGenerationRequest,
TTSRequest,
)
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.handler.retry_handler import RetryHandler
from app.log.logger import get_openai_logger
from app.service.chat.openai_chat_service import OpenAIChatService
from app.service.embedding.embedding_service import EmbeddingService
from app.service.image.image_create_service import ImageCreateService
from app.service.tts.tts_service import TTSService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
from app.service.tts.tts_service import TTSService
from app.utils.helpers import redact_key_for_logging
router = APIRouter()
@@ -53,7 +53,7 @@ async def get_tts_service():
@router.get("/v1/models")
@router.get("/hf/v1/models")
async def list_models(
_=Depends(security_service.verify_authorization),
allowed_token=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
):
"""获取可用的 OpenAI 模型列表 (兼容 Gemini 和 OpenAI)。"""
@@ -61,6 +61,7 @@ async def list_models(
async with handle_route_errors(logger, operation_name):
logger.info("Handling models list request")
api_key = await key_manager.get_random_valid_key()
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
return await model_service.get_gemini_openai_models(api_key)
@@ -70,7 +71,7 @@ async def list_models(
@RetryHandler(key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),
allowed_token=Depends(security_service.verify_authorization),
api_key: str = Depends(get_next_working_key_wrapper),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: OpenAIChatService = Depends(get_openai_chat_service),
@@ -92,23 +93,48 @@ async def chat_completion(
status_code=400, detail=f"Model {request.model} is not supported"
)
raw_response = None
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
raw_response = await chat_service.create_image_chat_completion(
request, current_api_key
)
else:
response = await chat_service.create_chat_completion(request, current_api_key)
if request.stream:
return StreamingResponse(response, media_type="text/event-stream")
return response
raw_response = await chat_service.create_chat_completion(
request, current_api_key
)
if request.stream:
try:
# 尝试获取第一条数据,判断是正常 SSEdata: 前缀)还是错误 JSON
first_chunk = await raw_response.__anext__()
except StopAsyncIteration:
# 如果流直接结束,退回标准 SSE 输出
return StreamingResponse(raw_response, media_type="text/event-stream")
except Exception as e:
# 初始化流异常,直接返回 500 错误
return JSONResponse(
content={"error": {"code": e.args[0], "message": e.args[1]}},
status_code=e.args[0],
)
# 如果以 "data:" 开头,代表正常 SSE将首块和后续块一起发送
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
async def combined():
yield first_chunk
async for chunk in raw_response:
yield chunk
return StreamingResponse(combined(), media_type="text/event-stream")
else:
return raw_response
@router.post("/v1/images/generations")
@router.post("/hf/v1/images/generations")
async def generate_image(
request: ImageGenerationRequest,
_=Depends(security_service.verify_authorization),
allowed_token=Depends(security_service.verify_authorization),
):
"""处理 OpenAI 图像生成请求。"""
operation_name = "generate_image"
@@ -122,7 +148,7 @@ async def generate_image(
@router.post("/hf/v1/embeddings")
async def embedding(
request: EmbeddingRequest,
_=Depends(security_service.verify_authorization),
allowed_token=Depends(security_service.verify_authorization),
key_manager: KeyManager = Depends(get_key_manager),
):
"""处理 OpenAI 文本嵌入请求。"""
@@ -130,6 +156,7 @@ async def 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 allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
response = await embedding_service.create_embedding(
input_text=request.input, model=request.model, api_key=api_key
@@ -162,7 +189,7 @@ async def get_keys_list(
@router.post("/hf/v1/audio/speech")
async def text_to_speech(
request: TTSRequest,
_=Depends(security_service.verify_authorization),
allowed_token=Depends(security_service.verify_authorization),
api_key: str = Depends(get_next_working_key_wrapper),
tts_service: TTSService = Depends(get_tts_service),
):
@@ -171,6 +198,7 @@ async def text_to_speech(
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling TTS request for model: {request.model}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
audio_data = await tts_service.create_tts(request, api_key)
return Response(content=audio_data, media_type="audio/wav")

View File

@@ -6,16 +6,31 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.core.security import verify_auth_token
from app.config.config import settings
from app.core.security import verify_auth_token
from app.log.logger import get_routes_logger
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes, files_routes, key_routes
from app.router import (
config_routes,
error_log_routes,
files_routes,
gemini_routes,
key_routes,
openai_compatiable_routes,
openai_routes,
scheduler_routes,
stats_routes,
version_routes,
vertex_express_routes,
)
from app.service.key.key_manager import get_key_manager_instance
from app.service.stats.stats_service import StatsService
from app.utils.static_version import get_static_url
logger = get_routes_logger()
templates = Jinja2Templates(directory="app/templates")
# 设置模板全局变量
templates.env.globals["static_url"] = get_static_url
def setup_routers(app: FastAPI) -> None:
@@ -69,9 +84,12 @@ def setup_page_routes(app: FastAPI) -> None:
if verify_auth_token(auth_token):
logger.info("Successful authentication")
response = RedirectResponse(url="/config", status_code=302)
response = RedirectResponse(url="/keys", status_code=302)
response.set_cookie(
key="auth_token", value=auth_token, httponly=True, max_age=settings.ADMIN_SESSION_EXPIRE
key="auth_token",
value=auth_token,
httponly=True,
max_age=settings.ADMIN_SESSION_EXPIRE,
)
return response
logger.warning("Failed authentication attempt with invalid token")
@@ -91,7 +109,9 @@ def setup_page_routes(app: FastAPI) -> None:
key_manager = await get_key_manager_instance()
keys_status = await key_manager.get_keys_by_status()
total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
total_keys = len(keys_status["valid_keys"]) + len(
keys_status["invalid_keys"]
)
valid_key_count = len(keys_status["valid_keys"])
invalid_key_count = len(keys_status["invalid_keys"])
@@ -133,7 +153,7 @@ def setup_page_routes(app: FastAPI) -> None:
},
},
)
@app.get("/config", response_class=HTMLResponse)
async def config_page(request: Request):
"""配置编辑页面"""
@@ -142,13 +162,15 @@ def setup_page_routes(app: FastAPI) -> None:
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
logger.info("Config page accessed successfully")
return templates.TemplateResponse("config_editor.html", {"request": request})
return templates.TemplateResponse(
"config_editor.html", {"request": request}
)
except Exception as e:
logger.error(f"Error accessing config page: {str(e)}")
raise
@app.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
"""错误日志页面"""
@@ -157,7 +179,7 @@ def setup_page_routes(app: FastAPI) -> None:
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to logs page")
return RedirectResponse(url="/", status_code=302)
logger.info("Logs page accessed successfully")
return templates.TemplateResponse("error_logs.html", {"request": request})
except Exception as e:
@@ -187,6 +209,7 @@ def setup_api_stats_routes(app: FastAPI) -> None:
Args:
app: FastAPI应用程序实例
"""
@app.get("/api/stats/details")
async def api_stats_details(request: Request, period: str):
"""获取指定时间段内的 API 调用详情"""
@@ -201,8 +224,67 @@ def setup_api_stats_routes(app: FastAPI) -> None:
details = await stats_service.get_api_call_details(period)
return details
except ValueError as e:
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
logger.warning(
f"Invalid period requested for API stats details: {period} - {str(e)}"
)
return {"error": str(e)}, 400
except Exception as e:
logger.error(f"Error fetching API stats details for period {period}: {str(e)}")
logger.error(
f"Error fetching API stats details for period {period}: {str(e)}"
)
return {"error": "Internal server error"}, 500
@app.get("/api/stats/attention-keys")
async def api_stats_attention_keys(
request: Request, limit: int = 20, status_code: int = 429
):
"""返回最近24小时指定错误码次数最多的Key仅包含内存Key列表中的。默认错误码429。"""
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 attention-keys")
return {"error": "Unauthorized"}, 401
# 支持所有标准HTTP状态码范围
# if not isinstance(status_code, int) or status_code < 100 or status_code > 599:
# return {"error": f"Unsupported status_code: {status_code}"}, 400
key_manager = await get_key_manager_instance()
keys_status = await key_manager.get_keys_by_status()
in_memory_keys = set(keys_status.get("valid_keys", [])) | set(
keys_status.get("invalid_keys", [])
)
stats_service = StatsService()
data = await stats_service.get_attention_keys_last_24h(
in_memory_keys, limit, status_code
)
return data
except Exception as e:
logger.error(f"Error fetching attention keys: {e}")
return {"error": "Internal server error"}, 500
@app.get("/api/stats/key-details")
async def api_stats_key_details(request: Request, key: str, period: str):
"""获取指定密钥在指定时间段内的调用详情"""
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 key stats details")
return {"error": "Unauthorized"}, 401
logger.info(
f"Fetching key call details for key=...{key[-4:] if key else ''}, period: {period}"
)
stats_service = StatsService()
details = await stats_service.get_key_call_details(key, period)
return details
except ValueError as e:
logger.warning(
f"Invalid period requested for key stats details: {period} - {str(e)}"
)
return {"error": str(e)}, 400
except Exception as e:
logger.error(
f"Error fetching key stats details for period {period}: {str(e)}"
)
return {"error": "Internal server error"}, 500

View File

@@ -1,16 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from copy import deepcopy
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from app.config.config import settings
from app.log.logger import get_vertex_express_logger
from app.core.constants import API_VERSION
from app.core.security import SecurityService
from app.domain.gemini_models import GeminiRequest
from app.handler.error_handler import handle_route_errors
from app.handler.retry_handler import RetryHandler
from app.log.logger import get_vertex_express_logger
from app.service.chat.vertex_express_chat_service import GeminiChatService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors
from app.core.constants import API_VERSION
from app.utils.helpers import redact_key_for_logging
router = APIRouter(prefix=f"/vertex-express/{API_VERSION}")
@@ -37,8 +39,8 @@ async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
@router.get("/models")
async def list_models(
_=Depends(security_service.verify_key_or_goog_api_key),
key_manager: KeyManager = Depends(get_key_manager)
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
key_manager: KeyManager = Depends(get_key_manager),
):
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
operation_name = "list_gemini_models"
@@ -48,20 +50,30 @@ async def list_models(
try:
api_key = await key_manager.get_random_valid_key()
if not api_key:
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
raise HTTPException(
status_code=503, detail="No valid API keys available to fetch models."
)
logger.info(f"Using allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
models_data = await model_service.get_gemini_models(api_key)
if not models_data or "models" not in models_data:
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
raise HTTPException(
status_code=500, detail="Failed to fetch base models list."
)
models_json = deepcopy(models_data)
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
model_mapping = {
x.get("name", "").split("/", maxsplit=1)[-1]: x
for x in models_json.get("models", [])
}
def add_derived_model(base_name, suffix, display_suffix):
model = model_mapping.get(base_name)
if not model:
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
logger.warning(
f"Base model '{base_name}' not found for derived model '{suffix}'."
)
return
item = deepcopy(model)
item["name"] = f"models/{base_name}{suffix}"
@@ -75,7 +87,7 @@ async def list_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")
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")
@@ -87,7 +99,8 @@ async def list_models(
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"
status_code=500,
detail="Internal server error while fetching Gemini models list",
) from e
@@ -96,25 +109,30 @@ async def list_models(
async def generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
chat_service: GeminiChatService = Depends(get_chat_service),
):
"""处理 Gemini 非流式内容生成请求。"""
operation_name = "gemini_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
logger.info(f"Handling Gemini content generation request for model: {model_name}")
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 allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(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")
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
model=model_name, request=request, api_key=api_key
)
return response
@@ -124,24 +142,50 @@ async def generate_content(
async def stream_generate_content(
model_name: str,
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
chat_service: GeminiChatService = Depends(get_chat_service),
):
"""处理 Gemini 流式内容生成请求。"""
operation_name = "gemini_stream_generate_content"
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
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 allowed token: {allowed_token}")
logger.info(f"Using API key: {redact_key_for_logging(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")
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
raw_stream = chat_service.stream_generate_content(
model=model_name, request=request, api_key=api_key
)
return StreamingResponse(response_stream, media_type="text/event-stream")
try:
# 尝试获取第一条数据,判断是正常 SSEdata: 前缀)还是错误 JSON
first_chunk = await raw_stream.__anext__()
except StopAsyncIteration:
# 如果流直接结束,退回标准 SSE 输出
return StreamingResponse(raw_stream, media_type="text/event-stream")
except Exception as e:
# 初始化流异常,直接返回 500 错误
return JSONResponse(
content={"error": {"code": e.args[0], "message": e.args[1]}},
status_code=e.args[0],
)
# 如果以 "data:" 开头,代表正常 SSE将首块和后续块一起发送
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
async def combined():
yield first_chunk
async for chunk in raw_stream:
yield chunk
return StreamingResponse(combined(), media_type="text/event-stream")

View File

@@ -1,4 +1,3 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config.config import settings
@@ -6,9 +5,9 @@ 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.files.files_service import get_files_service
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
from app.service.files.files_service import get_files_service
from app.utils.helpers import redact_key_for_logging
logger = Logger.setup_logger("scheduler")
@@ -106,15 +105,16 @@ async def cleanup_expired_files():
try:
files_service = await get_files_service()
deleted_count = await files_service.cleanup_expired_files()
if deleted_count > 0:
logger.info(f"Successfully cleaned up {deleted_count} expired files.")
else:
logger.info("No expired files to clean up.")
except Exception as e:
logger.error(
f"An error occurred during the scheduled file cleanup: {str(e)}", exc_info=True
f"An error occurred during the scheduled file cleanup: {str(e)}",
exc_info=True,
)
@@ -122,44 +122,45 @@ 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)."
)
if settings.CHECK_INTERVAL_HOURS != 0:
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点执行
# 新增:添加自动删除错误日志的定时任务,每天凌晨0点执行
scheduler.add_job(
delete_old_error_logs,
"cron",
hour=3,
hour=0,
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分执行
# 新增:添加自动删除请求日志的定时任务,每天凌晨0点执行
scheduler.add_job(
delete_old_request_logs_task,
"cron",
hour=3,
minute=5,
hour=0,
minute=0,
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."
)
# 新增:添加文件过期清理的定时任务,每小时执行一次
if getattr(settings, 'FILES_CLEANUP_ENABLED', True):
cleanup_interval = getattr(settings, 'FILES_CLEANUP_INTERVAL_HOURS', 1)
if getattr(settings, "FILES_CLEANUP_ENABLED", True):
cleanup_interval = getattr(settings, "FILES_CLEANUP_INTERVAL_HOURS", 1)
scheduler.add_job(
cleanup_expired_files,
"interval",

View File

@@ -1,19 +1,20 @@
# app/services/chat_service.py
import datetime
import json
import re
import datetime
import time
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.database.services import add_error_log, add_request_log, get_file_api_key
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, get_file_api_key
from app.utils.helpers import redact_key_for_logging
logger = get_gemini_logger()
@@ -28,6 +29,7 @@ def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
return True
return False
def _extract_file_references(contents: List[Dict[str, Any]]) -> List[str]:
"""從內容中提取文件引用"""
file_names = []
@@ -42,7 +44,9 @@ def _extract_file_references(contents: List[Dict[str, Any]]) -> List[str]:
file_uri = file_data["fileUri"]
# 從 URI 中提取文件名
# 1. https://generativelanguage.googleapis.com/v1beta/files/{file_id}
match = re.match(rf"{re.escape(settings.BASE_URL)}/(files/.*)", file_uri)
match = re.match(
rf"{re.escape(settings.BASE_URL)}/(files/.*)", file_uri
)
if not match:
logger.warning(f"Invalid file URI: {file_uri}")
continue
@@ -51,19 +55,36 @@ def _extract_file_references(contents: List[Dict[str, Any]]) -> List[str]:
logger.info(f"Found file reference: {file_id}")
return file_names
def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
"exclusiveMaximum",
"exclusiveMinimum",
"const",
"examples",
"contentEncoding",
"contentMediaType",
"if",
"then",
"else",
"allOf",
"anyOf",
"oneOf",
"not",
"definitions",
"$schema",
"$id",
"$ref",
"$comment",
"readOnly",
"writeOnly",
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
@@ -74,13 +95,13 @@ def _clean_json_schema_properties(obj: Any) -> Any:
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
def _has_function_call(contents: List[Dict[str, Any]]) -> bool:
"""检查内容中是否包含 functionCall"""
if not contents or not isinstance(contents, list):
@@ -95,7 +116,7 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if isinstance(part, dict) and "functionCall" in part:
return True
return False
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
record = dict()
for item in tools:
@@ -119,6 +140,14 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
record[k] = v
return record
def _is_structured_output_request(payload: Dict[str, Any]) -> bool:
"""检查请求是否要求结构化JSON输出"""
try:
generation_config = payload.get("generationConfig", {})
return generation_config.get("responseMimeType") == "application/json"
except (AttributeError, TypeError):
return False
tool = dict()
if payload and isinstance(payload, dict) and "tools" in payload:
if payload.get("tools") and isinstance(payload.get("tools"), dict):
@@ -127,21 +156,29 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if items and isinstance(items, list):
tool.update(_merge_tools(items))
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (model.endswith("-search") or "-thinking" in model)
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {}
# "Tool use with a response mime type: 'application/json' is unsupported"
# Gemini API限制不支持同时使用tools和结构化输出(response_mime_type='application/json')
# 当请求指定了JSON响应格式时跳过所有工具的添加以避免API错误
has_structured_output = _is_structured_output_request(payload)
if not has_structured_output:
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (model.endswith("-search") or "-thinking" in model)
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {}
# 解决 "Tool use with function calling is unsupported" 问题
if tool.get("functionDeclarations") or _has_function_call(payload.get("contents", [])):
if tool.get("functionDeclarations") or _has_function_call(
payload.get("contents", [])
):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
tool.pop("urlContext", None)
@@ -175,10 +212,16 @@ def _filter_empty_parts(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
filtered_contents = []
for content in contents:
if not content or "parts" not in content or not isinstance(content.get("parts"), list):
if (
not content
or "parts" not in content
or not isinstance(content.get("parts"), list)
):
continue
valid_parts = [part for part in content["parts"] if isinstance(part, dict) and part]
valid_parts = [
part for part in content["parts"] if isinstance(part, dict) and part
]
if valid_parts:
new_content = content.copy()
@@ -227,30 +270,32 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
# 处理思考配置:优先使用客户端提供的配置,否则使用默认配置
client_thinking_config = None
if request.generationConfig and request.generationConfig.thinkingConfig:
client_thinking_config = request.generationConfig.thinkingConfig
if client_thinking_config is not None:
# 客户端提供了思考配置,直接使用
payload["generationConfig"]["thinkingConfig"] = client_thinking_config
else:
# 客户端没有提供思考配置,使用默认配置
# 客户端没有提供思考配置,使用默认配置
if model.endswith("-non-thinking"):
if "gemini-2.5-pro" in model:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif _get_real_model(model) in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000),
"includeThoughts": True,
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000)
}
return payload
@@ -297,11 +342,15 @@ class GeminiChatService:
logger.info(f"Request contains file references: {file_names}")
file_api_key = await get_file_api_key(file_names[0])
if file_api_key:
logger.info(f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}")
logger.info(
f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}"
)
api_key = file_api_key # 使用文件的 API key
else:
logger.warning(f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}")
logger.warning(
f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}"
)
payload = _build_payload(model, request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
@@ -316,13 +365,9 @@ class GeminiChatService:
return self.response_handler.handle_response(response, model, stream=False)
except Exception as e:
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(f"Normal API call failed with error: {error_log_msg}")
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=api_key,
@@ -330,7 +375,8 @@ class GeminiChatService:
error_type="gemini-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
request_datetime=request_datetime,
)
raise e
finally:
@@ -342,7 +388,7 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def count_tokens(
@@ -350,7 +396,9 @@ class GeminiChatService:
) -> Dict[str, Any]:
"""计算token数量"""
# countTokens API只需要contents
payload = {"contents": _filter_empty_parts(request.model_dump().get("contents", []))}
payload = {
"contents": _filter_empty_parts(request.model_dump().get("contents", []))
}
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
@@ -364,13 +412,9 @@ class GeminiChatService:
return response
except Exception as e:
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(f"Count tokens API call failed with error: {error_log_msg}")
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=api_key,
@@ -378,7 +422,7 @@ class GeminiChatService:
error_type="gemini-count-tokens",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
)
raise e
finally:
@@ -390,7 +434,7 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def stream_generate_content(
@@ -403,11 +447,15 @@ class GeminiChatService:
logger.info(f"Request contains file references: {file_names}")
file_api_key = await get_file_api_key(file_names[0])
if file_api_key:
logger.info(f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}")
logger.info(
f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}"
)
api_key = file_api_key # 使用文件的 API key
else:
logger.warning(f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}")
logger.warning(
f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}"
)
retries = 0
max_retries = settings.MAX_RETRIES
payload = _build_payload(model, request)
@@ -452,15 +500,11 @@ class GeminiChatService:
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=current_attempt_key,
@@ -468,21 +512,26 @@ class GeminiChatService:
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=(
payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
),
request_datetime=request_datetime,
)
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
logger.info(
f"Switched to new API key: {redact_key_for_logging(api_key)}"
)
else:
logger.error(f"No valid API key available after {retries} retries.")
break
raise
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break
logger.error(f"Max retries ({max_retries}) reached for streaming.")
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
@@ -492,5 +541,5 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)

View File

@@ -3,7 +3,6 @@
import asyncio
import datetime
import json
import re
import time
from copy import deepcopy
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
@@ -40,15 +39,31 @@ def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
"exclusiveMaximum",
"exclusiveMinimum",
"const",
"examples",
"contentEncoding",
"contentMediaType",
"if",
"then",
"else",
"allOf",
"anyOf",
"oneOf",
"not",
"definitions",
"$schema",
"$id",
"$ref",
"$comment",
"readOnly",
"writeOnly",
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
@@ -59,7 +74,7 @@ def _clean_json_schema_properties(obj: Any) -> Any:
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
@@ -87,7 +102,7 @@ def _build_tools(
if model.endswith("-search"):
tool["googleSearch"] = {}
real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {}
@@ -116,7 +131,7 @@ def _build_tools(
names, functions = set(), []
for fc in function_declarations:
if fc.get("name") not in names:
if fc.get("name")=="googleSearch":
if fc.get("name") == "googleSearch":
# cherry开启内置搜索时添加googleSearch工具
tool["googleSearch"] = {}
else:
@@ -130,7 +145,7 @@ def _build_tools(
if tool.get("functionDeclarations"):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
tool.pop("urlContext",None)
tool.pop("urlContext", None)
return [tool] if tool else []
@@ -160,17 +175,17 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
def _validate_and_set_max_tokens(
payload: Dict[str, Any],
max_tokens: Optional[int],
logger_instance
payload: Dict[str, Any], max_tokens: Optional[int], logger_instance
) -> None:
"""验证并设置 max_tokens 参数"""
if max_tokens is None:
return
# 参数验证和处理
if max_tokens <= 0:
logger_instance.warning(f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens")
logger_instance.warning(
f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens"
)
# 不设置 maxOutputTokens让 Gemini API 使用默认值
else:
payload["generationConfig"]["maxOutputTokens"] = max_tokens
@@ -193,27 +208,33 @@ def _build_payload(
"tools": _build_tools(request, messages),
"safetySettings": _get_safety_settings(request.model),
}
# 处理 max_tokens 参数
_validate_and_set_max_tokens(payload, request.max_tokens, logger)
# 处理 n 参数
if request.n is not None and request.n > 0:
payload["generationConfig"]["candidateCount"] = request.n
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if request.model.endswith("-non-thinking"):
if "gemini-2.5-pro" in request.model:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
"includeThoughts": True
"includeThoughts": True,
}
else:
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
@@ -263,7 +284,9 @@ class OpenAIChatService:
api_key: str,
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
messages, instruction = self.message_converter.convert(request.messages)
messages, instruction = self.message_converter.convert(
request.messages, request.model
)
payload = _build_payload(request, messages, instruction)
@@ -280,13 +303,13 @@ class OpenAIChatService:
is_success = False
status_code = None
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
# 尝试处理响应,捕获可能的响应处理异常
try:
result = self.response_handler.handle_response(
@@ -298,8 +321,10 @@ class OpenAIChatService:
)
return result
except Exception as response_error:
logger.error(f"Response processing failed for model {model}: {str(response_error)}")
logger.error(
f"Response processing failed for model {model}: {str(response_error)}"
)
# 记录详细的错误信息
if "parts" in str(response_error):
logger.error("Response structure issue - missing or invalid parts")
@@ -307,26 +332,26 @@ class OpenAIChatService:
candidate = response["candidates"][0]
content = candidate.get("content", {})
logger.error(f"Content structure: {content}")
# 重新抛出异常
raise response_error
except Exception as e:
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(f"API call failed for model {model}: {error_log_msg}")
# 特别记录 max_tokens 相关的错误
gen_config = payload.get('generationConfig', {})
gen_config = payload.get("generationConfig", {})
if "maxOutputTokens" in gen_config:
logger.error(f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}")
logger.error(
f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}"
)
# 如果是响应处理错误,记录更多信息
if "parts" in error_log_msg:
logger.error("This is likely a response processing error")
match = re.search(r"status code (\d+)", error_log_msg)
status_code = int(match.group(1)) if match else 500
await add_error_log(
gemini_key=api_key,
@@ -334,14 +359,17 @@ class OpenAIChatService:
error_type="openai-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
request_datetime=request_datetime,
)
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
logger.info(f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms")
logger.info(
f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms"
)
await add_request_log(
model_name=model,
api_key=api_key,
@@ -358,49 +386,44 @@ class OpenAIChatService:
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)
)
i = 0
try:
while not api_response_task.done():
try:
next_empty_chunk = await asyncio.wait_for(
empty_data_generator.__anext__(), timeout=0.1
i = i + 1
"""定期发送空数据以保持连接"""
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS:
i = 0
empty_chunk = self.response_handler.handle_response(
{},
model,
stream=True,
finish_reason="stop",
usage_metadata=None,
)
yield next_empty_chunk
except asyncio.TimeoutError:
pass
except (
StopAsyncIteration
):
break
response = await api_response_task
yield f"data: {json.dumps(empty_chunk)}\n\n"
logger.debug("Sent empty data chunk for fake stream heartbeat.")
await asyncio.sleep(1)
finally:
keep_sending_empty_data = False
response = await api_response_task
if response and response.get("candidates"):
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {}))
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")
):
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)
@@ -408,7 +431,9 @@ class OpenAIChatService:
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)
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(
@@ -436,7 +461,11 @@ class OpenAIChatService:
)
continue
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None, usage_metadata=usage_metadata
chunk,
model,
stream=True,
finish_reason=None,
usage_metadata=usage_metadata,
)
if openai_chunk:
text = self._extract_text_from_openai_chunk(openai_chunk)
@@ -450,7 +479,9 @@ class OpenAIChatService:
):
yield optimized_chunk_data
else:
if openai_chunk.get("choices") and openai_chunk["choices"][0].get("delta", {}).get("tool_calls"):
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"
@@ -506,27 +537,22 @@ class OpenAIChatService:
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries} with key {current_attempt_key}"
)
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
if isinstance(e, asyncio.TimeoutError):
status_code = 408
else:
status_code = 500
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 if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
),
request_datetime=request_datetime,
)
if self.key_manager:
@@ -542,7 +568,7 @@ class OpenAIChatService:
logger.error(
f"No valid API key available after {retries} retries, ceasing attempts for this request."
)
break
raise
else:
logger.error(
"KeyManager not available, cannot switch API key. Ceasing attempts for this request."
@@ -553,6 +579,7 @@ class OpenAIChatService:
logger.error(
f"Max retries ({max_retries}) reached for streaming model {model}."
)
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
@@ -565,13 +592,6 @@ class OpenAIChatService:
request_time=request_datetime,
)
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
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
@@ -630,9 +650,9 @@ class OpenAIChatService:
yield "data: [DONE]\n\n"
except Exception as e:
is_success = False
error_log_msg = f"Stream image completion failed for model {model}: {e}"
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(error_log_msg)
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
@@ -640,9 +660,9 @@ class OpenAIChatService:
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]},
request_datetime=request_datetime,
)
yield f"data: {json.dumps({'error': error_log_msg})}\n\n"
yield "data: [DONE]\n\n"
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
@@ -680,9 +700,9 @@ class OpenAIChatService:
return result
except Exception as e:
is_success = False
error_log_msg = f"Normal image completion failed for model {model}: {e}"
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(error_log_msg)
status_code = 500
await add_error_log(
gemini_key=api_key,
model_name=model,
@@ -690,8 +710,9 @@ class OpenAIChatService:
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]},
request_datetime=request_datetime,
)
raise e
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)

View File

@@ -1,19 +1,19 @@
# app/services/chat_service.py
import json
import re
import datetime
import json
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.database.services import add_error_log, add_request_log
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
from app.utils.helpers import redact_key_for_logging
logger = get_gemini_logger()
@@ -33,15 +33,31 @@ def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
"exclusiveMaximum",
"exclusiveMinimum",
"const",
"examples",
"contentEncoding",
"contentMediaType",
"if",
"then",
"else",
"allOf",
"anyOf",
"oneOf",
"not",
"definitions",
"$schema",
"$id",
"$ref",
"$comment",
"readOnly",
"writeOnly",
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
@@ -52,13 +68,13 @@ def _clean_json_schema_properties(obj: Any) -> Any:
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
def _has_function_call(contents: List[Dict[str, Any]]) -> bool:
"""检查内容中是否包含 functionCall"""
if not contents or not isinstance(contents, list):
@@ -73,7 +89,7 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if isinstance(part, dict) and "functionCall" in part:
return True
return False
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
record = dict()
for item in tools:
@@ -97,6 +113,14 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
record[k] = v
return record
def _is_structured_output_request(payload: Dict[str, Any]) -> bool:
"""检查请求是否要求结构化JSON输出"""
try:
generation_config = payload.get("generationConfig", {})
return generation_config.get("responseMimeType") == "application/json"
except (AttributeError, TypeError):
return False
tool = dict()
if payload and isinstance(payload, dict) and "tools" in payload:
if payload.get("tools") and isinstance(payload.get("tools"), dict):
@@ -105,21 +129,29 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if items and isinstance(items, list):
tool.update(_merge_tools(items))
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (model.endswith("-search") or "-thinking" in model)
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {}
# "Tool use with a response mime type: 'application/json' is unsupported"
# Gemini API限制不支持同时使用tools和结构化输出(response_mime_type='application/json')
# 当请求指定了JSON响应格式时跳过所有工具的添加以避免API错误
has_structured_output = _is_structured_output_request(payload)
if not has_structured_output:
if (
settings.TOOLS_CODE_EXECUTION_ENABLED
and not (model.endswith("-search") or "-thinking" in model)
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {}
# 解决 "Tool use with function calling is unsupported" 问题
if tool.get("functionDeclarations") or _has_function_call(payload.get("contents", [])):
if tool.get("functionDeclarations") or _has_function_call(
payload.get("contents", [])
):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
tool.pop("urlContext", None)
@@ -153,7 +185,7 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if request.generationConfig.maxOutputTokens is None:
# 如果未指定最大输出长度,则不传递该字段,解决截断的问题
request_dict["generationConfig"].pop("maxOutputTokens")
payload = {
"contents": request_dict.get("contents", []),
"tools": _build_tools(model, request_dict),
@@ -165,30 +197,32 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
# 处理思考配置:优先使用客户端提供的配置,否则使用默认配置
client_thinking_config = None
if request.generationConfig and request.generationConfig.thinkingConfig:
client_thinking_config = request.generationConfig.thinkingConfig
if client_thinking_config is not None:
# 客户端提供了思考配置,直接使用
payload["generationConfig"]["thinkingConfig"] = client_thinking_config
else:
# 客户端没有提供思考配置,使用默认配置
# 客户端没有提供思考配置,使用默认配置
if model.endswith("-non-thinking"):
if "gemini-2.5-pro" in model:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif _get_real_model(model) in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000),
"includeThoughts": True,
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000)
}
return payload
@@ -243,13 +277,9 @@ class GeminiChatService:
return self.response_handler.handle_response(response, model, stream=False)
except Exception as e:
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(f"Normal API call failed with error: {error_log_msg}")
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=api_key,
@@ -257,7 +287,8 @@ class GeminiChatService:
error_type="gemini-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
request_datetime=request_datetime,
)
raise e
finally:
@@ -269,7 +300,7 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def stream_generate_content(
@@ -287,7 +318,7 @@ class GeminiChatService:
request_datetime = datetime.datetime.now()
start_time = time.perf_counter()
current_attempt_key = api_key
final_api_key = current_attempt_key # Update final key used
final_api_key = current_attempt_key # Update final key used
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
@@ -320,15 +351,11 @@ class GeminiChatService:
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=current_attempt_key,
@@ -336,21 +363,26 @@ class GeminiChatService:
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=(
payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
),
request_datetime=request_datetime,
)
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
logger.info(
f"Switched to new API key: {redact_key_for_logging(api_key)}"
)
else:
logger.error(f"No valid API key available after {retries} retries.")
break
raise
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break
logger.error(f"Max retries ({max_retries}) reached for streaming.")
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
@@ -360,5 +392,5 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)

View File

@@ -1,24 +1,31 @@
# app/services/chat/api_client.py
from typing import Dict, Any, AsyncGenerator, Optional
import httpx
import random
from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator, Dict, Optional
import httpx
from app.config.config import settings
from app.log.logger import get_api_client_logger
from app.core.constants import DEFAULT_TIMEOUT
from app.log.logger import get_api_client_logger
logger = get_api_client_logger()
class ApiClient(ABC):
"""API客户端基类"""
@abstractmethod
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
async def generate_content(
self, payload: Dict[str, Any], model: str, api_key: str
) -> Dict[str, Any]:
pass
@abstractmethod
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
async def stream_generate_content(
self, payload: Dict[str, Any], model: str, api_key: str
) -> AsyncGenerator[str, None]:
pass
@@ -50,7 +57,7 @@ class GeminiApiClient(ApiClient):
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:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
@@ -73,11 +80,13 @@ class GeminiApiClient(ApiClient):
except httpx.RequestError as e:
logger.error(f"请求模型列表失败: {e}")
return None
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
async def generate_content(
self, payload: Dict[str, Any], model: str, api_key: str
) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
proxy_to_use = None
if settings.PROXIES:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
@@ -85,42 +94,46 @@ class GeminiApiClient(ApiClient):
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers()
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
try:
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text
logger.error(f"API call failed - Status: {response.status_code}, Content: {error_content}")
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
logger.error(
f"API call failed - Status: {response.status_code}, Content: {error_content}"
)
raise Exception(response.status_code, error_content)
response_data = response.json()
# 检查响应结构的基本信息
if not response_data.get("candidates"):
logger.warning("No candidates found in API response")
return response_data
except httpx.TimeoutException as e:
logger.error(f"Request timeout: {e}")
raise Exception(f"Request timeout: {e}")
raise Exception(500, f"Request timeout: {e}")
except httpx.RequestError as e:
logger.error(f"Request error: {e}")
raise Exception(f"Request error: {e}")
raise Exception(500, f"Request error: {e}")
except Exception as e:
logger.error(f"Unexpected error: {e}")
raise
raise Exception(500, f"Unexpected error: {e}")
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
async def stream_generate_content(
self, payload: Dict[str, Any], model: str, api_key: str
) -> AsyncGenerator[str, None]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
proxy_to_use = None
if settings.PROXIES:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
@@ -132,15 +145,19 @@ class GeminiApiClient(ApiClient):
headers = self._prepare_headers()
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
async with client.stream(method="POST", url=url, json=payload, headers=headers) as response:
async with client.stream(
method="POST", url=url, json=payload, headers=headers
) as response:
if response.status_code != 200:
error_content = await response.aread()
error_msg = error_content.decode("utf-8")
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
raise Exception(response.status_code, error_msg)
async for line in response.aiter_lines():
yield line
async def count_tokens(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
async def count_tokens(
self, payload: Dict[str, Any], model: str, api_key: str
) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
@@ -158,9 +175,91 @@ class GeminiApiClient(ApiClient):
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}")
raise Exception(response.status_code, error_content)
return response.json()
async def embed_content(
self, payload: Dict[str, Any], model: str, api_key: str
) -> Dict[str, Any]:
"""单一嵌入内容生成"""
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
proxy_to_use = None
if settings.PROXIES:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for embedding: {proxy_to_use}")
headers = self._prepare_headers()
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:embedContent?key={api_key}"
try:
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text
logger.error(
f"Embedding API call failed - Status: {response.status_code}, Content: {error_content}"
)
raise Exception(response.status_code, error_content)
return response.json()
except httpx.TimeoutException as e:
logger.error(f"Embedding request timeout: {e}")
raise Exception(500, f"Request timeout: {e}")
except httpx.RequestError as e:
logger.error(f"Embedding request error: {e}")
raise Exception(500, f"Request error: {e}")
except Exception as e:
logger.error(f"Unexpected embedding error: {e}")
raise Exception(500, f"Unexpected embedding error: {e}")
async def batch_embed_contents(
self, payload: Dict[str, Any], model: str, api_key: str
) -> Dict[str, Any]:
"""批量嵌入内容生成"""
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
proxy_to_use = None
if settings.PROXIES:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
else:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for batch embedding: {proxy_to_use}")
headers = self._prepare_headers()
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:batchEmbedContents?key={api_key}"
try:
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text
logger.error(
f"Batch embedding API call failed - Status: {response.status_code}, Content: {error_content}"
)
raise Exception(response.status_code, error_content)
return response.json()
except httpx.TimeoutException as e:
logger.error(f"Batch embedding request timeout: {e}")
raise Exception(500, f"Request timeout: {e}")
except httpx.RequestError as e:
logger.error(f"Batch embedding request error: {e}")
raise Exception(500, f"Request error: {e}")
except Exception as e:
logger.error(f"Unexpected batch embedding error: {e}")
raise Exception(500, f"Unexpected batch embedding error: {e}")
class OpenaiApiClient(ApiClient):
"""OpenAI API客户端"""
@@ -168,7 +267,7 @@ class OpenaiApiClient(ApiClient):
def __init__(self, base_url: str, timeout: int = DEFAULT_TIMEOUT):
self.base_url = base_url
self.timeout = timeout
def _prepare_headers(self, api_key: str) -> Dict[str, str]:
headers = {"Authorization": f"Bearer {api_key}"}
if settings.CUSTOM_HEADERS:
@@ -193,12 +292,16 @@ class OpenaiApiClient(ApiClient):
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}")
raise Exception(response.status_code, error_content)
return response.json()
async def generate_content(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
async def generate_content(
self, payload: Dict[str, Any], api_key: str
) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
logger.info(f"settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: {settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY}")
logger.info(
f"settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: {settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY}"
)
proxy_to_use = None
if settings.PROXIES:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
@@ -213,10 +316,12 @@ class OpenaiApiClient(ApiClient):
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}")
raise Exception(response.status_code, error_content)
return response.json()
async def stream_generate_content(self, payload: Dict[str, Any], api_key: str) -> AsyncGenerator[str, None]:
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:
@@ -229,17 +334,21 @@ class OpenaiApiClient(ApiClient):
headers = self._prepare_headers(api_key)
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/chat/completions"
async with client.stream(method="POST", url=url, json=payload, headers=headers) as response:
async with client.stream(
method="POST", url=url, json=payload, headers=headers
) as response:
if response.status_code != 200:
error_content = await response.aread()
error_msg = error_content.decode("utf-8")
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
raise Exception(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]:
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:
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
@@ -258,10 +367,12 @@ class OpenaiApiClient(ApiClient):
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}")
raise Exception(response.status_code, error_content)
return response.json()
async def generate_images(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
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
@@ -278,5 +389,5 @@ class OpenaiApiClient(ApiClient):
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()
raise Exception(response.status_code, error_content)
return response.json()

View File

@@ -1,6 +1,5 @@
import datetime
import time
import re
from typing import List, Union
import openai
@@ -8,8 +7,8 @@ from openai import APIStatusError
from openai.types import CreateEmbeddingResponse
from app.config.config import settings
from app.log.logger import get_embeddings_logger
from app.database.services import add_error_log, add_request_log
from app.log.logger import get_embeddings_logger
logger = get_embeddings_logger()
@@ -27,12 +26,20 @@ class EmbeddingService:
response = None
error_log_msg = ""
if isinstance(input_text, list):
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
request_msg_log = {
"input_truncated": [
str(item)[:100] + "..." if len(str(item)) > 100 else str(item)
for item in input_text[:5]
]
}
if len(input_text) > 5:
request_msg_log["input_truncated"].append("...")
request_msg_log["input_truncated"].append("...")
else:
request_msg_log = {"input_truncated": input_text[:1000] + "..." if len(input_text) > 1000 else input_text}
request_msg_log = {
"input_truncated": (
input_text[:1000] + "..." if len(input_text) > 1000 else input_text
)
}
try:
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
@@ -48,13 +55,9 @@ class EmbeddingService:
raise e
except Exception as e:
is_success = False
status_code = 500
error_log_msg = f"Generic error: {e}"
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
match = re.search(r"status code (\d+)", str(e))
if match:
status_code = int(match.group(1))
else:
status_code = 500
raise e
finally:
end_time = time.perf_counter()
@@ -66,13 +69,14 @@ class EmbeddingService:
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
)
request_msg=request_msg_log,
request_datetime=request_datetime,
)
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

@@ -0,0 +1,141 @@
# app/service/embedding/gemini_embedding_service.py
import datetime
import time
from typing import Any, Dict
from app.config.config import settings
from app.database.services import add_error_log, add_request_log
from app.domain.gemini_models import GeminiBatchEmbedRequest, GeminiEmbedRequest
from app.log.logger import get_gemini_embedding_logger
from app.service.client.api_client import GeminiApiClient
from app.service.key.key_manager import KeyManager
logger = get_gemini_embedding_logger()
def _build_embed_payload(request: GeminiEmbedRequest) -> Dict[str, Any]:
"""构建嵌入请求payload"""
payload = {"content": request.content.model_dump()}
if request.taskType:
payload["taskType"] = request.taskType
if request.title:
payload["title"] = request.title
if request.outputDimensionality:
payload["outputDimensionality"] = request.outputDimensionality
return payload
def _build_batch_embed_payload(
request: GeminiBatchEmbedRequest, model: str
) -> Dict[str, Any]:
"""构建批量嵌入请求payload"""
requests = []
for embed_request in request.requests:
embed_payload = _build_embed_payload(embed_request)
embed_payload["model"] = (
f"models/{model}" # Gemini API要求每个请求包含model字段
)
requests.append(embed_payload)
return {"requests": requests}
class GeminiEmbeddingService:
"""Gemini嵌入服务"""
def __init__(self, base_url: str, key_manager: KeyManager):
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
self.key_manager = key_manager
async def embed_content(
self, model: str, request: GeminiEmbedRequest, api_key: str
) -> Dict[str, Any]:
"""生成单一嵌入内容"""
payload = _build_embed_payload(request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
try:
response = await self.api_client.embed_content(payload, model, api_key)
is_success = True
status_code = 200
return response
except Exception as e:
is_success = False
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(f"Single embedding API call failed: {error_log_msg}")
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="gemini-embed-single",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
request_datetime=request_datetime,
)
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 batch_embed_contents(
self, model: str, request: GeminiBatchEmbedRequest, api_key: str
) -> Dict[str, Any]:
"""生成批量嵌入内容"""
payload = _build_batch_embed_payload(request, model)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
try:
response = await self.api_client.batch_embed_contents(
payload, model, api_key
)
is_success = True
status_code = 200
return response
except Exception as e:
is_success = False
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(f"Batch embedding API call failed: {error_log_msg}")
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="gemini-embed-batch",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
request_datetime=request_datetime,
)
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,
)

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, select
@@ -28,7 +28,7 @@ async def delete_old_error_logs():
)
return
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
cutoff_date = datetime.now() - 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')})."
@@ -121,6 +121,30 @@ async def process_get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]
raise
async def process_find_error_log_by_info(
gemini_key: str,
timestamp: datetime,
status_code: Optional[int] = None,
window_seconds: int = 100,
) -> Optional[Dict[str, Any]]:
"""
根据 key/状态码/时间窗口 查询最匹配的一条错误日志,未找到则返回 None。
"""
try:
return await db_services.find_error_log_by_info(
gemini_key=gemini_key,
timestamp=timestamp,
status_code=status_code,
window_seconds=window_seconds,
)
except Exception as e:
logger.error(
f"Service error in process_find_error_log_by_info: {e}",
exc_info=True,
)
raise
async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
按 ID 批量删除错误日志。

View File

@@ -9,6 +9,7 @@ from app.config.config import settings
from app.core.constants import VALID_IMAGE_RATIOS
from app.domain.openai_models import ImageGenerationRequest
from app.log.logger import get_image_create_logger
from app.utils.helpers import is_image_upload_configured
from app.utils.uploader import ImageUploaderFactory
logger = get_image_create_logger()
@@ -97,12 +98,18 @@ class ImageCreateService:
image_data = generated_image.image.image_bytes
image_uploader = None
if request.response_format == "b64_json":
# Return base64 if explicitly requested or if no uploader is configured
if (
request.response_format == "b64_json"
or not is_image_upload_configured(settings)
):
base64_image = base64.b64encode(image_data).decode("utf-8")
images_data.append(
{"b64_json": base64_image, "revised_prompt": request.prompt}
)
continue
else:
# Upload to configured provider
current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
@@ -115,6 +122,7 @@ class ImageCreateService:
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER,
api_key=settings.PICGO_API_KEY,
api_url=settings.PICGO_API_URL,
)
elif settings.UPLOAD_PROVIDER == "cloudflare_imgbed":
image_uploader = ImageUploaderFactory.create(
@@ -123,6 +131,16 @@ class ImageCreateService:
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
)
elif settings.UPLOAD_PROVIDER == "aliyun_oss":
image_uploader = ImageUploaderFactory.create(
provider=settings.UPLOAD_PROVIDER,
access_key=settings.OSS_ACCESS_KEY,
access_key_secret=settings.OSS_ACCESS_KEY_SECRET,
bucket_name=settings.OSS_BUCKET_NAME,
endpoint=settings.OSS_ENDPOINT,
region=settings.OSS_REGION,
use_internal=False
)
else:
raise ValueError(
f"Unsupported upload provider: {settings.UPLOAD_PROVIDER}"

View File

@@ -1,7 +1,4 @@
import datetime
import json
import re
import time
from typing import Any, AsyncGenerator, Dict, Union
@@ -11,20 +8,21 @@ from app.database.services import (
add_request_log,
)
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
from app.log.logger import get_openai_compatible_logger
from app.service.client.api_client import OpenaiApiClient
from app.service.key.key_manager import KeyManager
from app.utils.helpers import redact_key_for_logging
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)
@@ -37,10 +35,12 @@ class OpenAICompatiableService:
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参数目前不支持该参数
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)
return await self._handle_normal_completion(
request.model, request_dict, api_key
)
async def generate_images(
self,
@@ -78,13 +78,9 @@ class OpenAICompatiableService:
return response
except Exception as e:
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.error(f"Normal API call failed with error: {error_log_msg}")
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=api_key,
@@ -136,15 +132,11 @@ class OpenAICompatiableService:
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
status_code = e.args[0]
error_log_msg = e.args[1]
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
await add_error_log(
gemini_key=current_attempt_key,
@@ -152,7 +144,10 @@ class OpenAICompatiableService:
error_type="openai-compatiable-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
request_msg=(
payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
),
request_datetime=request_datetime,
)
if self.key_manager:
@@ -160,19 +155,21 @@ class OpenAICompatiableService:
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
logger.info(
f"Switched to new API key: {redact_key_for_logging(api_key)}"
)
else:
logger.error(
f"No valid API key available after {retries} retries."
)
break
raise
else:
logger.error("KeyManager not available for retry logic.")
break
break
if retries >= max_retries:
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
@@ -184,8 +181,3 @@ class OpenAICompatiableService:
latency_ms=latency_ms,
request_time=request_datetime,
)
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

@@ -2,12 +2,12 @@
Service for request log operations.
"""
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from sqlalchemy import delete
from app.database.connection import database
from app.config.config import settings
from app.database.connection import database
from app.database.models import RequestLog
from app.log.logger import get_request_log_logger
@@ -30,7 +30,7 @@ async def delete_old_request_logs_task():
)
try:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
query = delete(RequestLog).where(RequestLog.request_time < cutoff_date)

View File

@@ -146,7 +146,7 @@ class StatsService:
period: 时间段标识 ('1m', '1h', '24h')
Returns:
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status, status_code, latency_ms, error_log_id(可选)
Raises:
ValueError: 如果 period 无效
@@ -156,6 +156,8 @@ class StatsService:
start_time = now - datetime.timedelta(minutes=1)
elif period == "1h":
start_time = now - datetime.timedelta(hours=1)
elif period == "8h":
start_time = now - datetime.timedelta(hours=8)
elif period == "24h":
start_time = now - datetime.timedelta(hours=24)
else:
@@ -167,7 +169,8 @@ class StatsService:
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code,
RequestLog.status_code.label("status_code"),
RequestLog.latency_ms.label("latency_ms"),
)
.where(RequestLog.request_time >= start_time)
.order_by(RequestLog.request_time.desc())
@@ -175,31 +178,127 @@ class StatsService:
results = await database.fetch_all(query)
details = []
details: list[dict] = []
for row in results:
status = "failure"
if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure"
details.append(
{
"timestamp": row[
"timestamp"
].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
}
)
record = {
"timestamp": row["timestamp"].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
"status_code": row["status_code"],
"latency_ms": row["latency_ms"],
}
details.append(record)
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}")
logger.error(f"Failed to get API call details for period '{period}': {e}")
raise
async def get_key_call_details(self, key: str, period: str) -> list[dict]:
"""获取指定密钥在指定时间段内的调用详情 (与 get_api_call_details 结构一致)"""
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 == "8h":
start_time = now - datetime.timedelta(hours=8)
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.label("status_code"),
RequestLog.latency_ms.label("latency_ms"),
)
.where(RequestLog.request_time >= start_time, RequestLog.api_key == key)
.order_by(RequestLog.request_time.desc())
)
results = await database.fetch_all(query)
details: list[dict] = []
for row in results:
status = "failure"
if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure"
record = {
"timestamp": row["timestamp"].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
"status_code": row["status_code"],
"latency_ms": row["latency_ms"],
}
details.append(record)
logger.info(
f"Retrieved {len(details)} key call details for key=...{key[-4:] if key else ''} period '{period}'"
)
return details
except Exception as e:
logger.error(
f"Failed to get key call details for key=...{key[-4:] if key else ''} period '{period}': {e}"
)
raise
async def get_attention_keys_last_24h(
self, include_keys: set[str], limit: int = 20, status_code: int = 429
) -> list[dict]:
"""返回最近24小时内指定状态码(默认429)最多的Key列表仅包含include_keys中的Key。
Returns: [{"key": str, "count": int, "status_code": int}, ...] 按次数降序
"""
try:
now = datetime.datetime.now()
start_time = now - datetime.timedelta(hours=24)
if not include_keys:
return []
query = (
select(
RequestLog.api_key.label("key"),
func.count(RequestLog.id).label("count"),
)
.where(
RequestLog.request_time >= start_time,
RequestLog.status_code == status_code,
RequestLog.api_key.isnot(None),
RequestLog.api_key.in_(list(include_keys)),
)
.group_by(RequestLog.api_key)
.order_by(func.count(RequestLog.id).desc())
.limit(limit)
)
rows = await database.fetch_all(query)
return [
{"key": row["key"], "count": row["count"], "status_code": status_code}
for row in rows
if row["key"]
]
except Exception as e:
logger.error(
f"Failed to get attention keys ({status_code}) in last 24h: {e}"
)
return []
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
"""
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
@@ -220,8 +319,7 @@ class StatsService:
try:
query = (
select(
RequestLog.model_name, func.count(
RequestLog.id).label("call_count")
RequestLog.model_name, func.count(RequestLog.id).label("call_count")
)
.where(
RequestLog.api_key == key,
@@ -240,8 +338,7 @@ class StatsService:
)
return {}
usage_details = {row["model_name"]: row["call_count"]
for row in results}
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}"
)

315
app/static/css/fonts.css Normal file
View File

@@ -0,0 +1,315 @@
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -16,6 +16,13 @@ const PROXY_REGEX =
const VERTEX_API_KEY_REGEX = /AQ\.[a-zA-Z0-9_\-]{50}/g; // 新增 Vertex Express API Key 正则
const MASKED_VALUE = "••••••••";
// API Keys Pagination Constants
const API_KEYS_PER_PAGE = 20; // 每页显示的API密钥数量
let currentApiKeyPage = 1;
let totalApiKeyPages = 1;
let allApiKeys = []; // 存储所有API密钥数据
let filteredApiKeys = []; // 存储过滤后的API密钥数据
// DOM Elements - Global Scope for frequently accessed elements
const safetySettingsContainer = document.getElementById(
"SAFETY_SETTINGS_container"
@@ -97,6 +104,24 @@ document.addEventListener("DOMContentLoaded", function () {
});
}
// 检查间隔小时数输入控制
const checkIntervalInput = document.getElementById("CHECK_INTERVAL_HOURS");
if (checkIntervalInput) {
checkIntervalInput.addEventListener("input", function () {
let value = parseFloat(this.value);
if (isNaN(value) || value < 0) {
this.value = 0;
}
});
checkIntervalInput.addEventListener("change", function () {
let value = parseFloat(this.value);
if (isNaN(value) || value < 0) {
this.value = 0;
}
});
}
// Toggle switch events
const toggleSwitches = document.querySelectorAll(".toggle-switch");
toggleSwitches.forEach((toggleSwitch) => {
@@ -147,6 +172,17 @@ document.addEventListener("DOMContentLoaded", function () {
if (apiKeySearchInput)
apiKeySearchInput.addEventListener("input", handleApiKeySearch);
// API Key Pagination Event Listeners
const apiKeyPrevBtn = document.getElementById("apiKeyPrevBtn");
const apiKeyNextBtn = document.getElementById("apiKeyNextBtn");
if (apiKeyPrevBtn) {
apiKeyPrevBtn.addEventListener("click", prevApiKeyPage);
}
if (apiKeyNextBtn) {
apiKeyNextBtn.addEventListener("click", nextApiKeyPage);
}
// Bulk Delete API Key Modal Elements and Events
const bulkDeleteApiKeyBtn = document.getElementById("bulkDeleteApiKeyBtn");
const closeBulkDeleteModalBtn = document.getElementById(
@@ -753,6 +789,10 @@ async function initConfig() {
if (typeof config.AUTO_DELETE_ERROR_LOGS_DAYS === "undefined") {
config.AUTO_DELETE_ERROR_LOGS_DAYS = 7;
}
// 错误日志是否记录请求体(默认不记录)
if (typeof config.ERROR_LOG_RECORD_REQUEST_BODY === "undefined") {
config.ERROR_LOG_RECORD_REQUEST_BODY = false;
}
// --- 结束:处理自动删除错误日志配置的默认值 ---
// --- 新增:处理自动删除请求日志配置的默认值 ---
@@ -924,9 +964,9 @@ function populateForm(config) {
'<div class="text-gray-500 text-sm italic">添加自定义请求头,例如 X-Api-Key: your-key</div>';
}
// 4. Populate other array fields (excluding THINKING_MODELS)
// 4. Populate other array fields (excluding THINKING_MODELS and API_KEYS)
for (const [key, value] of Object.entries(config)) {
if (Array.isArray(value) && key !== "THINKING_MODELS") {
if (Array.isArray(value) && key !== "THINKING_MODELS" && key !== "API_KEYS") {
const container = document.getElementById(`${key}_container`);
if (container) {
value.forEach((itemValue) => {
@@ -940,6 +980,17 @@ function populateForm(config) {
}
}
// 4.1. 特殊处理API_KEYS - 使用分页
if (Array.isArray(config.API_KEYS)) {
allApiKeys = config.API_KEYS.filter(key =>
typeof key === "string" && key.trim() !== ""
);
filteredApiKeys = [...allApiKeys];
currentApiKeyPage = 1;
renderApiKeyPage();
updateApiKeyPagination();
}
// 5. Populate non-array/non-budget fields
for (const [key, value] of Object.entries(config)) {
if (
@@ -1062,44 +1113,31 @@ function populateForm(config) {
* Handles the bulk addition of API keys from the modal input.
*/
function handleBulkAddApiKeys() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!apiKeyBulkInput || !apiKeyContainer || !apiKeyModal) return;
if (!apiKeyBulkInput || !apiKeyModal) return;
const bulkText = apiKeyBulkInput.value;
const extractedKeys = bulkText.match(API_KEY_REGEX) || [];
const currentKeyInputs = apiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
let currentKeys = Array.from(currentKeyInputs)
.map((input) => {
return input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value")
: input.value;
})
.filter((key) => key && key.trim() !== "" && key !== MASKED_VALUE);
const combinedKeys = new Set([...currentKeys, ...extractedKeys]);
// 合并现有密钥和新密钥,去重
const combinedKeys = new Set([...allApiKeys, ...extractedKeys]);
const uniqueKeys = Array.from(combinedKeys);
apiKeyContainer.innerHTML = ""; // Clear existing items more directly
// 更新全局密钥数组
allApiKeys = uniqueKeys;
// 更新过滤后的数组
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
if (!searchTerm) {
filteredApiKeys = [...allApiKeys];
} else {
filteredApiKeys = allApiKeys.filter(key =>
key.toLowerCase().includes(searchTerm)
);
}
uniqueKeys.forEach((key) => {
addArrayItemWithValue("API_KEYS", key);
});
const newKeyInputs = apiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
newKeyInputs.forEach((input) => {
if (configForm && typeof initializeSensitiveFields === "function") {
const focusoutEvent = new Event("focusout", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(focusoutEvent);
}
});
// 重新渲染当前页
renderApiKeyPage();
updateApiKeyPagination();
closeModal(apiKeyModal);
showNotification(`添加/更新了 ${uniqueKeys.length} 个唯一密钥`, "success");
@@ -1109,32 +1147,139 @@ function handleBulkAddApiKeys() {
* Handles searching/filtering of API keys in the list.
*/
function handleApiKeySearch() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!apiKeySearchInput || !apiKeyContainer) return;
if (!apiKeySearchInput) return;
const searchTerm = apiKeySearchInput.value.toLowerCase();
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
keyItems.forEach((item) => {
const input = item.querySelector(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
// 过滤API密钥
if (!searchTerm) {
filteredApiKeys = [...allApiKeys];
} else {
filteredApiKeys = allApiKeys.filter(key =>
key.toLowerCase().includes(searchTerm)
);
if (input) {
const realValue = input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value").toLowerCase()
: input.value.toLowerCase();
item.style.display = realValue.includes(searchTerm) ? "flex" : "none";
}
}
// 重置到第一页
currentApiKeyPage = 1;
// 重新渲染当前页
renderApiKeyPage();
updateApiKeyPagination();
}
/**
* 渲染当前页的API密钥
*/
function renderApiKeyPage() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!apiKeyContainer) return;
// 清空容器
apiKeyContainer.innerHTML = "";
// 计算当前页的数据范围
const startIndex = (currentApiKeyPage - 1) * API_KEYS_PER_PAGE;
const endIndex = Math.min(startIndex + API_KEYS_PER_PAGE, filteredApiKeys.length);
const pageKeys = filteredApiKeys.slice(startIndex, endIndex);
// 渲染当前页的密钥
pageKeys.forEach((key) => {
addArrayItemWithValue("API_KEYS", key);
});
// 如果没有密钥,显示提示信息
if (pageKeys.length === 0) {
const emptyMessage = document.createElement("div");
emptyMessage.className = "text-gray-500 text-sm italic text-center py-4";
emptyMessage.textContent = filteredApiKeys.length === 0 ?
(allApiKeys.length === 0 ? "暂无API密钥" : "未找到匹配的密钥") :
"当前页无数据";
apiKeyContainer.appendChild(emptyMessage);
}
}
/**
* 更新分页控件
*/
function updateApiKeyPagination() {
totalApiKeyPages = Math.max(1, Math.ceil(filteredApiKeys.length / API_KEYS_PER_PAGE));
// 确保当前页在有效范围内
if (currentApiKeyPage > totalApiKeyPages) {
currentApiKeyPage = totalApiKeyPages;
}
const paginationContainer = document.getElementById("apiKeyPagination");
if (!paginationContainer) return;
// 如果只有一页或没有数据,隐藏分页控件
if (totalApiKeyPages <= 1) {
paginationContainer.style.display = "none";
return;
}
paginationContainer.style.display = "flex";
// 更新页码信息
const pageInfo = document.getElementById("apiKeyPageInfo");
if (pageInfo) {
pageInfo.textContent = `${currentApiKeyPage} 页,共 ${totalApiKeyPages} 页 (${filteredApiKeys.length} 个密钥)`;
}
// 更新按钮状态
const prevBtn = document.getElementById("apiKeyPrevBtn");
const nextBtn = document.getElementById("apiKeyNextBtn");
if (prevBtn) {
prevBtn.disabled = currentApiKeyPage <= 1;
prevBtn.className = currentApiKeyPage <= 1 ?
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
}
if (nextBtn) {
nextBtn.disabled = currentApiKeyPage >= totalApiKeyPages;
nextBtn.className = currentApiKeyPage >= totalApiKeyPages ?
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
}
}
/**
* 跳转到指定页
*/
function goToApiKeyPage(page) {
if (page < 1 || page > totalApiKeyPages) return;
currentApiKeyPage = page;
renderApiKeyPage();
updateApiKeyPagination();
}
/**
* 上一页
*/
function prevApiKeyPage() {
if (currentApiKeyPage > 1) {
goToApiKeyPage(currentApiKeyPage - 1);
}
}
/**
* 下一页
*/
function nextApiKeyPage() {
if (currentApiKeyPage < totalApiKeyPages) {
goToApiKeyPage(currentApiKeyPage + 1);
}
}
/**
* Handles the bulk deletion of API keys based on input from the modal.
*/
function handleBulkDeleteApiKeys() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!bulkDeleteApiKeyInput || !apiKeyContainer || !bulkDeleteApiKeyModal)
return;
if (!bulkDeleteApiKeyInput || !bulkDeleteApiKeyModal) return;
const bulkText = bulkDeleteApiKeyInput.value;
if (!bulkText.trim()) {
@@ -1149,24 +1294,30 @@ function handleBulkDeleteApiKeys() {
return;
}
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
// 从allApiKeys数组中删除匹配的密钥
let deleteCount = 0;
keyItems.forEach((item) => {
const input = item.querySelector(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
const realValue =
input &&
(input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value")
: input.value);
if (realValue && keysToDelete.has(realValue)) {
item.remove();
allApiKeys = allApiKeys.filter(key => {
if (keysToDelete.has(key)) {
deleteCount++;
return false;
}
return true;
});
// 更新过滤后的数组
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
if (!searchTerm) {
filteredApiKeys = [...allApiKeys];
} else {
filteredApiKeys = allApiKeys.filter(key =>
key.toLowerCase().includes(searchTerm)
);
}
// 重新渲染当前页
renderApiKeyPage();
updateApiKeyPagination();
closeModal(bulkDeleteApiKeyModal);
if (deleteCount > 0) {
@@ -1782,6 +1933,15 @@ function collectFormData() {
const arrayContainers = document.querySelectorAll(".array-container");
arrayContainers.forEach((container) => {
const key = container.id.replace("_container", "");
// 特殊处理API_KEYS - 使用全局数组而不是DOM元素
if (key === "API_KEYS") {
formData[key] = allApiKeys.filter(
(value) => value && value.trim() !== "" && value !== MASKED_VALUE
);
return;
}
const arrayInputs = container.querySelectorAll(`.${ARRAY_INPUT_CLASS}`);
formData[key] = Array.from(arrayInputs)
.map((input) => {

View File

@@ -108,8 +108,14 @@ function initStatItemAnimations() {
// 获取指定类型区域内选中的密钥
function getSelectedKeys(type) {
let selectorRoot;
if (type === 'attention') {
selectorRoot = '#attentionKeysList';
} else {
selectorRoot = `#${type}Keys`;
}
const checkboxes = document.querySelectorAll(
`#${type}Keys .key-checkbox:checked`
`${selectorRoot} .key-checkbox:checked`
);
return Array.from(checkboxes).map((cb) => cb.value);
}
@@ -119,27 +125,27 @@ function updateBatchActions(type) {
const selectedKeys = getSelectedKeys(type);
const count = selectedKeys.length;
const batchActionsDiv = document.getElementById(`${type}BatchActions`);
if (!batchActionsDiv) return;
const selectedCountSpan = document.getElementById(`${type}SelectedCount`);
const buttons = batchActionsDiv.querySelectorAll("button");
if (count > 0) {
batchActionsDiv.classList.remove("hidden");
selectedCountSpan.textContent = count;
if (selectedCountSpan) selectedCountSpan.textContent = count;
buttons.forEach((button) => (button.disabled = false));
} else {
batchActionsDiv.classList.add("hidden");
selectedCountSpan.textContent = "0";
if (selectedCountSpan) selectedCountSpan.textContent = "0";
buttons.forEach((button) => (button.disabled = true));
}
// 更新全选复选框状态
const selectAllCheckbox = document.getElementById(
`selectAll${type.charAt(0).toUpperCase() + type.slice(1)}`
);
const allCheckboxes = document.querySelectorAll(`#${type}Keys .key-checkbox`);
const selectAllId = `selectAll${type.charAt(0).toUpperCase() + type.slice(1)}`;
const selectAllCheckbox = document.getElementById(selectAllId);
const rootId = type === 'attention' ? 'attentionKeysList' : `${type}Keys`;
// 只有在有可见的 key 时才考虑全选状态
const visibleCheckboxes = document.querySelectorAll(
`#${type}Keys li:not([style*="display: none"]) .key-checkbox`
`#${rootId} li:not([style*="display: none"]) .key-checkbox`
);
if (selectAllCheckbox && visibleCheckboxes.length > 0) {
selectAllCheckbox.checked = count === visibleCheckboxes.length;
@@ -153,29 +159,28 @@ function updateBatchActions(type) {
// 全选/取消全选指定类型的密钥
function toggleSelectAll(type, isChecked) {
const listElement = document.getElementById(`${type}Keys`);
// Select checkboxes within LI elements that are NOT styled with display:none
// This targets currently visible items based on filtering.
const rootId = type === 'attention' ? 'attentionKeysList' : `${type}Keys`;
const listElement = document.getElementById(rootId);
if (!listElement) return;
const visibleCheckboxes = listElement.querySelectorAll(
`li:not([style*="display: none"]) .key-checkbox`
);
visibleCheckboxes.forEach((checkbox) => {
checkbox.checked = isChecked;
const listItem = checkbox.closest("li[data-key]"); // Get the LI from the current DOM
const listItem = checkbox.closest("li[data-key]");
if (listItem) {
listItem.classList.toggle("selected", isChecked);
// Sync with master array
const key = listItem.dataset.key;
const masterList = type === "valid" ? allValidKeys : allInvalidKeys;
if (masterList) {
// Ensure masterList is defined
const masterListItem = masterList.find((li) => li.dataset.key === key);
if (masterListItem) {
const masterCheckbox = masterListItem.querySelector(".key-checkbox");
if (masterCheckbox) {
masterCheckbox.checked = isChecked;
if (type !== 'attention') {
const key = listItem.dataset.key;
const masterList = type === "valid" ? allValidKeys : allInvalidKeys;
if (masterList) {
const masterListItem = masterList.find((li) => li.dataset.key === key);
if (masterListItem) {
const masterCheckbox = masterListItem.querySelector(".key-checkbox");
if (masterCheckbox) {
masterCheckbox.checked = isChecked;
}
}
}
}
@@ -346,7 +351,8 @@ function showResetModal(type) {
// 设置确认按钮事件
confirmButton.onclick = () => executeResetAll(type);
// 显示模态框
// 显示模态框,确保位于最上层
modalElement.style.zIndex = '1001';
modalElement.classList.remove("hidden");
}
@@ -1161,20 +1167,21 @@ function initializeKeySelectionListeners() {
if (listItem) {
listItem.classList.toggle("selected", checkbox.checked);
// Sync with master array
const key = listItem.dataset.key;
const masterList =
keyType === "valid" ? allValidKeys : allInvalidKeys;
if (masterList) {
// Ensure masterList is defined
const masterListItem = masterList.find(
(li) => li.dataset.key === key
);
if (masterListItem) {
const masterCheckbox =
masterListItem.querySelector(".key-checkbox");
if (masterCheckbox) {
masterCheckbox.checked = checkbox.checked;
// Sync with master array (only for valid/invalid lists)
if (keyType !== 'attention') {
const key = listItem.dataset.key;
const masterList =
keyType === "valid" ? allValidKeys : allInvalidKeys;
if (masterList) {
const masterListItem = masterList.find(
(li) => li.dataset.key === key
);
if (masterListItem) {
const masterCheckbox =
masterListItem.querySelector(".key-checkbox");
if (masterCheckbox) {
masterCheckbox.checked = checkbox.checked;
}
}
}
}
@@ -1186,50 +1193,9 @@ function initializeKeySelectionListeners() {
setupEventListenersForList("validKeys", "valid");
setupEventListenersForList("invalidKeys", "invalid");
setupEventListenersForList("attentionKeysList", "attention");
}
function initializeAutoRefreshControls() {
const autoRefreshToggle = document.getElementById("autoRefreshToggle");
const autoRefreshIntervalTime = 60000; // 60秒
let autoRefreshTimer = null;
function startAutoRefresh() {
if (autoRefreshTimer) return;
console.log("启动自动刷新...");
showNotification("自动刷新已启动", "info", 2000);
autoRefreshTimer = setInterval(() => {
console.log("自动刷新 keys_status 页面...");
location.reload();
}, autoRefreshIntervalTime);
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
console.log("停止自动刷新...");
showNotification("自动刷新已停止", "info", 2000);
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
if (autoRefreshToggle) {
const isAutoRefreshEnabled =
localStorage.getItem("autoRefreshEnabled") === "true";
autoRefreshToggle.checked = isAutoRefreshEnabled;
if (isAutoRefreshEnabled) {
startAutoRefresh();
}
autoRefreshToggle.addEventListener("change", () => {
if (autoRefreshToggle.checked) {
localStorage.setItem("autoRefreshEnabled", "true");
startAutoRefresh();
} else {
localStorage.setItem("autoRefreshEnabled", "false");
stopAutoRefresh();
}
});
}
}
// Debounce function
function debounce(func, delay) {
@@ -1478,6 +1444,261 @@ function initializeDropdownMenu() {
}
}
// --- Chart: API success/failure over time ---
let apiStatsChart = null;
function buildChartConfig(labels, successData, failureData) {
return {
type: 'line',
data: {
labels,
datasets: [
{
label: '成功',
data: successData,
borderColor: 'rgba(16,185,129,1)', // emerald-500
backgroundColor: 'rgba(16,185,129,0.15)',
tension: 0.3,
fill: true,
pointRadius: 2,
},
{
label: '失败',
data: failureData,
borderColor: 'rgba(239,68,68,1)', // red-500
backgroundColor: 'rgba(239,68,68,0.15)',
tension: 0.3,
fill: true,
pointRadius: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: { mode: 'index', intersect: false },
},
interaction: { mode: 'nearest', axis: 'x', intersect: false },
scales: {
x: { title: { display: true, text: '时间' } },
y: { title: { display: true, text: '调用次数' }, beginAtZero: true, ticks: { precision: 0 } },
},
},
};
}
async function fetchPeriodDetails(period) {
// Uses backend endpoint /api/stats/details?period={period}
return await fetchAPI(`/api/stats/details?period=${period}`);
}
function bucketizeDetails(period, details) {
// details is expected to be an array of call records with fields: timestamp, status
// Build buckets depending on period
const buckets = new Map();
const addToBucket = (key, isSuccess) => {
if (!buckets.has(key)) buckets.set(key, { success: 0, failure: 0 });
const obj = buckets.get(key);
if (isSuccess) obj.success += 1; else obj.failure += 1;
};
const toKey = (ts) => {
const d = new Date(ts);
if (period === '1m') {
// bucket by second within last minute
const mm = String(d.getMinutes()).padStart(2,'0');
const ss = String(d.getSeconds()).padStart(2,'0');
return `${mm}:${ss}`;
} else if (period === '1h') {
// bucket by minute
const HH = String(d.getHours()).padStart(2,'0');
const mm = String(d.getMinutes()).padStart(2,'0');
return `${HH}:${mm}`;
} else if (period === '8h') {
// bucket by hour for 8h window (same as 24h)
const MM = String(d.getMonth()+1).padStart(2,'0');
const DD = String(d.getDate()).padStart(2,'0');
const HH = String(d.getHours()).padStart(2,'0');
return `${MM}-${DD} ${HH}:00`;
} else {
// 24h: bucket by hour
const MM = String(d.getMonth()+1).padStart(2,'0');
const DD = String(d.getDate()).padStart(2,'0');
const HH = String(d.getHours()).padStart(2,'0');
return `${MM}-${DD} ${HH}:00`;
}
};
(details || []).forEach((call) => {
const key = toKey(call.timestamp);
const isSuccess = call.status === 'success';
addToBucket(key, isSuccess);
});
// sort labels chronologically by parsing back to date when possible
const labels = Array.from(buckets.keys()).sort((a,b)=>{
// Try to create date objects relative to today for ordering; fallback to string compare
const da = Date.parse(a) || 0;
const db = Date.parse(b) || 0;
if (da && db) return da - db;
return a.localeCompare(b);
});
const successData = labels.map(l => buckets.get(l).success);
const failureData = labels.map(l => buckets.get(l).failure);
return { labels, successData, failureData };
}
async function renderApiChart(period) {
const canvas = document.getElementById('apiStatsChart');
if (!canvas || typeof Chart === 'undefined') return;
try {
const details = await fetchPeriodDetails(period);
const { labels, successData, failureData } = bucketizeDetails(period, details || []);
const cfg = buildChartConfig(labels, successData, failureData);
if (apiStatsChart) {
apiStatsChart.destroy();
}
apiStatsChart = new Chart(canvas.getContext('2d'), cfg);
} catch (e) {
console.error('Failed to render chart:', e);
}
}
// --- Helpers for Attention Keys panel ---
// track current active status code tab
let currentStatus = 429;
function getLimit() {
const el = document.getElementById('attentionLimitInput');
const v = parseInt(el && el.value, 10);
if (isNaN(v)) return 10;
// clamp between 1 and 1000 to match input limits
return Math.min(1000, Math.max(1, v));
}
async function fetchAndRenderAttentionKeys(statusCode = 429, limit = 10) {
const listEl = document.getElementById('attentionKeysList');
if (!listEl) return;
try {
const data = await fetchAPI(`/api/stats/attention-keys?status_code=${statusCode}&limit=${limit}`);
listEl.innerHTML = '';
if (!data || (Array.isArray(data) && data.length === 0) || data.error) {
listEl.innerHTML = '<li class="text-center text-gray-500 py-2">暂无需要注意的Key</li>';
updateBatchActions('attention');
return;
}
data.forEach(item => {
const li = document.createElement('li');
li.className = 'flex items-center justify-between bg-white rounded border px-3 py-2';
li.dataset.key = item.key || '';
const masked = item.key ? `${item.key.substring(0,4)}...${item.key.substring(item.key.length-4)}` : 'N/A';
const code = item.status_code ?? statusCode;
li.innerHTML = `
<div class="flex items-center gap-3">
<input type="checkbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded key-checkbox" value="${item.key || ''}">
<span class="font-mono text-sm">${masked}</span>
<span class="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded">${code}: ${item.count}</span>
</div>
<div class="flex items-center gap-2">
<button class="px-2 py-1 text-xs rounded bg-success-600 hover:bg-success-700 text-white" title="验证此Key">验证</button>
<button class="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white" title="查看24小时详情">详情</button>
<button class="px-2 py-1 text-xs rounded bg-blue-500 hover:bg-blue-600 text-white" title="复制Key">复制</button>
<button class="px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-900 text-white" title="删除此Key">删除</button>
</div>`;
const [verifyBtn, detailBtn, copyBtn, deleteBtn] = li.querySelectorAll('button');
verifyBtn.addEventListener('click', (e) => { e.stopPropagation(); verifyKey(item.key, e.currentTarget); });
detailBtn.addEventListener('click', (e) => { e.stopPropagation(); window.showKeyUsageDetails(item.key); });
copyBtn.addEventListener('click', (e) => { e.stopPropagation(); copyKey(item.key); });
deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); showSingleKeyDeleteConfirmModal(item.key, e.currentTarget); });
// Checkbox change updates batch actions
const checkbox = li.querySelector('.key-checkbox');
if (checkbox) {
checkbox.addEventListener('change', () => updateBatchActions('attention'));
}
listEl.appendChild(li);
});
updateBatchActions('attention');
} catch (e) {
listEl.innerHTML = `<li class="text-center text-red-500 py-2">加载失败: ${e.message}</li>`;
updateBatchActions('attention');
}
}
function initChartControls() {
const btn1h = document.getElementById('chartBtn1h');
const btn8h = document.getElementById('chartBtn8h');
const btn24h = document.getElementById('chartBtn24h');
const setActive = (activeBtn) => {
[btn1h, btn8h, btn24h].forEach(btn => {
if (!btn) return;
if (btn === activeBtn) {
btn.classList.remove('bg-gray-200');
btn.classList.add('bg-primary-600','text-white');
} else {
btn.classList.add('bg-gray-200');
btn.classList.remove('bg-primary-600','text-white');
}
});
};
if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); });
if (btn8h) btn8h.addEventListener('click', async () => { setActive(btn8h); await renderApiChart('8h'); });
if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); });
// default period
if (btn1h) setActive(btn1h);
renderApiChart('1h');
}
function initAttentionKeysControls() {
const btn429 = document.getElementById('attentionErr429');
const btn403 = document.getElementById('attentionErr403');
const btn400 = document.getElementById('attentionErr400');
// 修复:补充获取数量输入框,避免未声明变量导致初始化报错
const limitInput = document.getElementById('attentionLimitInput');
const setActive = (activeBtn) => {
[btn429, btn403, btn400].forEach(btn => {
if (!btn) return;
if (btn === activeBtn) {
btn.classList.remove('bg-gray-200');
btn.classList.add('bg-primary-600','text-white');
} else {
btn.classList.add('bg-gray-200');
btn.classList.remove('bg-primary-600','text-white');
}
});
};
if (btn429) btn429.addEventListener('click', () => { setActive(btn429); currentStatus = 429; fetchAndRenderAttentionKeys(429, getLimit()); });
if (btn403) btn403.addEventListener('click', () => { setActive(btn403); currentStatus = 403; fetchAndRenderAttentionKeys(403, getLimit()); });
if (btn400) btn400.addEventListener('click', () => { setActive(btn400); currentStatus = 400; fetchAndRenderAttentionKeys(400, getLimit()); });
// 自定义查询
const input = document.getElementById('attentionErrCustom');
const go = document.getElementById('attentionErrGo');
const trigger = () => {
if (!input) return;
const val = parseInt(input.value, 10);
if (!isNaN(val) && val >= 100 && val <= 599) {
setActive(null);
[btn429, btn403, btn400].forEach(btn=>{ if(btn){ btn.classList.add('bg-gray-200'); btn.classList.remove('bg-primary-600','text-white'); }});
currentStatus = val;
fetchAndRenderAttentionKeys(val, getLimit());
} else {
showNotification('请输入100-599之间的HTTP状态码', 'warning');
}
};
if (go) go.addEventListener('click', trigger);
if (input) input.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ trigger(); }});
// limit变化实时刷新当前状态码
if (limitInput) limitInput.addEventListener('change', () => {
fetchAndRenderAttentionKeys(currentStatus, getLimit());
});
if (btn429) setActive(btn429); // default active
}
// 初始化
document.addEventListener("DOMContentLoaded", () => {
initializePageAnimationsAndEffects();
@@ -1485,10 +1706,12 @@ document.addEventListener("DOMContentLoaded", () => {
initializeKeyFilterControls();
initializeGlobalBatchVerificationHandlers();
initializeKeySelectionListeners();
initializeAutoRefreshControls();
initializeKeyPaginationAndSearch(); // This will also handle initial display
registerServiceWorker();
initializeDropdownMenu(); // 初始化下拉菜单
initChartControls(); // 初始化图表与时间区间切换
initAttentionKeysControls(); // 初始化值得注意的Key错误码切换
fetchAndRenderAttentionKeys(429, 10); // 默认渲染429数量10
// Initial batch actions update might be needed if not covered by displayPage
// updateBatchActions('valid');
@@ -1744,6 +1967,82 @@ async function showApiCallDetails(
}
}
// 获取并显示错误日志详情通过日志ID
async function fetchAndShowErrorDetail(logId) {
try {
const detail = await fetchAPI(`/api/logs/errors/${logId}/details`);
if (!detail) {
showResultModal(false, `未找到日志 ${logId}`, false);
return;
}
const container = document.createElement('div');
container.className = 'space-y-3 text-sm';
const basic = document.createElement('div');
basic.innerHTML = `
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
`;
const codeBlock = document.createElement('pre');
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
codeBlock.textContent = detail.error_log || '无错误日志内容';
const reqBlock = document.createElement('pre');
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
reqBlock.textContent = detail.request_msg || '';
container.appendChild(basic);
container.appendChild(codeBlock);
if (detail.request_msg) container.appendChild(reqBlock);
showResultModal(false, container, false);
} catch (e) {
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
}
}
// 新增:根据 key / 状态码 / 时间窗口(±100秒) 查询并显示错误日志详情
async function fetchAndShowErrorDetailByInfo(geminiKey, statusCode, timestampISO) {
try {
if (!geminiKey || !timestampISO) {
showResultModal(false, '缺少必要参数,无法查询错误详情', false);
return;
}
const params = new URLSearchParams();
params.set('gemini_key', geminiKey);
params.set('timestamp', timestampISO);
if (statusCode !== null && statusCode !== undefined) {
params.set('status_code', String(statusCode));
}
params.set('window_seconds', '100');
const detail = await fetchAPI(`/api/logs/errors/lookup?${params.toString()}`);
if (!detail) {
showResultModal(false, '未找到匹配的错误日志', false);
return;
}
const container = document.createElement('div');
container.className = 'space-y-3 text-sm';
const basic = document.createElement('div');
basic.innerHTML = `
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
<div><span class="font-semibold">错误码:</span> ${detail.error_code ?? 'N/A'}</div>
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
`;
const codeBlock = document.createElement('pre');
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
codeBlock.textContent = detail.error_log || '无错误日志内容';
const reqBlock = document.createElement('pre');
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
reqBlock.textContent = detail.request_msg || '';
container.appendChild(basic);
container.appendChild(codeBlock);
if (detail.request_msg) container.appendChild(reqBlock);
showResultModal(false, container, false);
} catch (e) {
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
}
}
// 关闭 API 调用详情模态框
function closeApiCallDetailsModal() {
const modal = document.getElementById("apiCallDetailsModal");
@@ -1767,23 +2066,33 @@ function renderApiCallDetails(
successCalls !== undefined &&
failureCalls !== undefined
) {
const total = Number(totalCalls) || 0;
const succ = Number(successCalls) || 0;
const fail = Number(failureCalls) || 0;
const denom = total > 0 ? total : succ + fail;
const succRate = denom > 0 ? ((succ / denom) * 100).toFixed(1) : '0.0';
const failRate = denom > 0 ? ((fail / denom) * 100).toFixed(1) : '0.0';
summaryHtml = `
<div class="mb-4 p-3 bg-white dark:bg-gray-700 rounded-lg">
<h4 class="font-semibold text-gray-700 dark:text-gray-200 mb-2 text-md border-b pb-1.5 dark:border-gray-600">期间调用概览:</h4>
<div class="grid grid-cols-3 gap-2 text-center">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">总计</p>
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">${totalCalls}</p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">成功</p>
<p class="text-lg font-bold text-success-600 dark:text-success-400">${successCalls}</p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">失败</p>
<p class="text-lg font-bold text-danger-600 dark:text-danger-400">${failureCalls}</p>
</div>
</div>
<div class="mb-4">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded-lg overflow-hidden">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">总计</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">失败</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功率</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
<tr>
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-primary-600 dark:text-primary-400">${totalCalls}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${successCalls}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-danger-600 dark:text-danger-400">${failureCalls}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${succRate}%</td>
</tr>
</tbody>
</table>
</div>
`;
}
@@ -1807,7 +2116,10 @@ function renderApiCallDetails(
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">密钥 (部分)</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">模型</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态码</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">耗时(ms)</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">详情</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@@ -1828,17 +2140,25 @@ function renderApiCallDetails(
const statusIcon =
call.status === "success" ? "fa-check-circle" : "fa-times-circle";
const detailsBtn =
call.status === "failure"
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${call.key}', ${call.status_code ?? 'null'}, '${call.timestamp}')">
<i class="fas fa-info-circle mr-1"></i>详情
</button>`
: "-";
tableHtml += `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">${timestamp}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">${keyDisplay}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${
call.model || "N/A"
}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.model || "N/A"}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.status_code ?? "-"}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.latency_ms ?? "-"}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
<i class="fas ${statusIcon} mr-1"></i>
${call.status}
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm">${detailsBtn}</td>
</tr>
`;
});
@@ -1867,67 +2187,122 @@ window.showKeyUsageDetails = async function (key) {
return;
}
// renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数
function renderKeyUsageDetails(data, container) {
if (!data || Object.keys(data).length === 0) {
// 构建内容框架(时间范围按钮 + 图表 + 表格容器)
const controlsHtml = `
<div class="flex items-center gap-2 mb-3 text-xs">
<button id="keyBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
<button id="keyBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
<button id="keyBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
</div>
<div class="h-48 mb-4">
<canvas id="keyUsageChart"></canvas>
</div>
<div id="keyUsageTable"></div>`;
contentArea.innerHTML = controlsHtml;
// 设置标题
titleElement.textContent = `密钥 ${keyDisplay} - 请求详情`;
// 显示模态框
modal.classList.remove("hidden");
let keyUsageChart = null;
function buildKeyChartConfig(labels, successData, failureData) {
return buildChartConfig(labels, successData, failureData);
}
function bucketizeKeyDetails(period, details) {
return bucketizeDetails(period, details);
}
function renderKeyUsageTable(data) {
const container = document.getElementById('keyUsageTable');
if (!container) return;
if (!data || data.length === 0) {
container.innerHTML = `
<div class="text-center py-10 text-gray-500">
<i class="fas fa-info-circle text-3xl"></i>
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
<p class="mt-2">该时间段内没有 API 调用记录。</p>
</div>`;
return;
}
let tableHtml = `
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th>
</tr>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态码</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">耗时(ms)</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">详情</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">`;
const sortedModels = Object.entries(data).sort(
([, countA], [, countB]) => countB - countA
);
sortedModels.forEach(([model, count]) => {
data.forEach((row) => {
const timestamp = new Date(row.timestamp).toLocaleString();
const statusClass = row.status === 'success' ? 'text-success-600' : 'text-danger-600';
const statusIcon = row.status === 'success' ? 'fa-check-circle' : 'fa-times-circle';
const detailsBtn = row.status === 'failure'
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${row.key}', ${row.status_code ?? 'null'}, '${row.timestamp}')">
<i class="fas fa-info-circle mr-1"></i>详情
</button>`
: '-';
tableHtml += `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
</tr>`;
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${timestamp}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.model || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.status_code ?? '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.latency_ms ?? '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm ${statusClass}"><i class="fas ${statusIcon} mr-1"></i>${row.status}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">${detailsBtn}</td>
</tr>`;
});
tableHtml += `
</tbody>
</table>`;
tableHtml += `</tbody></table>`;
container.innerHTML = tableHtml;
}
// 设置标题
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
// 显示模态框并设置加载状态
modal.classList.remove("hidden");
contentArea.innerHTML = `
<div class="text-center py-10">
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
<p class="text-gray-500 mt-2">加载中...</p>
</div>`;
try {
const data = await fetchAPI(`/api/key-usage-details/${key}`);
if (data) {
renderKeyUsageDetails(data, contentArea);
} else {
renderKeyUsageDetails({}, contentArea); // Show empty state if no data
}
} catch (apiError) {
console.error("获取密钥使用详情失败:", apiError);
contentArea.innerHTML = `
<div class="text-center py-10 text-danger-500">
async function renderForPeriod(period) {
try {
const details = await fetchAPI(`/api/stats/key-details?key=${encodeURIComponent(key)}&period=${period}`);
const { labels, successData, failureData } = bucketizeKeyDetails(period, details || []);
const canvas = document.getElementById('keyUsageChart');
if (canvas && typeof Chart !== 'undefined') {
const cfg = buildKeyChartConfig(labels, successData, failureData);
if (keyUsageChart) keyUsageChart.destroy();
keyUsageChart = new Chart(canvas.getContext('2d'), cfg);
}
renderKeyUsageTable(details || []);
} catch (e) {
console.error('加载密钥期内详情失败:', e);
const tableContainer = document.getElementById('keyUsageTable');
if (tableContainer) {
tableContainer.innerHTML = `<div class="text-center py-10 text-danger-500">
<i class="fas fa-exclamation-triangle text-3xl"></i>
<p class="mt-2">加载失败: ${apiError.message}</p>
<p class="mt-2">加载失败: ${e.message}</p>
</div>`;
}
}
}
// 绑定按钮事件与默认加载
const btn1h = document.getElementById('keyBtn1h');
const btn8h = document.getElementById('keyBtn8h');
const btn24h = document.getElementById('keyBtn24h');
const setActive = (activeBtn) => {
[btn1h, btn8h, btn24h].forEach((btn) => {
if (!btn) return;
if (btn === activeBtn) {
btn.classList.remove('bg-gray-200');
btn.classList.add('bg-primary-600','text-white');
} else {
btn.classList.add('bg-gray-200');
btn.classList.remove('bg-primary-600','text-white');
}
});
};
if (btn1h) btn1h.addEventListener('click', () => { setActive(btn1h); renderForPeriod('1h'); });
if (btn8h) btn8h.addEventListener('click', () => { setActive(btn8h); renderForPeriod('8h'); });
if (btn24h) btn24h.addEventListener('click', () => { setActive(btn24h); renderForPeriod('24h'); });
if (btn1h) setActive(btn1h);
renderForPeriod('1h');
};
// 关闭密钥使用详情模态框

File diff suppressed because one or more lines are too long

View File

@@ -51,7 +51,7 @@
</div>
<h2 class="text-3xl font-extrabold text-center text-gray-800 mb-8 animate-slide-down">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
<img src="{{ static_url('icons/logo.png') }}" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance
</h2>

View File

@@ -4,21 +4,21 @@
<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" />
<link rel="manifest" href="{{ static_url('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 rel="icon" href="{{ static_url('icons/icon-192x192.png') }}" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
href="{{ static_url('css/fonts.css') }}"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<script src="https://cdn.tailwindcss.com"></script>
<script src="{{ static_url('js/tailwindcss.js') }}"></script>
<script>
tailwind.config = {
theme: {

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,179 @@ endblock %} {% block head_extra_styles %}
.search-container {
grid-template-columns: 1fr;
}
/* 移动端主容器布局 */
.mobile-buttons-container {
display: flex !important;
flex-direction: column !important;
gap: 1rem !important;
align-items: stretch !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
/* 移动端搜索控件布局优化 */
.mobile-search-controls {
grid-template-columns: 1fr !important;
gap: 0.75rem !important;
width: 100% !important;
margin-bottom: 0.5rem !important;
}
/* 按钮容器在移动端的布局 */
.buttons-container-responsive {
display: flex !important;
flex-direction: column !important;
gap: 0.5rem !important;
width: 100% !important;
align-items: stretch !important;
justify-content: stretch !important;
}
/* 移动端所有按钮样式 */
.buttons-container-responsive button {
width: 100% !important;
max-width: 100% !important;
justify-content: center !important;
text-align: center !important;
min-width: 0 !important;
flex-shrink: 0 !important;
box-sizing: border-box !important;
padding: 0.5rem 1rem !important;
font-size: 0.875rem !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
}
/* 中等屏幕优化 */
@media (max-width: 1024px) and (min-width: 769px) {
.buttons-container-responsive {
flex-wrap: wrap !important;
justify-content: center !important;
}
.buttons-container-responsive button {
flex-shrink: 1 !important;
min-width: 0 !important;
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
}
}
/* 小屏幕(手机)特殊优化 - 确保按钮在边框内 */
@media (max-width: 640px) {
/* 强制重写主容器布局 */
.mobile-buttons-container {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
gap: 1rem !important;
overflow: visible !important;
}
/* 搜索区域在移动端占满宽度 */
.mobile-search-controls {
width: 100% !important;
box-sizing: border-box !important;
}
/* 按钮区域完全重新布局 */
.buttons-container-responsive {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
gap: 0.5rem !important;
padding: 0 !important;
margin: 0 !important;
box-sizing: border-box !important;
overflow: visible !important;
}
/* 所有按钮统一样式 */
.buttons-container-responsive button {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
padding: 0.5rem 1rem !important;
margin: 0 !important;
font-size: 0.875rem !important;
line-height: 1.25rem !important;
border-radius: 0.5rem !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex-shrink: 0 !important;
}
/* 特别针对清空全部按钮 */
#deleteAllLogsBtn {
background-color: #f87171 !important;
border: 1px solid #f87171 !important;
}
#deleteAllLogsBtn:hover {
background-color: #ef4444 !important;
border: 1px solid #ef4444 !important;
}
/* 确保容器不会溢出父级 */
.mobile-buttons-container,
.mobile-buttons-container > *,
.buttons-container-responsive,
.buttons-container-responsive > * {
max-width: 100% !important;
box-sizing: border-box !important;
}
/* 额外的安全边距控制 */
.mobile-buttons-container .grid {
padding-left: 0 !important;
padding-right: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* 确保主内容区域有适当的内边距 */
.rounded-xl.p-6 {
padding-left: 1rem !important;
padding-right: 1rem !important;
}
}
/* 超小屏幕额外优化 */
@media (max-width: 480px) {
.mobile-buttons-container {
gap: 0.75rem !important;
}
.buttons-container-responsive {
gap: 0.4rem !important;
}
.buttons-container-responsive button {
padding: 0.4rem 0.8rem !important;
font-size: 0.8rem !important;
}
/* 主容器内边距进一步缩小 */
.rounded-xl.p-6 {
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
}
/* 确保清空全部按钮文字不会太挤 */
#deleteAllLogsBtn i {
margin-right: 0.25rem !important;
}
}
input[type="text"],
@@ -586,7 +759,7 @@ endblock %} {% block head_extra_styles %}
class="text-3xl font-extrabold text-center text-gray-800 mb-4"
>
<img
src="/static/icons/logo.png"
src="{{ static_url('icons/logo.png') }}"
alt="Gemini Balance Logo"
class="h-9 inline-block align-middle mr-2"
/>
@@ -636,10 +809,10 @@ endblock %} {% block head_extra_styles %}
<!-- 搜索与操作控件 -->
<div
class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"
class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6 mobile-buttons-container"
>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full mobile-search-controls"
>
<input
type="text"
@@ -684,7 +857,7 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<div class="flex items-center gap-3 flex-shrink-0 buttons-container-responsive">
<button
id="searchBtn"
class="flex items-center justify-center px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap"
@@ -1041,7 +1214,7 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
{% endblock %} {% block body_scripts %}
<script src="/static/js/error_logs.js"></script>
<script src="{{ static_url('js/error_logs.js') }}"></script>
<script>
// error_logs.html specific JS initialization (if any)
// e.g., initialize date pickers or other elements if needed

View File

@@ -38,6 +38,18 @@ endblock %} {% block head_extra_styles %}
}
}
/* 让图表卡片在网格中占满整行 */
.stats-card.chart-wide {
grid-column: 1 / -1;
}
/* 图表容器固定高度,配合 Chart.js maintainAspectRatio:false */
.chart-container {
height: 300px;
}
@media (max-width: 640px) {
.chart-container { height: 220px; }
}
/* 统计卡片样式 */
.stats-card {
background-color: rgba(255, 255, 255, 0.95);
@@ -310,12 +322,13 @@ endblock %} {% block head_extra_styles %}
border-color: rgba(59, 130, 246, 0.3);
}
/* 隐藏原生复选框 */
.key-checkbox {
/* 隐藏原生复选框(仅隐藏有效/无效列表中的复选框保留值得注意的Key列表中的复选框可见 */
#validKeys .key-checkbox,
#invalidKeys .key-checkbox {
display: none;
}
/* 自定义复选框样式 */
/* 自定义复选框样式(仅针对有效/无效列表) */
#validKeys li::before,
#invalidKeys li::before {
content: "";
@@ -351,6 +364,31 @@ endblock %} {% block head_extra_styles %}
font-size: 0.8rem;
}
/* 值得注意的Key列表样式与选中态保留原生复选框可见 */
#attentionKeysList li {
display: flex;
align-items: center;
justify-content: space-between;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
transition: all 0.2s ease;
cursor: pointer;
}
#attentionKeysList li:hover {
border-color: rgba(0, 0, 0, 0.12);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
background-color: rgba(249, 250, 251, 0.95);
}
#attentionKeysList li.selected {
background-color: rgba(239, 246, 255, 0.95); /* light blue */
border-color: rgba(59, 130, 246, 0.35);
}
#attentionKeysList .key-checkbox {
margin-right: 0.25rem;
}
.key-text {
color: #374151 !important; /* gray-700 for light theme */
text-shadow: none;
@@ -1096,31 +1134,15 @@ endblock %} {% block head_extra_styles %}
}
</style>
{% endblock %} {% block head_extra_scripts %}
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
<script src="/static/js/keys_status.js"></script>
<!-- Chart.js for time-series chart -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
<!-- Load page script with defer to guarantee DOM is ready and keep execution order -->
<script src="/static/js/keys_status.js" defer></script>
{% endblock %} {% block content %}
<div class="container max-w-6xl mx-auto px-4">
<!-- Increased max-width -->
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
<div class="absolute top-6 right-6 flex items-center gap-3">
<!-- 自动刷新开关 -->
<div class="flex items-center text-sm select-none font-semibold" style="color: #1f2937 !important;">
<span class="mr-2">自动刷新</span>
<div
class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"
>
<input
type="checkbox"
name="autoRefreshToggle"
id="autoRefreshToggle"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
/>
<label
for="autoRefreshToggle"
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
></label>
</div>
</div>
<!-- 手动刷新按钮 -->
<button
class="bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300"
@@ -1263,7 +1285,94 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
</div>
<!-- 可切换时间区间的成功/失败图表卡片 -->
<div class="stats-card chart-wide">
<div class="stats-card-header">
<h3 class="stats-card-title">
<i class="fas fa-chart-bar"></i>
<span>调用趋势图</span>
</h3>
<div class="flex items-center gap-2 text-xs">
<button id="chartBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
<button id="chartBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
<button id="chartBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
</div>
</div>
<div class="p-4 chart-container">
<canvas id="apiStatsChart"></canvas>
</div>
</div>
<!-- 值得注意的 Key 卡片(错误码统计,可切换) -->
<div class="stats-card chart-wide">
<div class="stats-card-header">
<h3 class="stats-card-title">
<i class="fas fa-exclamation-circle"></i>
<span>值得注意的Key24h内错误码最多</span>
</h3>
<div class="flex items-center gap-2 text-xs">
<button id="attentionErr429" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="429 Too Many Requests">429</button>
<button id="attentionErr403" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="403 Forbidden">403</button>
<button id="attentionErr400" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="400 Bad Request">400</button>
<div class="flex items-center gap-1 ml-2">
<input id="attentionErrCustom" type="number" min="100" max="599" placeholder="自定义" class="form-input h-7 w-20 px-2 py-1 text-xs border rounded focus:ring-primary-500 focus:border-primary-500" />
<button id="attentionErrGo" class="px-2 py-1 rounded bg-blue-500 hover:bg-blue-600 text-white" title="查询">查询</button>
</div>
<div class="flex items-center gap-2 ml-3">
<label for="attentionLimitInput" class="text-xs text-gray-600">数量</label>
<input id="attentionLimitInput" type="number" min="1" max="1000" value="10" class="form-input h-7 w-20 px-2 py-1 text-xs border rounded focus:ring-primary-500 focus:border-primary-500" />
<!-- 全选移动到数量输入框右侧 -->
<div class="flex items-center gap-1">
<input
type="checkbox"
id="selectAllAttention"
class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
onchange="toggleSelectAll('attention', this.checked)"
/>
<label for="selectAllAttention" class="text-xs select-none whitespace-nowrap font-semibold" style="color: #1f2937 !important;">全选</label>
</div>
</div>
</div>
</div>
<div class="p-4">
<!-- 批量操作按钮组 (仅在选中时显示) -->
<div
id="attentionBatchActions"
class="p-3 border mb-3 hidden flex items-center flex-wrap gap-3"
style="background-color: rgba(249, 250, 251, 0.95); border-color: rgba(0, 0, 0, 0.08);"
>
<span class="text-sm font-semibold whitespace-nowrap" style="color: #1f2937 !important;">
已选择 <span id="attentionSelectedCount">0</span>
</span>
<button
class="flex items-center gap-1 bg-success-600 hover:bg-success-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:cursor-not-allowed"
onclick="event.stopPropagation(); showVerifyModal('attention', event)"
disabled
>
<i class="fas fa-check-double"></i> 批量验证
</button>
<button
class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:cursor-not-allowed"
onclick="event.stopPropagation(); copySelectedKeys('attention')"
disabled
>
<i class="fas fa-copy"></i> 批量复制
</button>
<button
class="flex items-center gap-1 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:cursor-not-allowed"
onclick="event.stopPropagation(); showDeleteConfirmationModal('attention', event)"
disabled
>
<i class="fas fa-trash-alt"></i> 批量删除
</button>
</div>
<ul id="attentionKeysList" class="space-y-2">
<li class="text-center text-gray-500 py-2">加载中...</li>
</ul>
</div>
</div>
</div>
<!-- 有效密钥区域 -->
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
@@ -1873,7 +1982,8 @@ endblock %} {% block head_extra_styles %}
<!-- 操作结果模态框 -->
<div
id="resultModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center hidden"
style="z-index: 1001;"
>
<div
class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200"

View File

@@ -1,14 +1,17 @@
"""
通用工具函数模块
"""
import json
import re
import base64
import requests
from typing import Dict, Any, List, Optional, Tuple
from pathlib import Path
import logging
import base64
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
from app.config.config import Settings
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
helper_logger = logging.getLogger("app.utils")
@@ -20,23 +23,25 @@ VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
"""
从 base64 字符串中提取 MIME 类型和数据
Args:
base64_string: 可能包含 MIME 类型信息的 base64 字符串
Returns:
tuple: (mime_type, encoded_data)
"""
# 检查字符串是否以 "data:" 格式开始
if base64_string.startswith('data:'):
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
@@ -44,20 +49,20 @@ def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
def convert_image_to_base64(url: str) -> str:
"""
将图片URL转换为base64编码
Args:
url: 图片URL
Returns:
str: base64编码的图片数据
Raises:
Exception: 如果获取图片失败
"""
response = requests.get(url)
if response.status_code == 200:
# 将图片内容转换为base64
img_data = base64.b64encode(response.content).decode('utf-8')
img_data = base64.b64encode(response.content).decode("utf-8")
return img_data
else:
raise Exception(f"Failed to fetch image: {response.status_code}")
@@ -66,64 +71,66 @@ def convert_image_to_base64(url: str) -> str:
def format_json_response(data: Dict[str, Any], indent: int = 2) -> str:
"""
格式化JSON响应
Args:
data: 要格式化的数据
indent: 缩进空格数
Returns:
str: 格式化后的JSON字符串
"""
return json.dumps(data, indent=indent, ensure_ascii=False)
def parse_prompt_parameters(prompt: str, default_ratio: str = "1:1") -> Tuple[str, int, str]:
def parse_prompt_parameters(
prompt: str, default_ratio: str = "1:1"
) -> Tuple[str, int, str]:
"""
从prompt中解析参数
支持的格式:
- {n:数量} 例如: {n:2} 生成2张图片
- {ratio:比例} 例如: {ratio:16:9} 使用16:9比例
Args:
prompt: 提示文本
default_ratio: 默认比例
Returns:
tuple: (清理后的提示文本, 图片数量, 比例)
"""
# 默认值
n = 1
aspect_ratio = default_ratio
# 解析n参数
n_match = re.search(r'{n:(\d+)}', prompt)
n_match = re.search(r"{n:(\d+)}", prompt)
if n_match:
n = int(n_match.group(1))
if n < 1 or n > 4:
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
prompt = prompt.replace(n_match.group(0), '').strip()
# 解析ratio参数
ratio_match = re.search(r'{ratio:(\d+:\d+)}', prompt)
prompt = prompt.replace(n_match.group(0), "").strip()
# 解析ratio参数
ratio_match = re.search(r"{ratio:(\d+:\d+)}", prompt)
if ratio_match:
aspect_ratio = ratio_match.group(1)
if aspect_ratio not in VALID_IMAGE_RATIOS:
raise ValueError(
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(VALID_IMAGE_RATIOS)}"
)
prompt = prompt.replace(ratio_match.group(0), '').strip()
prompt = prompt.replace(ratio_match.group(0), "").strip()
return prompt, n, aspect_ratio
def extract_image_urls_from_markdown(text: str) -> List[str]:
"""
从Markdown文本中提取图片URL
Args:
text: Markdown文本
Returns:
List[str]: 图片URL列表
"""
@@ -135,23 +142,22 @@ def extract_image_urls_from_markdown(text: str) -> List[str]:
def is_valid_api_key(key: str) -> bool:
"""
检查API密钥格式是否有效
Args:
key: API密钥
Returns:
bool: 如果密钥格式有效则返回True
"""
# 检查Gemini API密钥格式
if key.startswith('AIza'):
if key.startswith("AIza"):
return len(key) >= 30
# 检查OpenAI API密钥格式
if key.startswith('sk-'):
return len(key) >= 30
return False
# 检查OpenAI API密钥格式
if key.startswith("sk-"):
return len(key) >= 30
return False
def redact_key_for_logging(key: str) -> str:
@@ -177,15 +183,49 @@ def get_current_version(default_version: str = "0.0.0") -> str:
"""Reads the current version from the VERSION file."""
version_file = VERSION_FILE_PATH
try:
with version_file.open('r', encoding='utf-8') as f:
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}'.")
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}'.")
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}'.")
helper_logger.error(
f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'."
)
return default_version
def is_image_upload_configured(settings: Settings) -> bool:
"""Return True only if a valid upload provider is selected and all required settings for that provider are present."""
provider = (getattr(settings, "UPLOAD_PROVIDER", "") or "").strip().lower()
if provider == "smms":
return bool(getattr(settings, "SMMS_SECRET_TOKEN", ""))
if provider == "picgo":
return bool(getattr(settings, "PICGO_API_KEY", ""))
if provider == "aliyun_oss":
return all(
[
getattr(settings, "OSS_ACCESS_KEY", ""),
getattr(settings, "OSS_ACCESS_KEY_SECRET", ""),
getattr(settings, "OSS_BUCKET_NAME", ""),
getattr(settings, "OSS_ENDPOINT", ""),
getattr(settings, "OSS_REGION", "")
]
)
if provider == "cloudflare_imgbed":
return all(
[
getattr(settings, "CLOUDFLARE_IMGBED_URL", ""),
getattr(settings, "CLOUDFLARE_IMGBED_AUTH_CODE", ""),
]
)
return False

127
app/utils/static_version.py Normal file
View File

@@ -0,0 +1,127 @@
"""
静态资源版本控制工具
用于给CSS和JS文件添加版本参数避免浏览器缓存问题
"""
import hashlib
import time
from functools import lru_cache
from pathlib import Path
from typing import Dict
from app.utils.helpers import get_current_version
class StaticVersionManager:
"""静态资源版本管理器"""
def __init__(self, static_dir: str = "app/static"):
self.static_dir = Path(static_dir)
self._version_cache: Dict[str, str] = {}
self._use_file_hash = True # 是否使用文件哈希作为版本号
def get_version_for_file(self, file_path: str) -> str:
"""
获取文件的版本号
Args:
file_path: 相对于static目录的文件路径'css/fonts.css'
Returns:
版本号字符串
"""
if self._use_file_hash:
return self._get_file_hash_version(file_path)
else:
return self._get_app_version()
def _get_file_hash_version(self, file_path: str) -> str:
"""基于文件内容生成哈希版本号"""
# 如果已经缓存过,直接返回
if file_path in self._version_cache:
return self._version_cache[file_path]
full_path = self.static_dir / file_path
if not full_path.exists():
# 文件不存在使用应用版本号作为fallback
version = self._get_app_version()
else:
try:
# 读取文件内容并计算MD5哈希
with open(full_path, "rb") as f:
content = f.read()
hash_object = hashlib.md5(content)
version = hash_object.hexdigest()[:8] # 取前8位
except Exception:
# 读取失败使用应用版本号作为fallback
version = self._get_app_version()
# 缓存结果
self._version_cache[file_path] = version
return version
def _get_app_version(self) -> str:
"""获取应用程序版本号"""
try:
return get_current_version().replace(".", "")
except Exception:
# 如果获取版本失败,使用时间戳
return str(int(time.time()))
def get_versioned_url(self, file_path: str) -> str:
"""
获取带版本参数的URL
Args:
file_path: 相对于static目录的文件路径
Returns:
带版本参数的URL
"""
version = self.get_version_for_file(file_path)
return f"/static/{file_path}?v={version}"
def clear_cache(self):
"""清空版本缓存"""
self._version_cache.clear()
# 全局实例
_static_version_manager = StaticVersionManager()
def get_static_url(file_path: str) -> str:
"""
获取静态资源的版本化URL
Args:
file_path: 相对于static目录的文件路径
Returns:
带版本参数的完整URL
Example:
get_static_url('css/fonts.css') -> '/static/css/fonts.css?v=a1b2c3d4'
get_static_url('js/config_editor.js') -> '/static/js/config_editor.js?v=e5f6g7h8'
"""
return _static_version_manager.get_versioned_url(file_path)
def clear_static_cache():
"""清空静态资源版本缓存"""
_static_version_manager.clear_cache()
@lru_cache(maxsize=128)
def get_cached_static_url(file_path: str) -> str:
"""
获取缓存的静态资源URL用于开发环境
Args:
file_path: 相对于static目录的文件路径
Returns:
带版本参数的完整URL
"""
return get_static_url(file_path)

View File

@@ -2,6 +2,12 @@ import requests
from app.domain.image_models import ImageMetadata, ImageUploader, UploadResponse
from enum import Enum
from typing import Optional, Any
import hashlib
import base64
import hmac
from datetime import datetime
from urllib.parse import quote
from app.log.logger import get_image_create_logger
class UploadErrorType(Enum):
"""上传错误类型枚举"""
@@ -179,9 +185,22 @@ class PicGoUploader(ImageUploader):
"""
try:
# 准备请求头
headers = {
"X-API-Key": self.api_key
}
headers = {}
# 构建请求URL
request_url = self.api_url
# 判断是否为默认PicGo URL如果是则使用header认证否则使用URL参数认证
if self.api_url == "https://www.picgo.net/api/1/upload":
headers["X-API-Key"] = self.api_key
else:
# 对于自定义URL将API key作为查询参数添加到URL中
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
parsed_url = urlparse(request_url)
query_params = parse_qs(parsed_url.query)
query_params["key"] = self.api_key
new_query = urlencode(query_params, doseq=True)
request_url = urlunparse(parsed_url._replace(query=new_query))
# 准备文件数据
files = {
@@ -190,7 +209,7 @@ class PicGoUploader(ImageUploader):
# 发送请求
response = requests.post(
self.api_url,
request_url,
headers=headers,
files=files
)
@@ -201,6 +220,34 @@ class PicGoUploader(ImageUploader):
# 解析响应
result = response.json()
# 处理自定义PicGo服务器的响应格式
if "success" in result and "result" in result:
# 自定义PicGo服务器格式: {"success": true, "result": ["url"]}
if result["success"]:
image_url = result["result"][0] if result["result"] and len(result["result"]) > 0 else ""
image_metadata = ImageMetadata(
width=0,
height=0,
filename=filename,
size=0,
url=image_url,
delete_url=None
)
return UploadResponse(
success=True,
code="success",
message="Upload success",
data=image_metadata
)
else:
raise UploadError(
message="Upload failed",
error_type=UploadErrorType.SERVER_ERROR,
status_code=400,
details=result
)
# 处理官方PicGo服务器的响应格式
# 验证上传是否成功
if result.get("status_code") != 200:
error_message = "Upload failed"
@@ -259,6 +306,191 @@ class PicGoUploader(ImageUploader):
)
class AliyunOSSUploader(ImageUploader):
"""阿里云OSS图片上传器"""
def __init__(self, access_key: str, access_key_secret: str, bucket_name: str,
endpoint: str, region: str, use_internal: bool = False):
"""
初始化阿里云OSS上传器
Args:
access_key: OSS访问密钥ID
access_key_secret: OSS访问密钥
bucket_name: OSS存储桶名称
endpoint: OSS端点地址
region: OSS区域
use_internal: 是否使用内网端点
"""
self.access_key = access_key
self.access_key_secret = access_key_secret
self.bucket_name = bucket_name
self.endpoint = endpoint
self.region = region
self.use_internal = use_internal
self.logger = get_image_create_logger()
# 构建请求URL
if not endpoint.startswith(('http://', 'https://')):
self.base_url = f"https://{bucket_name}.{endpoint}"
else:
self.base_url = f"{endpoint}/{bucket_name}"
self.logger.info(f"Initialized AliyunOSSUploader for bucket: {bucket_name}, region: {region}")
def _sign_request(self, method: str, path: str, headers: dict, content: bytes = b'') -> dict:
"""
为OSS请求生成签名
Args:
method: HTTP方法
path: 请求路径
headers: 请求头
content: 请求内容
Returns:
包含签名的请求头
"""
# 计算Content-MD5
content_md5 = base64.b64encode(hashlib.md5(content).digest()).decode('utf-8') if content else ''
# 设置日期
date = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
# 更新headers
headers['Date'] = date
if content_md5:
headers['Content-MD5'] = content_md5
headers['Content-Type'] = headers.get('Content-Type', 'image/png')
# 构建CanonicalizedOSSHeaders
oss_headers = []
for key, value in sorted(headers.items()):
if key.lower().startswith('x-oss-'):
oss_headers.append(f"{key.lower()}:{value}")
canonicalized_oss_headers = '\n'.join(oss_headers)
if canonicalized_oss_headers:
canonicalized_oss_headers += '\n'
# 构建CanonicalizedResource
canonicalized_resource = f"/{self.bucket_name}{path}"
# 构建StringToSign
string_to_sign = f"{method}\n{content_md5}\n{headers.get('Content-Type', '')}\n{date}\n{canonicalized_oss_headers}{canonicalized_resource}"
# 计算签名
signature = base64.b64encode(
hmac.new(
self.access_key_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
hashlib.sha1
).digest()
).decode('utf-8')
# 添加Authorization头
headers['Authorization'] = f"OSS {self.access_key}:{signature}"
return headers
def upload(self, file: bytes, filename: str) -> UploadResponse:
"""
上传图片到阿里云OSS
Args:
file: 图片文件二进制数据
filename: 文件名将作为OSS对象的key
Returns:
UploadResponse: 上传响应对象
Raises:
UploadError: 上传失败时抛出异常
"""
# 记录开始上传的日志
self.logger.info(f"Starting OSS upload for file: {filename}, size: {len(file)} bytes")
try:
# 构建对象路径
object_key = f"/{filename}"
# 准备请求头
headers = {
'Content-Type': 'image/png',
'x-oss-object-acl': 'public-read' # 设置为公共读
}
# 签名请求
signed_headers = self._sign_request('PUT', object_key, headers, file)
# 构建完整URL
upload_url = f"{self.base_url}{object_key}"
self.logger.debug(f"OSS upload URL: {upload_url}")
# 发送请求
response = requests.put(
upload_url,
data=file,
headers=signed_headers
)
# 检查响应状态
if response.status_code != 200:
error_msg = f"OSS upload failed with status {response.status_code}, response: {response.text}"
self.logger.error(f"OSS upload failed for {filename}: {error_msg}")
raise UploadError(
message=f"OSS upload failed with status {response.status_code}",
error_type=UploadErrorType.SERVER_ERROR,
status_code=response.status_code,
details={'response': response.text}
)
# 构建访问URL
if self.endpoint.startswith(('http://', 'https://')):
access_url = f"{self.endpoint}/{self.bucket_name}{object_key}"
else:
access_url = f"https://{self.bucket_name}.{self.endpoint}{object_key}"
# 构建图片元数据
image_metadata = ImageMetadata(
width=0, # OSS PUT不返回图片尺寸
height=0,
filename=filename,
size=len(file),
url=access_url,
delete_url=None # OSS需要单独的删除操作
)
# 记录上传成功的日志
self.logger.info(f"OSS upload successful for {filename}, URL: {access_url}")
return UploadResponse(
success=True,
code="success",
message="Upload to Aliyun OSS success",
data=image_metadata
)
except requests.RequestException as e:
error_msg = f"OSS upload request failed: {str(e)}"
self.logger.error(f"OSS upload request failed for {filename}: {error_msg}")
raise UploadError(
message=error_msg,
error_type=UploadErrorType.NETWORK_ERROR,
original_error=e
)
except UploadError:
# UploadError 已经被记录了,直接重新抛出
raise
except Exception as e:
error_msg = f"OSS upload failed: {str(e)}"
self.logger.error(f"OSS upload unexpected error for {filename}: {error_msg}")
raise UploadError(
message=error_msg,
error_type=UploadErrorType.UNKNOWN,
original_error=e
)
class CloudFlareImgBedUploader(ImageUploader):
"""CloudFlare图床上传器"""
@@ -389,7 +621,7 @@ class ImageUploaderFactory:
credentials["secret_key"]
)
elif provider == "picgo":
api_url = credentials.get("api_url", "https://www.picgo.net/api/1/upload")
api_url = credentials.get("api_url") or "https://www.picgo.net/api/1/upload"
return PicGoUploader(credentials["api_key"], api_url)
elif provider == "cloudflare_imgbed":
return CloudFlareImgBedUploader(
@@ -397,4 +629,13 @@ class ImageUploaderFactory:
credentials["base_url"],
credentials.get("upload_folder", ""),
)
elif provider == "aliyun_oss":
return AliyunOSSUploader(
credentials["access_key"],
credentials["access_key_secret"],
credentials["bucket_name"],
credentials["endpoint"],
credentials["region"],
credentials.get("use_internal", False)
)
raise ValueError(f"Unknown provider: {provider}")

View File

@@ -36,4 +36,13 @@ services:
interval: 10s # 每隔10秒检查一次
timeout: 5s # 每次检查的超时时间为5秒
retries: 3 # 重试3次失败后标记为 unhealthy
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查
# adminer:
# image: adminer:latest
# container_name: gemini-balance-adminer
# restart: unless-stopped
# ports:
# - "8080:8080"
# depends_on:
# mysql:
# condition: service_healthy