Commit ·
c50496f
0
Parent(s):
sync: github -> hf space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +175 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +92 -0
- .github/ISSUE_TEMPLATE/config.yml +8 -0
- .github/workflows/docker-publish.yml +81 -0
- .github/workflows/update-version.yml +51 -0
- .gitignore +99 -0
- CONTRIBUTING.md +169 -0
- Dockerfile +34 -0
- LICENSE +83 -0
- README.md +768 -0
- config.py +457 -0
- darwin-install.sh +55 -0
- docker-compose.yml +79 -0
- docs/README_EN.md +760 -0
- docs/README_JA.md +760 -0
- front/common.js +0 -0
- front/control_panel.html +0 -0
- front/control_panel_mobile.html +0 -0
- install.ps1 +35 -0
- install.sh +302 -0
- log.py +327 -0
- pyproject.toml +103 -0
- render.yaml +20 -0
- requirements-termux.txt +14 -0
- requirements.txt +14 -0
- src/api/Response_example.txt +210 -0
- src/api/antigravity.py +845 -0
- src/api/geminicli.py +808 -0
- src/api/utils.py +505 -0
- src/auth.py +1089 -0
- src/converter/anthropic2gemini.py +1260 -0
- src/converter/anti_truncation.py +731 -0
- src/converter/fake_stream.py +537 -0
- src/converter/gemini_fix.py +472 -0
- src/converter/openai2gemini.py +1533 -0
- src/converter/thoughtSignature_fix.py +56 -0
- src/converter/utils.py +237 -0
- src/credential_manager.py +510 -0
- src/google_oauth_api.py +852 -0
- src/httpx_client.py +121 -0
- src/keeplive.py +88 -0
- src/models.py +379 -0
- src/panel/__init__.py +37 -0
- src/panel/auth.py +192 -0
- src/panel/config_routes.py +224 -0
- src/panel/creds.py +1585 -0
- src/panel/logs.py +237 -0
- src/panel/root.py +34 -0
- src/panel/utils.py +166 -0
- src/panel/version.py +107 -0
.env.example
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ================================================================
|
| 2 |
+
# GCLI2API 环境变量配置示例文件
|
| 3 |
+
# 复制此文件为 .env 并根据需要修改配置值
|
| 4 |
+
# ================================================================
|
| 5 |
+
|
| 6 |
+
# ================================================================
|
| 7 |
+
# 服务器配置
|
| 8 |
+
# ================================================================
|
| 9 |
+
|
| 10 |
+
# 服务器监听地址
|
| 11 |
+
# 默认: 0.0.0.0 (监听所有网络接口)
|
| 12 |
+
HOST=0.0.0.0
|
| 13 |
+
|
| 14 |
+
# 服务器端口
|
| 15 |
+
# 默认: 7861
|
| 16 |
+
PORT=7861
|
| 17 |
+
|
| 18 |
+
# ================================================================
|
| 19 |
+
# 密码配置 (支持分离密码)
|
| 20 |
+
# ================================================================
|
| 21 |
+
|
| 22 |
+
# 聊天API访问密码 (用于OpenAI和Gemini API端点认证)
|
| 23 |
+
# 默认: 继承通用密码或 pwd
|
| 24 |
+
API_PASSWORD=your_api_password
|
| 25 |
+
|
| 26 |
+
# 控制面板访问密码 (用于Web界面登录认证)
|
| 27 |
+
# 默认: 继承通用密码或 pwd
|
| 28 |
+
PANEL_PASSWORD=your_panel_password
|
| 29 |
+
|
| 30 |
+
# 通用访问密码 (兼容性保留)
|
| 31 |
+
# 设置后会覆盖上述两个专用密码,优先级最高
|
| 32 |
+
# 如果只想使用一个密码,设置此项即可
|
| 33 |
+
# 默认: pwd
|
| 34 |
+
PASSWORD=pwd
|
| 35 |
+
|
| 36 |
+
# ================================================================
|
| 37 |
+
# 存储配置
|
| 38 |
+
# ================================================================
|
| 39 |
+
|
| 40 |
+
# 存储后端优先级: PostgreSQL > MongoDB > 本地sqlite文件存储
|
| 41 |
+
# 系统会自动选择可用的最高优先级存储后端
|
| 42 |
+
|
| 43 |
+
# PostgreSQL 分布式存储模式配置 (最高优先级)
|
| 44 |
+
# 设置 POSTGRESQL_URI 后自动启用 PostgreSQL 模式
|
| 45 |
+
# 本地 PostgreSQL: postgresql://user:password@localhost:5432/gcli2api
|
| 46 |
+
# 带 SSL: postgresql://user:password@host:5432/gcli2api?sslmode=require
|
| 47 |
+
# 默认: 无 (不启用 PostgreSQL 存储)
|
| 48 |
+
POSTGRESQL_URI=postgresql://user:password@localhost:5432/gcli2api
|
| 49 |
+
|
| 50 |
+
# MongoDB 分布式存储模式配置 (第二优先级)
|
| 51 |
+
# 设置 MONGODB_URI 后自动启用 MongoDB 模式,不再使用本地文件存储
|
| 52 |
+
|
| 53 |
+
# Redis 缓存存储配置
|
| 54 |
+
# 设置 REDIS_URL 后自动启用 Redis 模式,性能最佳,可大幅降低 MongoDB 的读写压力
|
| 55 |
+
# 本地 Redis: redis://127.0.0.1:6379/0
|
| 56 |
+
# 带密码: redis://:password@127.0.0.1:6379/0
|
| 57 |
+
# 默认: 无 (不启用 Redis 缓存)
|
| 58 |
+
REDIS_URL=redis://127.0.0.1:6379/0
|
| 59 |
+
|
| 60 |
+
# MongoDB 连接字符串 (设置后启用 MongoDB 分布式存储模式)
|
| 61 |
+
# 本地 MongoDB: mongodb://localhost:27017
|
| 62 |
+
# 带认证: mongodb://admin:password@localhost:27017/admin
|
| 63 |
+
# MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net
|
| 64 |
+
# 副本集: mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=rs0
|
| 65 |
+
# 默认: 无 (使用本地文件存储)
|
| 66 |
+
MONGODB_URI=mongodb://localhost:27017
|
| 67 |
+
|
| 68 |
+
# MongoDB 数据库名称 (仅在启用 MongoDB 模式时有效)
|
| 69 |
+
# 默认: gcli2api
|
| 70 |
+
MONGODB_DATABASE=gcli2api
|
| 71 |
+
|
| 72 |
+
# ================================================================
|
| 73 |
+
# Google API 配置
|
| 74 |
+
# ================================================================
|
| 75 |
+
|
| 76 |
+
# 凭证文件目录 (仅在文件存储模式下使用)
|
| 77 |
+
# 默认: ./creds
|
| 78 |
+
CREDENTIALS_DIR=./creds
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# 代理配置 (可选)
|
| 82 |
+
# 支持 http, https, socks5 代理
|
| 83 |
+
# 格式: http://proxy:port, https://proxy:port, socks5://proxy:port
|
| 84 |
+
PROXY=http://localhost:7890
|
| 85 |
+
|
| 86 |
+
# Google API 代理 URL 配置 (可选)
|
| 87 |
+
|
| 88 |
+
# Google Code Assist API 端点
|
| 89 |
+
# 默认: https://cloudcode-pa.googleapis.com
|
| 90 |
+
CODE_ASSIST_ENDPOINT=https://cloudcode-pa.googleapis.com
|
| 91 |
+
# 用于Google OAuth2认证的代理URL
|
| 92 |
+
# 默认: https://oauth2.googleapis.com
|
| 93 |
+
OAUTH_PROXY_URL=https://oauth2.googleapis.com
|
| 94 |
+
|
| 95 |
+
# 用于Google APIs调用的代理URL
|
| 96 |
+
# 默认: https://www.googleapis.com
|
| 97 |
+
GOOGLEAPIS_PROXY_URL=https://www.googleapis.com
|
| 98 |
+
|
| 99 |
+
# 用于Google Cloud Resource Manager API的URL
|
| 100 |
+
# 默认: https://cloudresourcemanager.googleapis.com
|
| 101 |
+
RESOURCE_MANAGER_API_URL=https://cloudresourcemanager.googleapis.com
|
| 102 |
+
|
| 103 |
+
# 用于Google Cloud Service Usage API的URL
|
| 104 |
+
# 默认: https://serviceusage.googleapis.com
|
| 105 |
+
SERVICE_USAGE_API_URL=https://serviceusage.googleapis.com
|
| 106 |
+
|
| 107 |
+
# ================================================================
|
| 108 |
+
# 错误处理和重试配置
|
| 109 |
+
# ================================================================
|
| 110 |
+
|
| 111 |
+
# 是否启用自动封禁功能
|
| 112 |
+
# 当凭证返回特定错误码时自动禁用该凭证
|
| 113 |
+
# 默认: false
|
| 114 |
+
AUTO_BAN=false
|
| 115 |
+
|
| 116 |
+
# 自动封禁的错误码列表 (逗号分隔)
|
| 117 |
+
# 默认: 400,403
|
| 118 |
+
AUTO_BAN_ERROR_CODES=403
|
| 119 |
+
|
| 120 |
+
# 是否启用 429 错误重试
|
| 121 |
+
# 默认: true
|
| 122 |
+
RETRY_429_ENABLED=true
|
| 123 |
+
|
| 124 |
+
# 429 错误最大重试次数
|
| 125 |
+
# 默认: 5
|
| 126 |
+
RETRY_429_MAX_RETRIES=5
|
| 127 |
+
|
| 128 |
+
# 429 错误重试间隔 (秒)
|
| 129 |
+
# 默认: 1
|
| 130 |
+
RETRY_429_INTERVAL=1
|
| 131 |
+
|
| 132 |
+
# ================================================================
|
| 133 |
+
# 日志配置
|
| 134 |
+
# ================================================================
|
| 135 |
+
|
| 136 |
+
# 日志级别
|
| 137 |
+
# 可选值: debug, info, warning, error, critical
|
| 138 |
+
# 默认: info
|
| 139 |
+
LOG_LEVEL=info
|
| 140 |
+
|
| 141 |
+
# 日志文件路径
|
| 142 |
+
# 默认: log.txt
|
| 143 |
+
LOG_FILE=log.txt
|
| 144 |
+
|
| 145 |
+
# ================================================================
|
| 146 |
+
# 高级功能配置
|
| 147 |
+
# ================================================================
|
| 148 |
+
|
| 149 |
+
# 流式抗截断最大尝试次数
|
| 150 |
+
# 用于 "流式抗截断/" 前缀的模型
|
| 151 |
+
# 默认: 3
|
| 152 |
+
ANTI_TRUNCATION_MAX_ATTEMPTS=3
|
| 153 |
+
|
| 154 |
+
# ================================================================
|
| 155 |
+
# 环境变量使用说明
|
| 156 |
+
# ================================================================
|
| 157 |
+
|
| 158 |
+
# 1. 存储模式配置 (按优先级自动选择):
|
| 159 |
+
# - PostgreSQL 分布式模式 (最高优先级): 设置 POSTGRESQL_URI,数据存储在 PostgreSQL 数据库
|
| 160 |
+
# - Redis 缓存: 同时设置 REDIS_URI和 MONGODB_URI时,数据缓存在 Redis 数据库,持久化在MONGODB,性能最佳
|
| 161 |
+
# - MongoDB 分布式模式: 设置 MONGODB_URI,数据存储在 MongoDB 数据库
|
| 162 |
+
# - 文件存储模式 (默认): 不设置上述 URI,数据存储在本地 creds/ 目录
|
| 163 |
+
# - 自动切换: 系统根据可用的存储配置自动选择最高优先级的存储后端
|
| 164 |
+
|
| 165 |
+
# 2. 密码配置优先级:
|
| 166 |
+
# a) PASSWORD 环境变量 (最高优先级,设置后覆盖其他密码)
|
| 167 |
+
# b) API_PASSWORD / PANEL_PASSWORD 环境变量 (专用密码)
|
| 168 |
+
# c) 默认值 "pwd"
|
| 169 |
+
#
|
| 170 |
+
# 3. 通用配置优先级:
|
| 171 |
+
# 环境变量 > 默认值
|
| 172 |
+
|
| 173 |
+
# 4. 布尔值环境变量:
|
| 174 |
+
# true/1/yes/on 表示启用
|
| 175 |
+
# false/0/no/off 表示禁用
|
.github/ISSUE_TEMPLATE/bug_report.yml
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Bug 报告
|
| 2 |
+
description: 报告项目使用中遇到的问题
|
| 3 |
+
title: "[Bug]: "
|
| 4 |
+
labels: ["bug", "待处理"]
|
| 5 |
+
body:
|
| 6 |
+
- type: markdown
|
| 7 |
+
attributes:
|
| 8 |
+
value: |
|
| 9 |
+
## 感谢你的反馈!
|
| 10 |
+
请填写以下信息以帮助我们更快定位问题。
|
| 11 |
+
|
| 12 |
+
- type: checkboxes
|
| 13 |
+
id: checklist
|
| 14 |
+
attributes:
|
| 15 |
+
label: 提交前确认
|
| 16 |
+
options:
|
| 17 |
+
- label: 我已经搜索过现有的 issues,确认这不是重复问题
|
| 18 |
+
required: true
|
| 19 |
+
- label: 我已经阅读过项目文档
|
| 20 |
+
required: true
|
| 21 |
+
|
| 22 |
+
- type: dropdown
|
| 23 |
+
id: latest-version
|
| 24 |
+
attributes:
|
| 25 |
+
label: 是否是最新版
|
| 26 |
+
description: 请确认你使用的是否是最新版本
|
| 27 |
+
options:
|
| 28 |
+
- 是,使用最新版
|
| 29 |
+
- 否,使用旧版本
|
| 30 |
+
validations:
|
| 31 |
+
required: true
|
| 32 |
+
|
| 33 |
+
- type: input
|
| 34 |
+
id: channel
|
| 35 |
+
attributes:
|
| 36 |
+
label: 调用的是哪个渠道
|
| 37 |
+
description: 例如 geminicli 或者 antigravity
|
| 38 |
+
placeholder: "例如: geminicli"
|
| 39 |
+
validations:
|
| 40 |
+
required: true
|
| 41 |
+
|
| 42 |
+
- type: input
|
| 43 |
+
id: model
|
| 44 |
+
attributes:
|
| 45 |
+
label: 调用的是哪个模型
|
| 46 |
+
description: 例如 gemini-2.5-flash
|
| 47 |
+
placeholder: "例如: gemini-2.5-flash"
|
| 48 |
+
validations:
|
| 49 |
+
required: true
|
| 50 |
+
|
| 51 |
+
- type: dropdown
|
| 52 |
+
id: format
|
| 53 |
+
attributes:
|
| 54 |
+
label: 调用的是哪个格式
|
| 55 |
+
description: 选择你使用的 API 格式
|
| 56 |
+
options:
|
| 57 |
+
- gemini 格式
|
| 58 |
+
- openai 格式
|
| 59 |
+
- claude 格式
|
| 60 |
+
- 其他格式
|
| 61 |
+
validations:
|
| 62 |
+
required: true
|
| 63 |
+
|
| 64 |
+
- type: textarea
|
| 65 |
+
id: error-content
|
| 66 |
+
attributes:
|
| 67 |
+
label: 具体报错内容
|
| 68 |
+
description: 请粘贴完整的错误信息或截图
|
| 69 |
+
placeholder: |
|
| 70 |
+
请在这里粘贴完整的错误日志或堆栈信息
|
| 71 |
+
render: shell
|
| 72 |
+
validations:
|
| 73 |
+
required: true
|
| 74 |
+
|
| 75 |
+
- type: textarea
|
| 76 |
+
id: error-description
|
| 77 |
+
attributes:
|
| 78 |
+
label: 错误描述
|
| 79 |
+
description: 详细描述问题的发生场景、预期行为和实际行为
|
| 80 |
+
placeholder: |
|
| 81 |
+
1. 我在做什么操作时遇到了这个问题
|
| 82 |
+
2. 我期望的结果是...
|
| 83 |
+
3. 但实际上发生了...
|
| 84 |
+
validations:
|
| 85 |
+
required: true
|
| 86 |
+
|
| 87 |
+
- type: textarea
|
| 88 |
+
id: additional-context
|
| 89 |
+
attributes:
|
| 90 |
+
label: 补充信息(可选)
|
| 91 |
+
description: 其他任何有助于解决问题的信息
|
| 92 |
+
placeholder: 例如:操作系统、Python 版本、相关配置等
|
.github/ISSUE_TEMPLATE/config.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blank_issues_enabled: false
|
| 2 |
+
contact_links:
|
| 3 |
+
- name: 使用问题讨论
|
| 4 |
+
url: https://github.com/su-kaka/gcli2api/issues
|
| 5 |
+
about: 如果是使用方面的问题,请在 issues 中提问
|
| 6 |
+
- name: 项目文档
|
| 7 |
+
url: https://github.com/su-kaka/gcli2api
|
| 8 |
+
about: 查看完整文档和使用指南
|
.github/workflows/docker-publish.yml
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Docker Build and Publish
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_run:
|
| 5 |
+
workflows: ["Update Version File"]
|
| 6 |
+
types:
|
| 7 |
+
- completed
|
| 8 |
+
branches:
|
| 9 |
+
- master
|
| 10 |
+
- main
|
| 11 |
+
push:
|
| 12 |
+
tags:
|
| 13 |
+
- 'v*'
|
| 14 |
+
pull_request:
|
| 15 |
+
branches:
|
| 16 |
+
- master
|
| 17 |
+
- main
|
| 18 |
+
workflow_dispatch:
|
| 19 |
+
|
| 20 |
+
env:
|
| 21 |
+
REGISTRY: ghcr.io
|
| 22 |
+
IMAGE_NAME: ${{ github.repository }}
|
| 23 |
+
|
| 24 |
+
jobs:
|
| 25 |
+
build-and-push:
|
| 26 |
+
runs-on: ubuntu-latest
|
| 27 |
+
# 只在 workflow_run 成功时运行,或者非 workflow_run 触发时运行
|
| 28 |
+
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
| 29 |
+
permissions:
|
| 30 |
+
contents: read
|
| 31 |
+
packages: write
|
| 32 |
+
|
| 33 |
+
steps:
|
| 34 |
+
- name: Checkout repository
|
| 35 |
+
uses: actions/checkout@v4
|
| 36 |
+
with:
|
| 37 |
+
# workflow_run 触发时需要获取最新的代码(包括 version.txt 的更新)
|
| 38 |
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
|
| 39 |
+
|
| 40 |
+
- name: Set up QEMU
|
| 41 |
+
uses: docker/setup-qemu-action@v3
|
| 42 |
+
|
| 43 |
+
- name: Set up Docker Buildx
|
| 44 |
+
uses: docker/setup-buildx-action@v3
|
| 45 |
+
|
| 46 |
+
- name: Log in to GitHub Container Registry
|
| 47 |
+
uses: docker/login-action@v3
|
| 48 |
+
with:
|
| 49 |
+
registry: ${{ env.REGISTRY }}
|
| 50 |
+
username: ${{ github.actor }}
|
| 51 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 52 |
+
|
| 53 |
+
- name: Extract metadata
|
| 54 |
+
id: meta
|
| 55 |
+
uses: docker/metadata-action@v5
|
| 56 |
+
with:
|
| 57 |
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
| 58 |
+
tags: |
|
| 59 |
+
type=ref,event=branch
|
| 60 |
+
type=ref,event=tag
|
| 61 |
+
type=ref,event=pr
|
| 62 |
+
type=raw,value=latest,enable={{is_default_branch}}
|
| 63 |
+
type=sha,prefix={{branch}}-
|
| 64 |
+
type=semver,pattern={{version}}
|
| 65 |
+
type=semver,pattern={{major}}.{{minor}}
|
| 66 |
+
type=semver,pattern={{major}}
|
| 67 |
+
|
| 68 |
+
- name: Build and push Docker image
|
| 69 |
+
uses: docker/build-push-action@v5
|
| 70 |
+
with:
|
| 71 |
+
context: .
|
| 72 |
+
platforms: linux/amd64,linux/arm64
|
| 73 |
+
push: ${{ github.event_name != 'pull_request' }}
|
| 74 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 75 |
+
labels: ${{ steps.meta.outputs.labels }}
|
| 76 |
+
cache-from: type=gha
|
| 77 |
+
cache-to: type=gha,mode=max
|
| 78 |
+
build-args: |
|
| 79 |
+
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
| 80 |
+
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
| 81 |
+
REVISION=${{ github.sha }}
|
.github/workflows/update-version.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Update Version File
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
- main
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
update-version:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
permissions:
|
| 13 |
+
contents: write
|
| 14 |
+
|
| 15 |
+
steps:
|
| 16 |
+
- name: Checkout repository
|
| 17 |
+
uses: actions/checkout@v4
|
| 18 |
+
with:
|
| 19 |
+
fetch-depth: 0
|
| 20 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
| 21 |
+
|
| 22 |
+
- name: Update version.txt
|
| 23 |
+
run: |
|
| 24 |
+
# 获取最新commit信息
|
| 25 |
+
FULL_HASH=$(git log -1 --format=%H)
|
| 26 |
+
SHORT_HASH=$(git log -1 --format=%h)
|
| 27 |
+
MESSAGE=$(git log -1 --format=%s)
|
| 28 |
+
DATE=$(git log -1 --format=%ci)
|
| 29 |
+
|
| 30 |
+
# 写入version.txt
|
| 31 |
+
echo "full_hash=$FULL_HASH" > version.txt
|
| 32 |
+
echo "short_hash=$SHORT_HASH" >> version.txt
|
| 33 |
+
echo "message=$MESSAGE" >> version.txt
|
| 34 |
+
echo "date=$DATE" >> version.txt
|
| 35 |
+
|
| 36 |
+
echo "Version file updated:"
|
| 37 |
+
cat version.txt
|
| 38 |
+
|
| 39 |
+
- name: Commit version.txt if changed
|
| 40 |
+
run: |
|
| 41 |
+
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
| 42 |
+
git config --local user.name "github-actions[bot]"
|
| 43 |
+
|
| 44 |
+
# 检查是否有变化
|
| 45 |
+
if git diff --quiet version.txt; then
|
| 46 |
+
echo "No changes to version.txt"
|
| 47 |
+
else
|
| 48 |
+
git add version.txt
|
| 49 |
+
git commit -m "chore: update version.txt [skip ci]"
|
| 50 |
+
git push
|
| 51 |
+
fi
|
.gitignore
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Credential files - should never be committed
|
| 2 |
+
*.json
|
| 3 |
+
!package.json
|
| 4 |
+
!package-lock.json
|
| 5 |
+
!tsconfig.json
|
| 6 |
+
*.toml
|
| 7 |
+
!pyproject.toml
|
| 8 |
+
creds/
|
| 9 |
+
CLAUDE.md
|
| 10 |
+
GEMINI.md
|
| 11 |
+
.kiro
|
| 12 |
+
# Environment configuration
|
| 13 |
+
.env
|
| 14 |
+
|
| 15 |
+
# Python
|
| 16 |
+
uv.lock
|
| 17 |
+
.python-version
|
| 18 |
+
__pycache__/
|
| 19 |
+
*.py[cod]
|
| 20 |
+
*$py.class
|
| 21 |
+
*.so
|
| 22 |
+
.Python
|
| 23 |
+
build/
|
| 24 |
+
develop-eggs/
|
| 25 |
+
dist/
|
| 26 |
+
downloads/
|
| 27 |
+
eggs/
|
| 28 |
+
.eggs/
|
| 29 |
+
lib/
|
| 30 |
+
lib64/
|
| 31 |
+
parts/
|
| 32 |
+
sdist/
|
| 33 |
+
var/
|
| 34 |
+
wheels/
|
| 35 |
+
pip-wheel-metadata/
|
| 36 |
+
share/python-wheels/
|
| 37 |
+
*.egg-info/
|
| 38 |
+
.installed.cfg
|
| 39 |
+
*.egg
|
| 40 |
+
MANIFEST
|
| 41 |
+
|
| 42 |
+
# PyInstaller
|
| 43 |
+
*.manifest
|
| 44 |
+
*.spec
|
| 45 |
+
|
| 46 |
+
# Installer logs
|
| 47 |
+
pip-log.txt
|
| 48 |
+
pip-delete-this-directory.txt
|
| 49 |
+
|
| 50 |
+
# Unit test / coverage reports
|
| 51 |
+
htmlcov/
|
| 52 |
+
.tox/
|
| 53 |
+
.nox/
|
| 54 |
+
.coverage
|
| 55 |
+
.coverage.*
|
| 56 |
+
.cache
|
| 57 |
+
nosetests.xml
|
| 58 |
+
coverage.xml
|
| 59 |
+
*.cover
|
| 60 |
+
*.py,cover
|
| 61 |
+
.hypothesis/
|
| 62 |
+
.pytest_cache/
|
| 63 |
+
|
| 64 |
+
# Virtual environments
|
| 65 |
+
.env
|
| 66 |
+
.venv
|
| 67 |
+
env/
|
| 68 |
+
venv/
|
| 69 |
+
ENV/
|
| 70 |
+
env.bak/
|
| 71 |
+
venv.bak/
|
| 72 |
+
|
| 73 |
+
# IDE
|
| 74 |
+
.vscode/
|
| 75 |
+
.idea/
|
| 76 |
+
.claude/
|
| 77 |
+
*.swp
|
| 78 |
+
*.swo
|
| 79 |
+
*~
|
| 80 |
+
|
| 81 |
+
# OS
|
| 82 |
+
.DS_Store
|
| 83 |
+
.DS_Store?
|
| 84 |
+
._*
|
| 85 |
+
.Spotlight-V100
|
| 86 |
+
.Trashes
|
| 87 |
+
ehthumbs.db
|
| 88 |
+
Thumbs.db
|
| 89 |
+
|
| 90 |
+
# Logs
|
| 91 |
+
*.log
|
| 92 |
+
log.txt
|
| 93 |
+
|
| 94 |
+
# Temporary files
|
| 95 |
+
*.tmp
|
| 96 |
+
*.temp
|
| 97 |
+
*.bak
|
| 98 |
+
|
| 99 |
+
tools/
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to gcli2api
|
| 2 |
+
|
| 3 |
+
First off, thank you for considering contributing to gcli2api! It's people like you that make gcli2api such a great tool.
|
| 4 |
+
|
| 5 |
+
## Code of Conduct
|
| 6 |
+
|
| 7 |
+
This project is intended for personal learning and research purposes only. By participating, you are expected to uphold this code and respect the CNC-1.0 license restrictions on commercial use.
|
| 8 |
+
|
| 9 |
+
## How Can I Contribute?
|
| 10 |
+
|
| 11 |
+
### Reporting Bugs
|
| 12 |
+
|
| 13 |
+
Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible:
|
| 14 |
+
|
| 15 |
+
* **Use a clear and descriptive title**
|
| 16 |
+
* **Describe the exact steps to reproduce the problem**
|
| 17 |
+
* **Provide specific examples** - Include code snippets, configuration files, or log outputs
|
| 18 |
+
* **Describe the behavior you observed** and what you expected to see
|
| 19 |
+
* **Include environment details**: OS, Python version, Docker version (if applicable)
|
| 20 |
+
|
| 21 |
+
### Suggesting Enhancements
|
| 22 |
+
|
| 23 |
+
Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, include:
|
| 24 |
+
|
| 25 |
+
* **Use a clear and descriptive title**
|
| 26 |
+
* **Provide a detailed description** of the suggested enhancement
|
| 27 |
+
* **Explain why this enhancement would be useful**
|
| 28 |
+
* **List any alternative solutions** you've considered
|
| 29 |
+
|
| 30 |
+
### Pull Requests
|
| 31 |
+
|
| 32 |
+
1. Fork the repo and create your branch from `master`
|
| 33 |
+
2. If you've added code that should be tested, add tests
|
| 34 |
+
3. If you've changed APIs, update the documentation
|
| 35 |
+
4. Ensure the test suite passes
|
| 36 |
+
5. Make sure your code follows the existing style
|
| 37 |
+
6. Write a clear commit message
|
| 38 |
+
|
| 39 |
+
## Development Setup
|
| 40 |
+
|
| 41 |
+
### Prerequisites
|
| 42 |
+
|
| 43 |
+
* Python 3.12 or higher
|
| 44 |
+
* pip or uv package manager
|
| 45 |
+
|
| 46 |
+
### Setting Up Your Development Environment
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
# Clone your fork
|
| 50 |
+
git clone https://github.com/YOUR_USERNAME/gcli2api.git
|
| 51 |
+
cd gcli2api
|
| 52 |
+
|
| 53 |
+
# Install development dependencies
|
| 54 |
+
make install-dev
|
| 55 |
+
# or
|
| 56 |
+
pip install -e ".[dev]"
|
| 57 |
+
|
| 58 |
+
# Copy environment example
|
| 59 |
+
cp .env.example .env
|
| 60 |
+
# Edit .env with your configuration
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Development Workflow
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
# Run tests
|
| 67 |
+
make test
|
| 68 |
+
|
| 69 |
+
# Format code
|
| 70 |
+
make format
|
| 71 |
+
|
| 72 |
+
# Run linters
|
| 73 |
+
make lint
|
| 74 |
+
|
| 75 |
+
# Run the application locally
|
| 76 |
+
make run
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### Testing
|
| 80 |
+
|
| 81 |
+
We use pytest for testing. All new features should include appropriate tests.
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
# Run all tests
|
| 85 |
+
make test
|
| 86 |
+
|
| 87 |
+
# Run with coverage
|
| 88 |
+
make test-cov
|
| 89 |
+
|
| 90 |
+
# Run specific test file
|
| 91 |
+
python -m pytest test_tool_calling.py -v
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Code Style
|
| 95 |
+
|
| 96 |
+
* We use [Black](https://black.readthedocs.io/) for code formatting (line length: 100)
|
| 97 |
+
* We use [flake8](https://flake8.pycqa.org/) for linting
|
| 98 |
+
* We use [mypy](http://mypy-lang.org/) for type checking (optional, but encouraged)
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
# Format your code before committing
|
| 102 |
+
make format
|
| 103 |
+
|
| 104 |
+
# Check if code is properly formatted
|
| 105 |
+
make format-check
|
| 106 |
+
|
| 107 |
+
# Run linters
|
| 108 |
+
make lint
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## Project Structure
|
| 112 |
+
|
| 113 |
+
```
|
| 114 |
+
gcli2api/
|
| 115 |
+
├── src/ # Main source code
|
| 116 |
+
│ ├── auth.py # Authentication and OAuth
|
| 117 |
+
│ ├── credential_manager.py # Credential rotation
|
| 118 |
+
│ ├── openai_router.py # OpenAI-compatible endpoints
|
| 119 |
+
│ ├── gemini_router.py # Gemini native endpoints
|
| 120 |
+
│ ├── openai_transfer.py # Format conversion
|
| 121 |
+
│ ├── storage/ # Storage backends (Redis, MongoDB, Postgres, File)
|
| 122 |
+
│ └── ...
|
| 123 |
+
├── front/ # Frontend static files
|
| 124 |
+
├── tests/ # Test directory (to be created)
|
| 125 |
+
├── test_*.py # Test files (root level)
|
| 126 |
+
├── web.py # Main application entry point
|
| 127 |
+
├── config.py # Configuration management
|
| 128 |
+
└── requirements.txt # Production dependencies
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
## Coding Guidelines
|
| 132 |
+
|
| 133 |
+
### Python Style
|
| 134 |
+
|
| 135 |
+
* Follow PEP 8 guidelines
|
| 136 |
+
* Use type hints where appropriate
|
| 137 |
+
* Write docstrings for classes and functions
|
| 138 |
+
* Keep functions focused and concise
|
| 139 |
+
|
| 140 |
+
### Commit Messages
|
| 141 |
+
|
| 142 |
+
* Use the present tense ("Add feature" not "Added feature")
|
| 143 |
+
* Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
|
| 144 |
+
* Limit the first line to 72 characters or less
|
| 145 |
+
* Reference issues and pull requests liberally after the first line
|
| 146 |
+
|
| 147 |
+
### Documentation
|
| 148 |
+
|
| 149 |
+
* Update the README.md if you change functionality
|
| 150 |
+
* Comment your code where necessary
|
| 151 |
+
* Update the .env.example if you add new configuration options
|
| 152 |
+
|
| 153 |
+
## License
|
| 154 |
+
|
| 155 |
+
By contributing to gcli2api, you agree that your contributions will be licensed under the CNC-1.0 license. This is a strict anti-commercial license - see [LICENSE](LICENSE) for details.
|
| 156 |
+
|
| 157 |
+
### Important License Restrictions
|
| 158 |
+
|
| 159 |
+
* ❌ No commercial use
|
| 160 |
+
* ❌ No use by companies with revenue > $1M USD
|
| 161 |
+
* ❌ No use by VC-backed or publicly traded companies
|
| 162 |
+
* ✅ Personal learning, research, and educational use only
|
| 163 |
+
* ✅ Open source integration (must follow same license)
|
| 164 |
+
|
| 165 |
+
## Questions?
|
| 166 |
+
|
| 167 |
+
Feel free to open an issue with your question or reach out to the maintainers.
|
| 168 |
+
|
| 169 |
+
Thank you for contributing! 🎉
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage build for gcli2api
|
| 2 |
+
FROM python:3.13-slim as base
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 6 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 7 |
+
PIP_NO_CACHE_DIR=1 \
|
| 8 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 9 |
+
TZ=Asia/Shanghai
|
| 10 |
+
|
| 11 |
+
# Install tzdata and set timezone
|
| 12 |
+
RUN apt-get update && \
|
| 13 |
+
apt-get install -y --no-install-recommends tzdata && \
|
| 14 |
+
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
| 15 |
+
echo "Asia/Shanghai" > /etc/timezone && \
|
| 16 |
+
apt-get clean && \
|
| 17 |
+
rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
|
| 21 |
+
# Copy only requirements first for better caching
|
| 22 |
+
COPY requirements.txt .
|
| 23 |
+
|
| 24 |
+
# Install Python dependencies
|
| 25 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 26 |
+
|
| 27 |
+
# Copy application code
|
| 28 |
+
COPY . .
|
| 29 |
+
|
| 30 |
+
# Expose port
|
| 31 |
+
EXPOSE 7861
|
| 32 |
+
|
| 33 |
+
# Default command
|
| 34 |
+
CMD ["python", "web.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Cooperative Non-Commercial License (CNC-1.0)
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 gcli2api contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person or organization
|
| 6 |
+
obtaining a copy of this software and associated documentation files (the
|
| 7 |
+
"Software"), to use, copy, modify, merge, publish, distribute, and/or
|
| 8 |
+
sublicense the Software, subject to the following conditions:
|
| 9 |
+
|
| 10 |
+
TERMS AND CONDITIONS:
|
| 11 |
+
|
| 12 |
+
1. NON-COMMERCIAL USE ONLY
|
| 13 |
+
The Software may only be used for non-commercial purposes. Commercial use
|
| 14 |
+
is strictly prohibited without explicit written permission from the
|
| 15 |
+
copyright holders.
|
| 16 |
+
|
| 17 |
+
2. DEFINITION OF COMMERCIAL USE
|
| 18 |
+
"Commercial use" includes but is not limited to:
|
| 19 |
+
a) Using the Software to provide paid services or products
|
| 20 |
+
b) Integrating the Software into commercial products or services
|
| 21 |
+
c) Using the Software in any business operation that generates revenue
|
| 22 |
+
d) Offering the Software as part of a paid subscription or service
|
| 23 |
+
e) Using the Software to compete with the original project commercially
|
| 24 |
+
|
| 25 |
+
3. COPYLEFT REQUIREMENT
|
| 26 |
+
Any derivative works, modifications, or substantial portions of the Software
|
| 27 |
+
must be licensed under the same or substantially similar terms. This ensures
|
| 28 |
+
that all derivatives remain non-commercial and freely available.
|
| 29 |
+
|
| 30 |
+
4. SOURCE CODE AVAILABILITY
|
| 31 |
+
If you distribute the Software or any derivative works, you must make the
|
| 32 |
+
complete source code available under the same license terms at no charge.
|
| 33 |
+
|
| 34 |
+
5. ATTRIBUTION REQUIREMENT
|
| 35 |
+
You must retain all copyright notices, license notices, and attribution
|
| 36 |
+
statements in all copies or substantial portions of the Software.
|
| 37 |
+
|
| 38 |
+
6. ANTI-CORPORATE CLAUSE
|
| 39 |
+
This Software may not be used by corporations with annual revenue exceeding
|
| 40 |
+
$1 million USD, venture capital backed companies, or publicly traded
|
| 41 |
+
companies without explicit written permission from the copyright holders.
|
| 42 |
+
|
| 43 |
+
7. EDUCATIONAL AND RESEARCH EXEMPTION
|
| 44 |
+
Use by educational institutions, non-profit research organizations, and
|
| 45 |
+
individual researchers for educational or research purposes is explicitly
|
| 46 |
+
permitted and encouraged.
|
| 47 |
+
|
| 48 |
+
8. MODIFICATION AND CONTRIBUTION
|
| 49 |
+
Modifications and contributions to the Software are welcomed and encouraged,
|
| 50 |
+
provided they comply with these license terms. Contributors grant the same
|
| 51 |
+
license to their contributions.
|
| 52 |
+
|
| 53 |
+
9. PATENT GRANT
|
| 54 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free patent
|
| 55 |
+
license to make, have made, use, offer to sell, sell, import, and otherwise
|
| 56 |
+
transfer the Work for non-commercial purposes only.
|
| 57 |
+
|
| 58 |
+
10. TERMINATION
|
| 59 |
+
This license automatically terminates if you violate any of its terms.
|
| 60 |
+
Upon termination, you must cease all use and distribution of the Software
|
| 61 |
+
and destroy all copies in your possession.
|
| 62 |
+
|
| 63 |
+
11. LIABILITY DISCLAIMER
|
| 64 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 65 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 66 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 67 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 68 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 69 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 70 |
+
SOFTWARE.
|
| 71 |
+
|
| 72 |
+
12. JURISDICTION
|
| 73 |
+
This license shall be governed by and construed in accordance with the laws
|
| 74 |
+
of the jurisdiction where the copyright holder resides.
|
| 75 |
+
|
| 76 |
+
SUMMARY:
|
| 77 |
+
This license allows free use, modification, and distribution of the Software
|
| 78 |
+
for non-commercial purposes only. It explicitly prohibits commercial use and
|
| 79 |
+
ensures that all derivatives remain freely available under the same terms.
|
| 80 |
+
The license promotes cooperative development while preventing commercial
|
| 81 |
+
exploitation of the community's work.
|
| 82 |
+
|
| 83 |
+
For commercial licensing inquiries, please contact the copyright holders.
|
README.md
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: gcli2api
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: green
|
| 5 |
+
sdk: docker
|
| 6 |
+
app_port: 7861
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
# GeminiCLI to API
|
| 10 |
+
|
| 11 |
+
**灏?GeminiCLI 鍜?Antigravity 杞崲涓?OpenAI 銆丟EMINI 鍜?Claude API 鍏煎鎺ュ彛**
|
| 12 |
+
|
| 13 |
+
[](https://www.python.org/downloads/)
|
| 14 |
+
[](LICENSE)
|
| 15 |
+
[](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
|
| 16 |
+
|
| 17 |
+
[English](docs/README_EN.md) | 涓枃 | [鏃ユ湰瑾瀅(docs/README_JA.md)
|
| 18 |
+
|
| 19 |
+
## 馃殌 蹇€熼儴缃?
|
| 20 |
+
|
| 21 |
+
[](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
|
| 22 |
+
[](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 鈿狅笍 璁稿彲璇佸0鏄?
|
| 26 |
+
|
| 27 |
+
**鏈」鐩噰鐢?Cooperative Non-Commercial License (CNC-1.0)**
|
| 28 |
+
|
| 29 |
+
杩欐槸涓€涓弽鍟嗕笟鍖栫殑涓ユ牸寮€婧愬崗璁紝璇︽儏璇锋煡鐪?[LICENSE](LICENSE) 鏂囦欢銆?
|
| 30 |
+
|
| 31 |
+
### 鉁?鍏佽鐨勭敤閫旓細
|
| 32 |
+
- 涓汉瀛︿範銆佺爺绌躲€佹暀鑲茬敤閫?
|
| 33 |
+
- 闈炶惀鍒╃粍缁囦娇鐢?
|
| 34 |
+
- 寮€婧愰」鐩泦鎴愶紙闇€閬靛惊鐩稿悓鍗忚锛?
|
| 35 |
+
- 瀛︽湳鐮旂┒鍜岃鏂囧彂琛?
|
| 36 |
+
|
| 37 |
+
### 鉂?绂佹鐨勭敤閫旓細
|
| 38 |
+
- 浠讳綍褰㈠紡鐨勫晢涓氫娇鐢?
|
| 39 |
+
- 骞存敹鍏ヨ秴杩?00涓囩編鍏冪殑浼佷笟浣跨敤
|
| 40 |
+
- 椋庢姇鏀寔鎴栧叕寮€浜ゆ槗鐨勫叕鍙镐娇鐢?
|
| 41 |
+
- 鎻愪緵浠樿垂鏈嶅姟鎴栦骇鍝?
|
| 42 |
+
- 鍟嗕笟绔炰簤鐢ㄩ€?
|
| 43 |
+
|
| 44 |
+
## 鏍稿績鍔熻兘
|
| 45 |
+
|
| 46 |
+
### 馃攧 API 绔偣鍜屾牸寮忔敮鎸?
|
| 47 |
+
|
| 48 |
+
**澶氱鐐瑰鏍煎紡鏀寔**
|
| 49 |
+
- **OpenAI 鍏煎绔偣**锛歚/v1/chat/completions` 鍜?`/v1/models`
|
| 50 |
+
- 鏀寔鏍囧噯 OpenAI 鏍煎紡锛坢essages 缁撴瀯锛?
|
| 51 |
+
- 鏀寔 Gemini 鍘熺敓鏍煎紡锛坈ontents 缁撴瀯锛?
|
| 52 |
+
- 鑷姩鏍煎紡妫€娴嬪拰杞崲锛屾棤闇€鎵嬪姩鍒囨崲
|
| 53 |
+
- 鏀寔澶氭ā鎬佽緭鍏ワ紙鏂囨湰 + 鍥惧儚锛?
|
| 54 |
+
- **Gemini 鍘熺敓绔偣**锛歚/v1/models/{model}:generateContent` 鍜?`streamGenerateContent`
|
| 55 |
+
- 鏀寔瀹屾暣鐨?Gemini 鍘熺敓 API 瑙勮寖
|
| 56 |
+
- 澶氱璁よ瘉鏂瑰紡锛欱earer Token銆亁-goog-api-key 澶撮儴銆乁RL 鍙傛暟 key
|
| 57 |
+
- **Claude 鏍煎紡鍏煎**锛氬畬鏁存敮鎸?Claude API 鏍煎紡
|
| 58 |
+
- 绔偣锛歚/v1/messages`锛堥伒寰?Claude API 瑙勮寖锛?
|
| 59 |
+
- 鏀寔 Claude 鏍囧噯鐨?messages 鏍煎紡
|
| 60 |
+
- 鏀寔 system 鍙傛暟鍜?Claude 鐗规湁鍔熻兘
|
| 61 |
+
- 鑷姩杞崲涓哄悗绔敮鎸佺殑鏍煎紡
|
| 62 |
+
- **Antigravity API 鏀寔**锛氬悓鏃舵敮鎸?OpenAI銆丟emini 鍜?Claude 鏍煎紡
|
| 63 |
+
- OpenAI 鏍煎紡绔偣锛歚/antigravity/v1/chat/completions`
|
| 64 |
+
- Gemini 鏍煎紡绔偣锛歚/antigravity/v1/models/{model}:generateContent` 鍜?`streamGenerateContent`
|
| 65 |
+
- Claude 鏍煎紡绔偣锛歚/antigravity/v1/messages`
|
| 66 |
+
- 鏀寔鎵€鏈?Antigravity 妯″瀷锛圕laude銆丟emini 绛夛級
|
| 67 |
+
- 鑷姩妯″瀷鍚嶇О鏄犲皠鍜屾€濈淮妯″紡妫€娴?
|
| 68 |
+
|
| 69 |
+
### 馃攼 璁よ瘉鍜屽畨鍏ㄧ鐞?
|
| 70 |
+
|
| 71 |
+
**鐏垫椿鐨勫瘑鐮佺鐞?*
|
| 72 |
+
- **鍒嗙瀵嗙爜鏀寔**锛欰PI 瀵嗙爜锛堣亰澶╃鐐癸級鍜屾帶鍒堕潰鏉垮瘑鐮佸彲鐙珛璁剧疆
|
| 73 |
+
- **澶氱璁よ瘉鏂瑰紡**锛氭敮鎸?Authorization Bearer銆亁-goog-api-key 澶撮儴銆乁RL 鍙傛暟绛?
|
| 74 |
+
- **JWT Token 璁よ瘉**锛氭帶鍒堕潰鏉挎敮鎸?JWT 浠ょ墝璁よ瘉
|
| 75 |
+
- **鐢ㄦ埛閭鑾峰彇**锛氳嚜鍔ㄨ幏鍙栧拰鏄剧ず Google 璐︽埛閭鍦板潃
|
| 76 |
+
|
| 77 |
+
### 馃搳 鏅鸿兘鍑瘉绠$悊绯荤粺
|
| 78 |
+
|
| 79 |
+
**楂樼骇鍑瘉绠$悊**
|
| 80 |
+
- 澶氫釜 Google OAuth 鍑瘉鑷姩杞崲
|
| 81 |
+
- 閫氳繃鍐椾綑璁よ瘉澧炲己绋冲畾鎬?
|
| 82 |
+
- 璐熻浇鍧囪 涓庡苟鍙戣姹傛敮鎸?
|
| 83 |
+
- 鑷姩鏁呴殰妫€娴嬪拰鍑瘉绂佺敤
|
| 84 |
+
- 鍑瘉浣跨敤缁熻鍜岄厤棰濈鐞?
|
| 85 |
+
- 鏀寔鎵嬪姩鍚敤/绂佺敤鍑瘉鏂囦欢
|
| 86 |
+
- 鎵归噺鍑瘉鏂囦欢鎿嶄綔锛堝惎鐢ㄣ€佺鐢ㄣ€佸垹闄わ級
|
| 87 |
+
|
| 88 |
+
**鍑瘉鐘舵€佺洃鎺?*
|
| 89 |
+
- 瀹炴椂鍑瘉鍋ュ悍妫€鏌?
|
| 90 |
+
- 閿欒鐮佽拷韪紙429銆?03銆?00 绛夛級
|
| 91 |
+
- 鑷姩灏佺鏈哄埗锛堝彲閰嶇疆锛?
|
| 92 |
+
|
| 93 |
+
### 馃寠 娴佸紡浼犺緭鍜屽搷搴斿鐞?
|
| 94 |
+
|
| 95 |
+
**澶氱娴佸紡鏀寔**
|
| 96 |
+
- 鐪熸鐨勫疄鏃舵祦寮忓搷搴?
|
| 97 |
+
- 鍋囨祦寮忔ā寮忥紙鐢ㄤ簬鍏煎鎬э級
|
| 98 |
+
- 娴佸紡鎶楁埅鏂姛鑳斤紙闃叉鍥炵瓟琚埅鏂級
|
| 99 |
+
- 寮傛浠诲姟绠$悊鍜岃秴鏃跺鐞?
|
| 100 |
+
|
| 101 |
+
**鍝嶅簲浼樺寲**
|
| 102 |
+
- 鎬濈淮閾撅紙Thinking锛夊唴瀹瑰垎绂?
|
| 103 |
+
- 鎺ㄧ悊杩囩▼锛坮easoning_content锛夊鐞?
|
| 104 |
+
- 澶氳疆瀵硅瘽涓婁笅鏂囩鐞?
|
| 105 |
+
- 鍏煎鎬фā寮忥紙灏?system 娑堟伅杞崲涓?user 娑堟伅锛?
|
| 106 |
+
|
| 107 |
+
### 馃帥锔?Web 绠$悊鎺у埗鍙?
|
| 108 |
+
|
| 109 |
+
**鍏ㄥ姛鑳?Web 鐣岄潰**
|
| 110 |
+
- OAuth 璁よ瘉娴佺▼绠$悊锛堟敮鎸?GCLI 鍜?Antigravity 鍙屾ā寮忥級
|
| 111 |
+
- 鍑瘉鏂囦欢涓婁紶銆佷笅杞姐€佺鐞?
|
| 112 |
+
- 瀹炴椂鏃ュ織鏌ョ湅锛圵ebSocket锛?
|
| 113 |
+
- 绯荤粺閰嶇疆绠$悊
|
| 114 |
+
- 浣跨敤缁熻鍜岀洃鎺ч潰鏉?
|
| 115 |
+
- 绉诲姩绔€傞厤鐣岄潰
|
| 116 |
+
|
| 117 |
+
**鎵归噺鎿嶄綔鏀寔**
|
| 118 |
+
- ZIP 鏂囦欢鎵归噺涓婁紶鍑瘉锛圙CLI 鍜?Antigravity锛?
|
| 119 |
+
- 鎵归噺鍚敤/绂佺敤/鍒犻櫎鍑瘉
|
| 120 |
+
- 鎵归噺鑾峰彇鐢ㄦ埛閭
|
| 121 |
+
- 鎵归噺閰嶇疆绠$悊
|
| 122 |
+
- 缁熶竴鎵归噺涓婁紶鐣岄潰绠$悊鎵€鏈夊嚟璇佺被鍨?
|
| 123 |
+
|
| 124 |
+
### 馃搱 浣跨敤鐩戞帶
|
| 125 |
+
|
| 126 |
+
**瀹炴椂鐩戞帶**
|
| 127 |
+
- WebSocket 瀹炴椂鏃ュ織娴?
|
| 128 |
+
- 绯荤粺鐘舵€佺洃鎺?
|
| 129 |
+
- 鍑瘉鍋ュ悍鐘舵€?
|
| 130 |
+
|
| 131 |
+
### 馃敡 楂樼骇閰嶇疆鍜岃嚜瀹氫箟
|
| 132 |
+
|
| 133 |
+
**缃戠粶鍜屼唬鐞嗛厤缃?*
|
| 134 |
+
- HTTP/HTTPS 浠g悊鏀寔
|
| 135 |
+
- 浠g悊绔偣閰嶇疆锛圤Auth銆丟oogle APIs銆佸厓鏁版嵁鏈嶅姟锛?
|
| 136 |
+
- 瓒呮椂鍜岄噸璇曢厤缃?
|
| 137 |
+
- 缃戠粶閿欒澶勭悊鍜屾仮澶?
|
| 138 |
+
|
| 139 |
+
**鎬ц兘鍜岀ǔ瀹氭€ч厤缃?*
|
| 140 |
+
- 429 閿欒鑷姩閲嶈瘯锛堝彲閰嶇疆闂撮殧鍜屾鏁帮級
|
| 141 |
+
- 鎶楁埅鏂渶澶ч噸璇曟鏁?
|
| 142 |
+
|
| 143 |
+
**鏃ュ織鍜岃皟璇?*
|
| 144 |
+
- 澶氱骇鏃ュ織绯荤粺锛圖EBUG銆両NFO銆乄ARNING銆丒RROR锛?
|
| 145 |
+
- 鏃ュ織鏂囦欢绠$悊
|
| 146 |
+
- 瀹炴椂鏃ュ織娴?
|
| 147 |
+
- 鏃ュ織涓嬭浇鍜屾竻绌?
|
| 148 |
+
|
| 149 |
+
### 馃攧 鐜鍙橀噺鍜岄厤缃鐞?
|
| 150 |
+
|
| 151 |
+
**鐏垫椿鐨勯厤缃柟寮?*
|
| 152 |
+
- 鐜鍙橀噺閰嶇疆
|
| 153 |
+
- 鐑厤缃洿鏂帮紙閮ㄥ垎閰嶇疆椤癸級
|
| 154 |
+
- 閰嶇疆閿佸畾锛堢幆澧冨彉閲忎紭鍏堢骇锛?
|
| 155 |
+
|
| 156 |
+
## 鏀寔鐨勬ā鍨?
|
| 157 |
+
|
| 158 |
+
鎵€鏈夋ā鍨嬪潎鍏峰 1M 涓婁笅鏂囩獥鍙e閲忋€傛瘡涓嚟璇佹枃浠舵彁渚?1000 娆¤姹傞搴︺€?
|
| 159 |
+
|
| 160 |
+
### 馃 鍩虹妯″瀷
|
| 161 |
+
- `gemini-2.5-pro`
|
| 162 |
+
- `gemini-3-pro-preview`
|
| 163 |
+
- `gemini-3.1-pro-preview`
|
| 164 |
+
|
| 165 |
+
### 馃 鎬濈淮妯″瀷锛圱hinking Models锛?
|
| 166 |
+
- `gemini-2.5-pro-high`锛氭€濊€冩ā寮?
|
| 167 |
+
- `gemini-2.5-pro-low`锛氫綆鎬濊€冩ā寮?
|
| 168 |
+
- 鏀寔鑷畾涔夋€濊€冮绠楅厤缃?
|
| 169 |
+
- 鑷姩鍒嗙鎬濈淮鍐呭鍜屾渶缁堝洖绛?
|
| 170 |
+
|
| 171 |
+
### 馃攳 鎼滅储澧炲己妯″瀷
|
| 172 |
+
- `gemini-2.5-pro-search`锛氶泦鎴愭悳绱㈠姛鑳界殑妯″瀷
|
| 173 |
+
|
| 174 |
+
### 馃柤锔?鍥惧儚鐢熸垚妯″瀷锛圓ntigravity锛?
|
| 175 |
+
- `gemini-3.1-flash-image`锛氬熀纭€鍥惧儚鐢熸垚妯″瀷
|
| 176 |
+
- **鍒嗚鲸鐜囧悗缂€**锛?
|
| 177 |
+
- `-2k`锛?K 鍒嗚鲸鐜?
|
| 178 |
+
- `-4k`锛?K 楂樻竻鍒嗚鲸鐜?
|
| 179 |
+
- **姣斾緥鍚庣紑**锛?
|
| 180 |
+
- `-1x1`锛氭鏂瑰舰锛堝ご鍍忥級
|
| 181 |
+
- `-16x9`锛氭í灞忥紙鐢佃剳澹佺焊锛?
|
| 182 |
+
- `-9x16`锛氱珫灞忥紙鎵嬫満澹佺焊锛?
|
| 183 |
+
- `-21x9`锛氳秴瀹藉睆锛堝甫楸煎睆锛?
|
| 184 |
+
- `-4x3`锛氫紶缁熸樉绀哄櫒
|
| 185 |
+
- `-3x4`锛氱珫鐗堟捣鎶?
|
| 186 |
+
- **缁勫悎浣跨敤绀轰緥**锛?
|
| 187 |
+
- `gemini-3.1-flash-image-4k-16x9`锛?K 妯睆
|
| 188 |
+
- `gemini-3.1-flash-image-2k-9x16`锛?K 绔栧睆
|
| 189 |
+
- 涓嶆寚瀹氭瘮渚嬫椂锛孉PI 鑷姩鍐冲畾妯珫姣斾緥
|
| 190 |
+
|
| 191 |
+
### 馃寠 鐗规畩鍔熻兘鍙樹綋
|
| 192 |
+
- **鍋囨祦寮忔ā寮?*锛氬湪浠讳綍妯″瀷鍚嶇О鍚庢坊鍔?`-鍋囨祦寮廯 鍚庣紑
|
| 193 |
+
- 渚嬶細`gemini-2.5-pro-鍋囨祦寮廯
|
| 194 |
+
- 鐢ㄤ簬闇€瑕佹祦寮忓搷搴斾絾鏈嶅姟绔笉鏀寔鐪熸祦寮忕殑鍦烘櫙
|
| 195 |
+
- **娴佸紡鎶楁埅鏂ā寮?*锛氬湪妯″瀷鍚嶇О鍓嶆坊鍔?`娴佸紡鎶楁埅鏂?` 鍓嶇紑
|
| 196 |
+
- 渚嬶細`娴佸紡鎶楁埅鏂?gemini-2.5-pro`
|
| 197 |
+
- 鑷姩妫€娴嬪搷搴旀埅鏂苟閲嶈瘯锛岀‘淇濆畬鏁村洖绛?
|
| 198 |
+
|
| 199 |
+
### 馃敡 妯″瀷鍔熻兘鑷姩妫€娴?
|
| 200 |
+
- 绯荤粺鑷姩璇嗗埆妯″瀷鍚嶇О涓殑鍔熻兘鏍囪瘑
|
| 201 |
+
- 閫忔槑鍦板鐞嗗姛鑳芥ā寮忚浆鎹?
|
| 202 |
+
- 鏀寔鍔熻兘缁勫悎浣跨敤
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## 瀹夎鎸囧崡
|
| 208 |
+
|
| 209 |
+
### Termux 鐜
|
| 210 |
+
|
| 211 |
+
**鍒濆瀹夎**
|
| 212 |
+
```bash
|
| 213 |
+
curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
**閲嶅惎鏈嶅姟**
|
| 217 |
+
```bash
|
| 218 |
+
cd gcli2api
|
| 219 |
+
bash termux-start.sh
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
### Windows 鐜
|
| 223 |
+
|
| 224 |
+
**鍒濆瀹夎**
|
| 225 |
+
```powershell
|
| 226 |
+
iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
**閲嶅惎鏈嶅姟**
|
| 230 |
+
鍙屽嚮鎵ц `start.bat`
|
| 231 |
+
|
| 232 |
+
### Linux 鐜
|
| 233 |
+
|
| 234 |
+
**鍒濆瀹夎**
|
| 235 |
+
```bash
|
| 236 |
+
curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
**閲嶅惎鏈嶅姟**
|
| 240 |
+
```bash
|
| 241 |
+
cd gcli2api
|
| 242 |
+
bash start.sh
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
### macOS 鐜
|
| 246 |
+
|
| 247 |
+
**鍒濆瀹夎**
|
| 248 |
+
```bash
|
| 249 |
+
curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
**閲嶅惎鏈嶅姟**
|
| 253 |
+
```bash
|
| 254 |
+
cd gcli2api
|
| 255 |
+
bash start.sh
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
### Docker 鐜
|
| 259 |
+
|
| 260 |
+
**Docker 杩愯鍛戒护**
|
| 261 |
+
```bash
|
| 262 |
+
# 浣跨敤閫氱敤瀵嗙爜
|
| 263 |
+
docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
|
| 264 |
+
|
| 265 |
+
# 浣跨敤鍒嗙瀵嗙爜
|
| 266 |
+
docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
**Docker Mac**
|
| 270 |
+
```bash
|
| 271 |
+
# 浣跨敤閫氱敤瀵嗙爜
|
| 272 |
+
docker run -d \
|
| 273 |
+
--name gcli2api \
|
| 274 |
+
-p 7861:7861 \
|
| 275 |
+
-p 8080:8080 \
|
| 276 |
+
-e PASSWORD=pwd \
|
| 277 |
+
-e PORT=7861 \
|
| 278 |
+
-v "$(pwd)/data/creds":/app/creds \
|
| 279 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
```bash
|
| 283 |
+
# 浣跨敤鍒嗙瀵嗙爜
|
| 284 |
+
docker run -d \
|
| 285 |
+
--name gcli2api \
|
| 286 |
+
-p 7861:7861 \
|
| 287 |
+
-p 8080:8080 \
|
| 288 |
+
-e API_PASSWORD=api_pwd \
|
| 289 |
+
-e PANEL_PASSWORD=panel_pwd \
|
| 290 |
+
-e PORT=7861 \
|
| 291 |
+
-v $(pwd)/data/creds:/app/creds \
|
| 292 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
**Docker Compose 杩愯鍛戒护**
|
| 296 |
+
1. 灏嗕互涓嬪唴瀹逛繚瀛樹负 `docker-compose.yml` 鏂囦欢锛?
|
| 297 |
+
```yaml
|
| 298 |
+
version: '3.8'
|
| 299 |
+
|
| 300 |
+
services:
|
| 301 |
+
gcli2api:
|
| 302 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 303 |
+
container_name: gcli2api
|
| 304 |
+
restart: unless-stopped
|
| 305 |
+
network_mode: host
|
| 306 |
+
environment:
|
| 307 |
+
# 浣跨敤閫氱敤瀵嗙爜锛堟帹鑽愮敤浜庣畝鍗曢儴缃诧級
|
| 308 |
+
- PASSWORD=pwd
|
| 309 |
+
- PORT=7861
|
| 310 |
+
# 鎴栦娇鐢ㄥ垎绂诲瘑鐮侊紙鎺ㄨ崘鐢ㄤ簬鐢熶骇鐜锛?
|
| 311 |
+
# - API_PASSWORD=your_api_password
|
| 312 |
+
# - PANEL_PASSWORD=your_panel_password
|
| 313 |
+
volumes:
|
| 314 |
+
- ./data/creds:/app/creds
|
| 315 |
+
healthcheck:
|
| 316 |
+
test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
|
| 317 |
+
interval: 30s
|
| 318 |
+
timeout: 10s
|
| 319 |
+
retries: 3
|
| 320 |
+
start_period: 40s
|
| 321 |
+
```
|
| 322 |
+
2. 鍚姩鏈嶅姟锛?
|
| 323 |
+
```bash
|
| 324 |
+
docker-compose up -d
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
---
|
| 328 |
+
|
| 329 |
+
## 閰嶇疆璇存槑
|
| 330 |
+
|
| 331 |
+
1. 璁块棶 `http://127.0.0.1:7861` 锛堥粯璁ょ鍙o紝鍙€氳繃 PORT 鐜鍙橀噺淇敼锛?
|
| 332 |
+
2. 瀹屾垚 OAuth 璁よ瘉娴佺▼锛堥粯璁ゅ瘑鐮侊細`pwd`锛屽彲閫氳繃鐜鍙橀噺淇敼锛?
|
| 333 |
+
- **GCLI 妯″紡**锛氱敤浜庤幏鍙?Google Cloud Gemini API 鍑瘉
|
| 334 |
+
- **Antigravity 妯″紡**锛氱敤浜庤幏鍙?Google Antigravity API 鍑瘉
|
| 335 |
+
3. 閰嶇疆瀹㈡埛绔細
|
| 336 |
+
|
| 337 |
+
**OpenAI 鍏煎瀹㈡埛绔細**
|
| 338 |
+
- **绔偣鍦板潃**锛歚http://127.0.0.1:7861/v1`
|
| 339 |
+
- **API 瀵嗛挜**锛歚pwd`锛堥粯璁ゅ€硷紝鍙€氳繃 API_PASSWORD 鎴?PASSWORD 鐜鍙橀噺淇敼锛?
|
| 340 |
+
|
| 341 |
+
**Gemini 鍘熺敓瀹㈡埛绔細**
|
| 342 |
+
- **绔偣鍦板潃**锛歚http://127.0.0.1:7861`
|
| 343 |
+
- **璁よ瘉鏂瑰紡**锛?
|
| 344 |
+
- `Authorization: Bearer your_api_password`
|
| 345 |
+
- `x-goog-api-key: your_api_password`
|
| 346 |
+
- URL 鍙傛暟锛歚?key=your_api_password`
|
| 347 |
+
|
| 348 |
+
### 馃専 鍙岃璇佹ā寮忔敮鎸?
|
| 349 |
+
|
| 350 |
+
**GCLI 璁よ瘉妯″紡**
|
| 351 |
+
- 鏍囧噯鐨?Google Cloud Gemini API 璁よ瘉
|
| 352 |
+
- 鏀寔 OAuth2.0 璁よ瘉娴佺▼
|
| 353 |
+
- 鑷姩鍚敤蹇呴渶鐨?Google Cloud API
|
| 354 |
+
|
| 355 |
+
**Antigravity 璁よ瘉妯″紡**
|
| 356 |
+
- Google Antigravity API 涓撶敤璁よ瘉
|
| 357 |
+
- 鐙珛鐨勫嚟璇佺鐞嗙郴缁?
|
| 358 |
+
- 鏀寔鎵归噺涓婁紶鍜岀鐞?
|
| 359 |
+
- 涓?GCLI 鍑瘉瀹屽叏闅旂
|
| 360 |
+
|
| 361 |
+
**缁熶竴绠$悊鐣岄潰**
|
| 362 |
+
- 鍦?鎵归噺涓婁紶"鏍囩椤典腑鍙竴娆℃€х鐞嗕袱绉嶅嚟璇?
|
| 363 |
+
- 涓婂崐閮ㄥ垎锛欸CLI 鍑瘉鎵归噺涓婁紶锛堣摑鑹蹭富棰橈級
|
| 364 |
+
- 涓嬪崐閮ㄥ垎锛欰ntigravity 鍑瘉鎵归噺涓婁紶锛堢豢鑹蹭富棰橈級
|
| 365 |
+
- 鍚勮嚜鐙珛鐨勫嚟璇佺鐞嗘爣绛鹃〉
|
| 366 |
+
|
| 367 |
+
## 馃捑 鏁版嵁瀛樺偍妯″紡
|
| 368 |
+
|
| 369 |
+
### 馃専 瀛樺偍鍚庣鏀寔
|
| 370 |
+
|
| 371 |
+
gcli2api 鏀寔涓ょ瀛樺偍鍚庣锛?*鏈湴 SQLite锛堥粯璁わ級** 鍜?**MongoDB锛堜簯绔垎甯冨紡瀛樺偍锛?*
|
| 372 |
+
|
| 373 |
+
### 馃搧 鏈湴 SQLite 瀛樺偍锛堥粯璁わ級
|
| 374 |
+
|
| 375 |
+
**榛樿瀛樺偍鏂瑰紡**
|
| 376 |
+
- 鏃犻渶閰嶇疆锛屽紑绠卞嵆鐢?
|
| 377 |
+
- 鏁版嵁瀛樺偍鍦ㄦ湰鍦?SQLite 鏁版嵁搴撲腑
|
| 378 |
+
- 閫傚悎鍗曟満閮ㄧ讲鍜屼釜浜轰娇鐢?
|
| 379 |
+
- 鑷姩鍒涘缓鍜岀鐞嗘暟鎹簱鏂囦欢
|
| 380 |
+
|
| 381 |
+
### 馃崈 MongoDB 浜戠瀛樺偍妯″紡
|
| 382 |
+
|
| 383 |
+
**浜戠鍒嗗竷寮忓瓨鍌ㄦ柟妗?*
|
| 384 |
+
|
| 385 |
+
褰撻渶瑕佸瀹炰緥閮ㄧ讲鎴栦簯绔瓨鍌ㄦ椂锛屽彲浠ュ惎鐢?MongoDB 瀛樺偍妯″紡銆?
|
| 386 |
+
|
| 387 |
+
### 鈿欙笍 鍚敤 MongoDB 妯″紡
|
| 388 |
+
|
| 389 |
+
**姝ラ 1: 閰嶇疆 MongoDB 杩炴帴**
|
| 390 |
+
```bash
|
| 391 |
+
# 鏈湴 MongoDB
|
| 392 |
+
export MONGODB_URI="mongodb://localhost:27017"
|
| 393 |
+
|
| 394 |
+
# MongoDB Atlas 浜戞湇鍔?
|
| 395 |
+
export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
|
| 396 |
+
|
| 397 |
+
# 甯﹁璇佺殑 MongoDB
|
| 398 |
+
export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
|
| 399 |
+
|
| 400 |
+
# 鍙€夛細鑷畾涔夋暟鎹��鍚嶇О锛堥粯璁? gcli2api锛?
|
| 401 |
+
export MONGODB_DATABASE="my_gcli_db"
|
| 402 |
+
```
|
| 403 |
+
|
| 404 |
+
**姝ラ 2: 鍚姩搴旂敤**
|
| 405 |
+
```bash
|
| 406 |
+
# 搴旂敤浼氳嚜鍔ㄦ娴?MongoDB 閰嶇疆骞朵娇鐢?MongoDB 瀛樺偍
|
| 407 |
+
python web.py
|
| 408 |
+
```
|
| 409 |
+
|
| 410 |
+
**Docker 鐜浣跨敤 MongoDB**
|
| 411 |
+
```bash
|
| 412 |
+
# 鍗曟満 MongoDB 閮ㄧ讲
|
| 413 |
+
docker run -d --name gcli2api \
|
| 414 |
+
-e MONGODB_URI="mongodb://mongodb:27017" \
|
| 415 |
+
-e API_PASSWORD=your_password \
|
| 416 |
+
--network your_network \
|
| 417 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 418 |
+
|
| 419 |
+
# 浣跨敤 MongoDB Atlas
|
| 420 |
+
docker run -d --name gcli2api \
|
| 421 |
+
-e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
|
| 422 |
+
-e API_PASSWORD=your_password \
|
| 423 |
+
-p 7861:7861 \
|
| 424 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 425 |
+
```
|
| 426 |
+
|
| 427 |
+
**Docker Compose 绀轰緥**
|
| 428 |
+
```yaml
|
| 429 |
+
version: '3.8'
|
| 430 |
+
|
| 431 |
+
services:
|
| 432 |
+
mongodb:
|
| 433 |
+
image: mongo:7
|
| 434 |
+
container_name: gcli2api-mongodb
|
| 435 |
+
restart: unless-stopped
|
| 436 |
+
environment:
|
| 437 |
+
MONGO_INITDB_ROOT_USERNAME: admin
|
| 438 |
+
MONGO_INITDB_ROOT_PASSWORD: password123
|
| 439 |
+
volumes:
|
| 440 |
+
- mongodb_data:/data/db
|
| 441 |
+
ports:
|
| 442 |
+
- "27017:27017"
|
| 443 |
+
|
| 444 |
+
gcli2api:
|
| 445 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 446 |
+
container_name: gcli2api
|
| 447 |
+
restart: unless-stopped
|
| 448 |
+
depends_on:
|
| 449 |
+
- mongodb
|
| 450 |
+
environment:
|
| 451 |
+
- MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
|
| 452 |
+
- MONGODB_DATABASE=gcli2api
|
| 453 |
+
- API_PASSWORD=your_api_password
|
| 454 |
+
- PORT=7861
|
| 455 |
+
ports:
|
| 456 |
+
- "7861:7861"
|
| 457 |
+
|
| 458 |
+
volumes:
|
| 459 |
+
mongodb_data:
|
| 460 |
+
```
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
### 馃敡 楂樼骇閰嶇疆
|
| 464 |
+
|
| 465 |
+
**MongoDB 杩炴帴浼樺寲**
|
| 466 |
+
```bash
|
| 467 |
+
# 杩炴帴姹犲拰瓒呮椂閰嶇疆
|
| 468 |
+
export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
|
| 469 |
+
|
| 470 |
+
# 鍓湰闆嗛厤缃?
|
| 471 |
+
export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
|
| 472 |
+
|
| 473 |
+
# 璇诲啓鍒嗙閰嶇疆
|
| 474 |
+
export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
|
| 475 |
+
```
|
| 476 |
+
|
| 477 |
+
### 鐜鍙橀噺閰嶇疆
|
| 478 |
+
|
| 479 |
+
**鍩虹閰嶇疆**
|
| 480 |
+
- `PORT`: 鏈嶅姟绔彛锛堥粯璁わ細7861锛?
|
| 481 |
+
- `HOST`: 鏈嶅姟鍣ㄧ洃鍚湴鍧€锛堥粯璁わ細0.0.0.0锛?
|
| 482 |
+
|
| 483 |
+
**瀵嗙爜閰嶇疆**
|
| 484 |
+
- `API_PASSWORD`: 鑱婂ぉ API 璁块棶瀵嗙爜锛堥粯璁わ細缁ф壙 PASSWORD 鎴?pwd锛?
|
| 485 |
+
- `PANEL_PASSWORD`: 鎺у埗闈㈡澘璁块棶瀵嗙爜锛堥粯璁わ細缁ф壙 PASSWORD 鎴?pwd锛?
|
| 486 |
+
- `PASSWORD`: 閫氱敤瀵嗙爜锛岃缃悗瑕嗙洊涓婅堪涓や釜锛堥粯璁わ細pwd锛?
|
| 487 |
+
|
| 488 |
+
**鎬ц兘鍜岀ǔ瀹氭€ч厤缃?*
|
| 489 |
+
- `RETRY_429_ENABLED`: 鍚敤 429 閿欒鑷姩閲嶈瘯锛堥粯璁わ細true锛?
|
| 490 |
+
- `RETRY_429_MAX_RETRIES`: 429 閿欒鏈€澶ч噸璇曟鏁帮紙榛樿锛?锛?
|
| 491 |
+
- `RETRY_429_INTERVAL`: 429 閿欒閲嶈瘯闂撮殧锛岀锛堥粯璁わ細1.0锛?
|
| 492 |
+
- `ANTI_TRUNCATION_MAX_ATTEMPTS`: 鎶楁埅鏂渶澶ч噸璇曟鏁帮紙榛樿锛?锛?
|
| 493 |
+
|
| 494 |
+
**缃戠粶鍜屼唬鐞嗛厤缃?*
|
| 495 |
+
- `PROXY`: HTTP/HTTPS 浠g悊鍦板潃锛堟牸寮忥細`http://host:port`锛?
|
| 496 |
+
- `OAUTH_PROXY_URL`: OAuth 璁よ瘉浠g悊绔偣
|
| 497 |
+
- `GOOGLEAPIS_PROXY_URL`: Google APIs 浠g悊绔偣
|
| 498 |
+
- `METADATA_SERVICE_URL`: 鍏冩暟鎹湇鍔′唬鐞嗙鐐?
|
| 499 |
+
|
| 500 |
+
**鑷姩鍖栭厤缃?*
|
| 501 |
+
- `AUTO_BAN`: 鍚敤鍑瘉鑷姩灏佺锛堥粯璁わ細true锛?
|
| 502 |
+
- `AUTO_LOAD_ENV_CREDS`: 鍚姩鏃惰嚜鍔ㄥ姞杞界幆澧冨彉閲忓嚟璇侊紙榛樿锛歠alse锛?
|
| 503 |
+
|
| 504 |
+
**鍏煎鎬ч厤缃?*
|
| 505 |
+
- `COMPATIBILITY_MODE`: 鍚敤鍏煎鎬фā寮忥紝灏?system 娑堟伅杞负 user 娑堟伅锛堥粯璁わ細false锛?
|
| 506 |
+
|
| 507 |
+
**鏃ュ織閰嶇疆**
|
| 508 |
+
- `LOG_LEVEL`: 鏃ュ織绾у埆锛圖EBUG/INFO/WARNING/ERROR锛岄粯璁わ細INFO锛?
|
| 509 |
+
- `LOG_FILE`: 鏃ュ織鏂囦欢璺緞锛堥粯璁わ細log.txt锛?
|
| 510 |
+
|
| 511 |
+
**瀛樺偍閰嶇疆**
|
| 512 |
+
|
| 513 |
+
**SQLite 閰嶇疆锛堥粯璁わ級**
|
| 514 |
+
- 鏃犻渶閰嶇疆锛岃嚜鍔ㄤ娇鐢ㄦ湰鍦?SQLite 鏁版嵁搴?
|
| 515 |
+
- 鏁版嵁搴撴枃浠惰嚜鍔ㄥ垱寤哄湪椤圭洰鐩綍
|
| 516 |
+
|
| 517 |
+
**MongoDB 閰嶇疆锛堝彲閫変簯绔瓨鍌級**
|
| 518 |
+
- `MONGODB_URI`: MongoDB 杩炴帴瀛楃涓诧紙璁剧疆鍚庡惎鐢?MongoDB 妯″紡锛?
|
| 519 |
+
- `MONGODB_DATABASE`: MongoDB 鏁版嵁搴撳悕绉帮紙榛樿锛歡cli2api锛?
|
| 520 |
+
|
| 521 |
+
**Docker 浣跨敤绀轰緥**
|
| 522 |
+
```bash
|
| 523 |
+
# 浣跨敤閫氱敤瀵嗙爜
|
| 524 |
+
docker run -d --name gcli2api \
|
| 525 |
+
-e PASSWORD=mypassword \
|
| 526 |
+
-e PORT=7861 \
|
| 527 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 528 |
+
|
| 529 |
+
# 浣跨敤鍒嗙瀵嗙爜
|
| 530 |
+
docker run -d --name gcli2api \
|
| 531 |
+
-e API_PASSWORD=my_api_password \
|
| 532 |
+
-e PANEL_PASSWORD=my_panel_password \
|
| 533 |
+
-e PORT=7861 \
|
| 534 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 535 |
+
```
|
| 536 |
+
|
| 537 |
+
娉ㄦ剰锛氬綋璁剧疆浜嗗嚟璇佺幆澧冨彉閲忔椂锛岀郴缁熷皢浼樺厛浣跨敤鐜鍙橀噺涓殑鍑瘉锛屽拷鐣?`creds` 鐩綍涓殑鏂囦欢銆?
|
| 538 |
+
|
| 539 |
+
### API 浣跨敤鏂瑰紡
|
| 540 |
+
|
| 541 |
+
鏈湇鍔℃敮鎸佷笁濂楀畬鏁寸殑 API 绔偣锛?
|
| 542 |
+
|
| 543 |
+
#### 1. OpenAI 鍏煎绔偣锛圙CLI锛?
|
| 544 |
+
|
| 545 |
+
**绔偣锛?* `/v1/chat/completions`
|
| 546 |
+
**璁よ瘉锛?* `Authorization: Bearer your_api_password`
|
| 547 |
+
|
| 548 |
+
鏀寔涓ょ璇锋眰鏍煎紡锛屼細鑷姩妫€娴嬪苟澶勭悊锛?
|
| 549 |
+
|
| 550 |
+
**OpenAI 鏍煎紡锛?*
|
| 551 |
+
```json
|
| 552 |
+
{
|
| 553 |
+
"model": "gemini-2.5-pro",
|
| 554 |
+
"messages": [
|
| 555 |
+
{"role": "system", "content": "You are a helpful assistant"},
|
| 556 |
+
{"role": "user", "content": "Hello"}
|
| 557 |
+
],
|
| 558 |
+
"temperature": 0.7,
|
| 559 |
+
"stream": true
|
| 560 |
+
}
|
| 561 |
+
```
|
| 562 |
+
|
| 563 |
+
**Gemini 鍘熺敓鏍煎紡锛?*
|
| 564 |
+
```json
|
| 565 |
+
{
|
| 566 |
+
"model": "gemini-2.5-pro",
|
| 567 |
+
"contents": [
|
| 568 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 569 |
+
],
|
| 570 |
+
"systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
|
| 571 |
+
"generationConfig": {
|
| 572 |
+
"temperature": 0.7
|
| 573 |
+
}
|
| 574 |
+
}
|
| 575 |
+
```
|
| 576 |
+
|
| 577 |
+
#### 2. Gemini 鍘熺敓绔偣锛圙CLI锛?
|
| 578 |
+
|
| 579 |
+
**闈炴祦寮忕鐐癸細** `/v1/models/{model}:generateContent`
|
| 580 |
+
**娴佸紡绔偣锛?* `/v1/models/{model}:streamGenerateContent`
|
| 581 |
+
**妯″瀷鍒楄〃锛?* `/v1/models`
|
| 582 |
+
|
| 583 |
+
**璁よ瘉鏂瑰紡锛堜换閫変竴绉嶏級锛?*
|
| 584 |
+
- `Authorization: Bearer your_api_password`
|
| 585 |
+
- `x-goog-api-key: your_api_password`
|
| 586 |
+
- URL 鍙傛暟锛歚?key=your_api_password`
|
| 587 |
+
|
| 588 |
+
**璇锋眰绀轰緥锛?*
|
| 589 |
+
```bash
|
| 590 |
+
# 浣跨敤 x-goog-api-key 澶撮儴
|
| 591 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
|
| 592 |
+
-H "x-goog-api-key: your_api_password" \
|
| 593 |
+
-H "Content-Type: application/json" \
|
| 594 |
+
-d '{
|
| 595 |
+
"contents": [
|
| 596 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 597 |
+
]
|
| 598 |
+
}'
|
| 599 |
+
|
| 600 |
+
# 浣跨敤 URL 鍙傛暟
|
| 601 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
|
| 602 |
+
-H "Content-Type: application/json" \
|
| 603 |
+
-d '{
|
| 604 |
+
"contents": [
|
| 605 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 606 |
+
]
|
| 607 |
+
}'
|
| 608 |
+
```
|
| 609 |
+
|
| 610 |
+
#### 3. Claude API 鏍煎紡绔偣
|
| 611 |
+
|
| 612 |
+
**绔偣锛?* `/v1/messages`
|
| 613 |
+
**璁よ瘉锛?* `x-api-key: your_api_password` 鎴?`Authorization: Bearer your_api_password`
|
| 614 |
+
|
| 615 |
+
**璇锋眰绀轰緥锛?*
|
| 616 |
+
```bash
|
| 617 |
+
curl -X POST "http://127.0.0.1:7861/v1/messages" \
|
| 618 |
+
-H "x-api-key: your_api_password" \
|
| 619 |
+
-H "anthropic-version: 2023-06-01" \
|
| 620 |
+
-H "Content-Type: application/json" \
|
| 621 |
+
-d '{
|
| 622 |
+
"model": "gemini-2.5-pro",
|
| 623 |
+
"max_tokens": 1024,
|
| 624 |
+
"messages": [
|
| 625 |
+
{"role": "user", "content": "Hello, Claude!"}
|
| 626 |
+
]
|
| 627 |
+
}'
|
| 628 |
+
```
|
| 629 |
+
|
| 630 |
+
**鏀寔 system 鍙傛暟锛?*
|
| 631 |
+
```json
|
| 632 |
+
{
|
| 633 |
+
"model": "gemini-2.5-pro",
|
| 634 |
+
"max_tokens": 1024,
|
| 635 |
+
"system": "You are a helpful assistant",
|
| 636 |
+
"messages": [
|
| 637 |
+
{"role": "user", "content": "Hello"}
|
| 638 |
+
]
|
| 639 |
+
}
|
| 640 |
+
```
|
| 641 |
+
|
| 642 |
+
**璇存槑锛?*
|
| 643 |
+
- 瀹屽叏鍏煎 Claude API 鏍煎紡瑙勮寖
|
| 644 |
+
- 鑷姩杞崲涓?Gemini 鏍煎紡璋冪敤鍚庣
|
| 645 |
+
- 鏀寔 Claude 鐨勬墍鏈夋爣鍑嗗弬鏁?
|
| 646 |
+
- 鍝嶅簲鏍煎紡绗﹀悎 Claude API 瑙勮寖
|
| 647 |
+
|
| 648 |
+
## 馃搵 瀹屾暣 API 鍙傝€?
|
| 649 |
+
|
| 650 |
+
### Web 鎺у埗鍙?API
|
| 651 |
+
|
| 652 |
+
**璁よ瘉绔偣**
|
| 653 |
+
- `POST /auth/login` - 鐢ㄦ埛鐧诲綍
|
| 654 |
+
- `POST /auth/start` - 寮€濮?OAuth 璁よ瘉锛堟敮鎸?GCLI 鍜?Antigravity 妯″紡锛?
|
| 655 |
+
- `POST /auth/callback` - 澶勭悊 OAuth 鍥炶皟
|
| 656 |
+
- `POST /auth/callback-url` - 浠庡洖璋?URL 鐩存帴瀹屾垚璁よ瘉
|
| 657 |
+
- `GET /auth/status/{project_id}` - 妫€鏌ヨ璇佺姸鎬?
|
| 658 |
+
|
| 659 |
+
**鍑瘉绠$悊绔偣**锛堟敮鎸?`mode=geminicli` 鎴?`mode=antigravity` 鍙傛暟锛?
|
| 660 |
+
- `POST /creds/upload` - 鎵归噺涓婁紶鍑瘉鏂囦欢锛堟敮鎸?JSON 鍜?ZIP锛?
|
| 661 |
+
- `GET /creds/status` - 鑾峰彇鍑瘉鐘舵€佸垪琛紙鏀寔鍒嗛〉鍜岀瓫閫夛級
|
| 662 |
+
- `GET /creds/detail/{filename}` - 鑾峰彇鍗曚釜鍑瘉璇︽儏
|
| 663 |
+
- `POST /creds/action` - 鍗曚釜鍑瘉鎿嶄綔锛堝惎鐢?绂佺敤/鍒犻櫎锛?
|
| 664 |
+
- `POST /creds/batch-action` - 鎵归噺鍑瘉鎿嶄綔
|
| 665 |
+
- `GET /creds/download/{filename}` - 涓嬭浇鍗曚釜鍑瘉鏂囦欢
|
| 666 |
+
- `GET /creds/download-all` - 鎵撳寘涓嬭浇鎵€鏈夊嚟璇?
|
| 667 |
+
- `POST /creds/fetch-email/{filename}` - 鑾峰彇鐢ㄦ埛閭
|
| 668 |
+
- `POST /creds/refresh-all-emails` - 鎵归噺鍒锋柊鐢ㄦ埛閭
|
| 669 |
+
- `POST /creds/deduplicate-by-email` - 鎸夐偖绠卞幓閲嶅嚟璇?
|
| 670 |
+
- `POST /creds/verify-project/{filename}` - 妫€楠屽嚟璇?Project ID
|
| 671 |
+
- `GET /creds/quota/{filename}` - 鑾峰彇鍑瘉棰濆害淇℃伅锛堜粎 Antigravity锛?
|
| 672 |
+
|
| 673 |
+
**閰嶇疆绠$悊绔偣**
|
| 674 |
+
- `GET /config/get` - 鑾峰彇褰撳墠閰嶇疆
|
| 675 |
+
- `POST /config/save` - 淇濆瓨閰嶇疆
|
| 676 |
+
|
| 677 |
+
**鏃ュ織绠$悊绔偣**
|
| 678 |
+
- `POST /logs/clear` - 娓呯┖鏃ュ織
|
| 679 |
+
- `GET /logs/download` - 涓嬭浇鏃ュ織鏂囦欢
|
| 680 |
+
- `WebSocket /logs/stream` - 瀹炴椂鏃ュ織娴?
|
| 681 |
+
|
| 682 |
+
**鐗堟湰淇℃伅绔偣**
|
| 683 |
+
- `GET /version/info` - 鑾峰彇鐗堟湰淇℃伅锛堝彲閫?`check_update=true` 鍙傛暟妫€鏌ユ洿鏂帮級
|
| 684 |
+
|
| 685 |
+
### 鑱婂ぉ API 鍔熻兘鐗规€?
|
| 686 |
+
|
| 687 |
+
**澶氭ā鎬佹敮鎸?*
|
| 688 |
+
```json
|
| 689 |
+
{
|
| 690 |
+
"model": "gemini-2.5-pro",
|
| 691 |
+
"messages": [
|
| 692 |
+
{
|
| 693 |
+
"role": "user",
|
| 694 |
+
"content": [
|
| 695 |
+
{"type": "text", "text": "鎻忚堪杩欏紶鍥剧墖"},
|
| 696 |
+
{
|
| 697 |
+
"type": "image_url",
|
| 698 |
+
"image_url": {
|
| 699 |
+
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
|
| 700 |
+
}
|
| 701 |
+
}
|
| 702 |
+
]
|
| 703 |
+
}
|
| 704 |
+
]
|
| 705 |
+
}
|
| 706 |
+
```
|
| 707 |
+
|
| 708 |
+
**鎬濈淮妯″紡鏀寔**
|
| 709 |
+
```json
|
| 710 |
+
{
|
| 711 |
+
"model": "gemini-2.5-pro-maxthinking",
|
| 712 |
+
"messages": [
|
| 713 |
+
{"role": "user", "content": "澶嶆潅鏁板闂"}
|
| 714 |
+
]
|
| 715 |
+
}
|
| 716 |
+
```
|
| 717 |
+
|
| 718 |
+
鍝嶅簲灏嗗寘鍚垎绂荤殑鎬濈淮鍐呭锛?
|
| 719 |
+
```json
|
| 720 |
+
{
|
| 721 |
+
"choices": [{
|
| 722 |
+
"message": {
|
| 723 |
+
"role": "assistant",
|
| 724 |
+
"content": "鏈€缁堢瓟妗?,
|
| 725 |
+
"reasoning_content": "璇︾粏鐨勬€濊€冭繃绋?.."
|
| 726 |
+
}
|
| 727 |
+
}]
|
| 728 |
+
}
|
| 729 |
+
```
|
| 730 |
+
|
| 731 |
+
**娴佸紡鎶楁埅鏂娇鐢?*
|
| 732 |
+
```json
|
| 733 |
+
{
|
| 734 |
+
"model": "娴佸紡鎶楁埅鏂?gemini-2.5-pro",
|
| 735 |
+
"messages": [
|
| 736 |
+
{"role": "user", "content": "鍐欎竴绡囬暱鏂囩珷"}
|
| 737 |
+
],
|
| 738 |
+
"stream": true
|
| 739 |
+
}
|
| 740 |
+
```
|
| 741 |
+
|
| 742 |
+
**鍏煎鎬фā寮?*
|
| 743 |
+
```bash
|
| 744 |
+
# 鍚敤鍏煎鎬фā寮?
|
| 745 |
+
export COMPATIBILITY_MODE=true
|
| 746 |
+
```
|
| 747 |
+
姝ゆā寮忎笅锛屾墍鏈?`system` 娑堟伅浼氳浆鎹负 `user` 娑堟伅锛屾彁楂樹笌鏌愪簺瀹㈡埛绔殑鍏煎鎬с€?
|
| 748 |
+
|
| 749 |
+
---
|
| 750 |
+
|
| 751 |
+
## 馃挰 浜ゆ祦缇?
|
| 752 |
+
|
| 753 |
+
娆㈣繋鍔犲叆 QQ 缇や氦娴佽璁猴紒
|
| 754 |
+
|
| 755 |
+
**QQ 缇ゅ彿锛?083250744**
|
| 756 |
+
|
| 757 |
+
<img src="docs/qq缇?jpg" width="200" alt="QQ缇や簩缁寸爜">
|
| 758 |
+
|
| 759 |
+
---
|
| 760 |
+
|
| 761 |
+
## 璁稿彲璇佷笌鍏嶈矗澹版槑
|
| 762 |
+
|
| 763 |
+
鏈」鐩粎渚涘涔犲拰鐮旂┒鐢ㄩ€斻€備娇鐢ㄦ湰椤圭洰琛ㄧず鎮ㄥ悓鎰忥細
|
| 764 |
+
- 涓嶅皢鏈」鐩敤浜庝换浣曞晢涓氱敤閫?
|
| 765 |
+
- 鎵挎媴浣跨敤鏈」鐩殑鎵€鏈夐闄╁拰璐d换
|
| 766 |
+
- 閬靛畧鐩稿叧鐨勬湇鍔℃潯娆惧拰娉曞緥娉曡
|
| 767 |
+
|
| 768 |
+
椤圭洰浣滆€呭鍥犱娇鐢ㄦ湰椤圭洰鑰屼骇鐢熺殑浠讳綍鐩存帴鎴栭棿鎺ユ崯澶变笉鎵挎媴璐d换銆?
|
config.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration constants for the Geminicli2api proxy server.
|
| 3 |
+
Centralizes all configuration to avoid duplication across modules.
|
| 4 |
+
|
| 5 |
+
- 启动时加载一次配置到内存
|
| 6 |
+
- 修改配置时调用 reload_config() 重新从数据库加载
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
# 全局配置缓存
|
| 13 |
+
_config_cache: dict[str, Any] = {}
|
| 14 |
+
_config_initialized = False
|
| 15 |
+
|
| 16 |
+
# Client Configuration
|
| 17 |
+
|
| 18 |
+
# 需要自动封禁的错误码 (默认值,可通过环境变量或配置覆盖)
|
| 19 |
+
AUTO_BAN_ERROR_CODES = [403]
|
| 20 |
+
|
| 21 |
+
# ====================== 环境变量映射表 ======================
|
| 22 |
+
# 统一维护环境变量名和配置键名的映射关系
|
| 23 |
+
# 格式: "环境变量名": "配置键名"
|
| 24 |
+
ENV_MAPPINGS = {
|
| 25 |
+
"CODE_ASSIST_ENDPOINT": "code_assist_endpoint",
|
| 26 |
+
"CREDENTIALS_DIR": "credentials_dir",
|
| 27 |
+
"PROXY": "proxy",
|
| 28 |
+
"OAUTH_PROXY_URL": "oauth_proxy_url",
|
| 29 |
+
"GOOGLEAPIS_PROXY_URL": "googleapis_proxy_url",
|
| 30 |
+
"RESOURCE_MANAGER_API_URL": "resource_manager_api_url",
|
| 31 |
+
"SERVICE_USAGE_API_URL": "service_usage_api_url",
|
| 32 |
+
"AUTO_BAN": "auto_ban_enabled",
|
| 33 |
+
"AUTO_BAN_ERROR_CODES": "auto_ban_error_codes",
|
| 34 |
+
"RETRY_429_MAX_RETRIES": "retry_429_max_retries",
|
| 35 |
+
"RETRY_429_ENABLED": "retry_429_enabled",
|
| 36 |
+
"RETRY_429_INTERVAL": "retry_429_interval",
|
| 37 |
+
"ANTI_TRUNCATION_MAX_ATTEMPTS": "anti_truncation_max_attempts",
|
| 38 |
+
"COMPATIBILITY_MODE": "compatibility_mode_enabled",
|
| 39 |
+
"RETURN_THOUGHTS_TO_FRONTEND": "return_thoughts_to_frontend",
|
| 40 |
+
"ANTIGRAVITY_STREAM2NOSTREAM": "antigravity_stream2nostream",
|
| 41 |
+
"HOST": "host",
|
| 42 |
+
"PORT": "port",
|
| 43 |
+
"API_PASSWORD": "api_password",
|
| 44 |
+
"PANEL_PASSWORD": "panel_password",
|
| 45 |
+
"PASSWORD": "password",
|
| 46 |
+
"KEEPALIVE_URL": "keepalive_url",
|
| 47 |
+
"KEEPALIVE_INTERVAL": "keepalive_interval",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ====================== 配置系统 ======================
|
| 52 |
+
|
| 53 |
+
async def init_config():
|
| 54 |
+
"""初始化配置缓存(启动时调用一次)"""
|
| 55 |
+
global _config_cache, _config_initialized
|
| 56 |
+
|
| 57 |
+
if _config_initialized:
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
from src.storage_adapter import get_storage_adapter
|
| 62 |
+
storage_adapter = await get_storage_adapter()
|
| 63 |
+
_config_cache = await storage_adapter.get_all_config()
|
| 64 |
+
_config_initialized = True
|
| 65 |
+
except Exception:
|
| 66 |
+
# 初始化失败时使用空缓存
|
| 67 |
+
_config_cache = {}
|
| 68 |
+
_config_initialized = True
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def reload_config():
|
| 72 |
+
"""重新加载配置(修改配置后调用)"""
|
| 73 |
+
global _config_cache, _config_initialized
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
from src.storage_adapter import get_storage_adapter
|
| 77 |
+
storage_adapter = await get_storage_adapter()
|
| 78 |
+
|
| 79 |
+
# 如果后端支持 reload_config_cache,调用它
|
| 80 |
+
if hasattr(storage_adapter._backend, 'reload_config_cache'):
|
| 81 |
+
await storage_adapter._backend.reload_config_cache()
|
| 82 |
+
|
| 83 |
+
# 重新加载配置缓存
|
| 84 |
+
_config_cache = await storage_adapter.get_all_config()
|
| 85 |
+
_config_initialized = True
|
| 86 |
+
except Exception:
|
| 87 |
+
pass
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _get_cached_config(key: str, default: Any = None) -> Any:
|
| 91 |
+
"""从内存缓存获取配置(同步)"""
|
| 92 |
+
return _config_cache.get(key, default)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def get_config_value(key: str, default: Any = None, env_var: Optional[str] = None) -> Any:
|
| 96 |
+
"""Get configuration value with priority: ENV > Storage > default."""
|
| 97 |
+
# 确保配置已初始化
|
| 98 |
+
if not _config_initialized:
|
| 99 |
+
await init_config()
|
| 100 |
+
|
| 101 |
+
# Priority 1: Environment variable
|
| 102 |
+
if env_var and os.getenv(env_var):
|
| 103 |
+
return os.getenv(env_var)
|
| 104 |
+
|
| 105 |
+
# Priority 2: Memory cache
|
| 106 |
+
value = _get_cached_config(key)
|
| 107 |
+
if value is not None:
|
| 108 |
+
return value
|
| 109 |
+
|
| 110 |
+
return default
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# Configuration getters - all async
|
| 114 |
+
async def get_proxy_config():
|
| 115 |
+
"""Get proxy configuration."""
|
| 116 |
+
proxy_url = await get_config_value("proxy", env_var="PROXY")
|
| 117 |
+
return proxy_url if proxy_url else None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
async def get_auto_ban_enabled() -> bool:
|
| 121 |
+
"""Get auto ban enabled setting."""
|
| 122 |
+
env_value = os.getenv("AUTO_BAN")
|
| 123 |
+
if env_value:
|
| 124 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 125 |
+
|
| 126 |
+
return bool(await get_config_value("auto_ban_enabled", False))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
async def get_auto_ban_error_codes() -> list:
|
| 130 |
+
"""
|
| 131 |
+
Get auto ban error codes.
|
| 132 |
+
|
| 133 |
+
Environment variable: AUTO_BAN_ERROR_CODES (comma-separated, e.g., "400,403")
|
| 134 |
+
Database config key: auto_ban_error_codes
|
| 135 |
+
Default: [400, 403]
|
| 136 |
+
"""
|
| 137 |
+
env_value = os.getenv("AUTO_BAN_ERROR_CODES")
|
| 138 |
+
if env_value:
|
| 139 |
+
try:
|
| 140 |
+
return [int(code.strip()) for code in env_value.split(",") if code.strip()]
|
| 141 |
+
except ValueError:
|
| 142 |
+
pass
|
| 143 |
+
|
| 144 |
+
codes = await get_config_value("auto_ban_error_codes")
|
| 145 |
+
if codes and isinstance(codes, list):
|
| 146 |
+
return codes
|
| 147 |
+
return AUTO_BAN_ERROR_CODES
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def get_retry_429_max_retries() -> int:
|
| 151 |
+
"""Get max retries for 429 errors."""
|
| 152 |
+
env_value = os.getenv("RETRY_429_MAX_RETRIES")
|
| 153 |
+
if env_value:
|
| 154 |
+
try:
|
| 155 |
+
return int(env_value)
|
| 156 |
+
except ValueError:
|
| 157 |
+
pass
|
| 158 |
+
|
| 159 |
+
return int(await get_config_value("retry_429_max_retries", 5))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
async def get_retry_429_enabled() -> bool:
|
| 163 |
+
"""Get 429 retry enabled setting."""
|
| 164 |
+
env_value = os.getenv("RETRY_429_ENABLED")
|
| 165 |
+
if env_value:
|
| 166 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 167 |
+
|
| 168 |
+
return bool(await get_config_value("retry_429_enabled", True))
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def get_retry_429_interval() -> float:
|
| 172 |
+
"""Get 429 retry interval in seconds."""
|
| 173 |
+
env_value = os.getenv("RETRY_429_INTERVAL")
|
| 174 |
+
if env_value:
|
| 175 |
+
try:
|
| 176 |
+
return float(env_value)
|
| 177 |
+
except ValueError:
|
| 178 |
+
pass
|
| 179 |
+
|
| 180 |
+
return float(await get_config_value("retry_429_interval", 1))
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
async def get_anti_truncation_max_attempts() -> int:
|
| 184 |
+
"""
|
| 185 |
+
Get maximum attempts for anti-truncation continuation.
|
| 186 |
+
|
| 187 |
+
Environment variable: ANTI_TRUNCATION_MAX_ATTEMPTS
|
| 188 |
+
Database config key: anti_truncation_max_attempts
|
| 189 |
+
Default: 3
|
| 190 |
+
"""
|
| 191 |
+
env_value = os.getenv("ANTI_TRUNCATION_MAX_ATTEMPTS")
|
| 192 |
+
if env_value:
|
| 193 |
+
try:
|
| 194 |
+
return int(env_value)
|
| 195 |
+
except ValueError:
|
| 196 |
+
pass
|
| 197 |
+
|
| 198 |
+
return int(await get_config_value("anti_truncation_max_attempts", 3))
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# Server Configuration
|
| 202 |
+
async def get_server_host() -> str:
|
| 203 |
+
"""
|
| 204 |
+
Get server host setting.
|
| 205 |
+
|
| 206 |
+
Environment variable: HOST
|
| 207 |
+
Database config key: host
|
| 208 |
+
Default: 0.0.0.0
|
| 209 |
+
"""
|
| 210 |
+
return str(await get_config_value("host", "0.0.0.0", "HOST"))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
async def get_server_port() -> int:
|
| 214 |
+
"""
|
| 215 |
+
Get server port setting.
|
| 216 |
+
|
| 217 |
+
Environment variable: PORT
|
| 218 |
+
Database config key: port
|
| 219 |
+
Default: 7861
|
| 220 |
+
"""
|
| 221 |
+
env_value = os.getenv("PORT")
|
| 222 |
+
if env_value:
|
| 223 |
+
try:
|
| 224 |
+
return int(env_value)
|
| 225 |
+
except ValueError:
|
| 226 |
+
pass
|
| 227 |
+
|
| 228 |
+
return int(await get_config_value("port", 7861))
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
async def get_api_password() -> str:
|
| 232 |
+
"""
|
| 233 |
+
Get API password setting for chat endpoints.
|
| 234 |
+
|
| 235 |
+
Environment variable: API_PASSWORD
|
| 236 |
+
Database config key: api_password
|
| 237 |
+
Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
|
| 238 |
+
"""
|
| 239 |
+
# 优先使用 API_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
|
| 240 |
+
api_password = await get_config_value("api_password", None, "API_PASSWORD")
|
| 241 |
+
if api_password is not None:
|
| 242 |
+
return str(api_password)
|
| 243 |
+
|
| 244 |
+
# 兼容性:使用通用密码
|
| 245 |
+
return str(await get_config_value("password", "pwd", "PASSWORD"))
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
async def get_panel_password() -> str:
|
| 249 |
+
"""
|
| 250 |
+
Get panel password setting for web interface.
|
| 251 |
+
|
| 252 |
+
Environment variable: PANEL_PASSWORD
|
| 253 |
+
Database config key: panel_password
|
| 254 |
+
Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
|
| 255 |
+
"""
|
| 256 |
+
# 优先使用 PANEL_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
|
| 257 |
+
panel_password = await get_config_value("panel_password", None, "PANEL_PASSWORD")
|
| 258 |
+
if panel_password is not None:
|
| 259 |
+
return str(panel_password)
|
| 260 |
+
|
| 261 |
+
# 兼容性:使用通用密码
|
| 262 |
+
return str(await get_config_value("password", "pwd", "PASSWORD"))
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
async def get_server_password() -> str:
|
| 266 |
+
"""
|
| 267 |
+
Get server password setting (deprecated, use get_api_password or get_panel_password).
|
| 268 |
+
|
| 269 |
+
Environment variable: PASSWORD
|
| 270 |
+
Database config key: password
|
| 271 |
+
Default: pwd
|
| 272 |
+
"""
|
| 273 |
+
return str(await get_config_value("password", "pwd", "PASSWORD"))
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
async def get_credentials_dir() -> str:
|
| 277 |
+
"""
|
| 278 |
+
Get credentials directory setting.
|
| 279 |
+
|
| 280 |
+
Environment variable: CREDENTIALS_DIR
|
| 281 |
+
Database config key: credentials_dir
|
| 282 |
+
Default: ./creds
|
| 283 |
+
"""
|
| 284 |
+
return str(await get_config_value("credentials_dir", "./creds", "CREDENTIALS_DIR"))
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
async def get_code_assist_endpoint() -> str:
|
| 288 |
+
"""
|
| 289 |
+
Get Code Assist endpoint setting.
|
| 290 |
+
|
| 291 |
+
Environment variable: CODE_ASSIST_ENDPOINT
|
| 292 |
+
Database config key: code_assist_endpoint
|
| 293 |
+
Default: https://cloudcode-pa.googleapis.com
|
| 294 |
+
"""
|
| 295 |
+
return str(
|
| 296 |
+
await get_config_value(
|
| 297 |
+
"code_assist_endpoint", "https://cloudcode-pa.googleapis.com", "CODE_ASSIST_ENDPOINT"
|
| 298 |
+
)
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
async def get_compatibility_mode_enabled() -> bool:
|
| 303 |
+
"""
|
| 304 |
+
Get compatibility mode setting.
|
| 305 |
+
|
| 306 |
+
兼容性模式:启用后所有system消息全部转换成user,停用system_instructions。
|
| 307 |
+
该选项可能会降低模型理解能力,但是能避免流式空回的情况。
|
| 308 |
+
|
| 309 |
+
Environment variable: COMPATIBILITY_MODE
|
| 310 |
+
Database config key: compatibility_mode_enabled
|
| 311 |
+
Default: False
|
| 312 |
+
"""
|
| 313 |
+
env_value = os.getenv("COMPATIBILITY_MODE")
|
| 314 |
+
if env_value:
|
| 315 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 316 |
+
|
| 317 |
+
return bool(await get_config_value("compatibility_mode_enabled", False))
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
async def get_return_thoughts_to_frontend() -> bool:
|
| 321 |
+
"""
|
| 322 |
+
Get return thoughts to frontend setting.
|
| 323 |
+
|
| 324 |
+
控制是否将思维链返回到前端。
|
| 325 |
+
启用后,思维链会在响应中返回;禁用后,思维链会在响应中被过滤掉。
|
| 326 |
+
|
| 327 |
+
Environment variable: RETURN_THOUGHTS_TO_FRONTEND
|
| 328 |
+
Database config key: return_thoughts_to_frontend
|
| 329 |
+
Default: True
|
| 330 |
+
"""
|
| 331 |
+
env_value = os.getenv("RETURN_THOUGHTS_TO_FRONTEND")
|
| 332 |
+
if env_value:
|
| 333 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 334 |
+
|
| 335 |
+
return bool(await get_config_value("return_thoughts_to_frontend", True))
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
async def get_antigravity_stream2nostream() -> bool:
|
| 339 |
+
"""
|
| 340 |
+
Get use stream for non-stream setting.
|
| 341 |
+
|
| 342 |
+
控制antigravity非流式请求是否使用流式API并收集为完整响应。
|
| 343 |
+
启用后,非流式请求将在后端使用流式API,然后收集所有块后再返回完整响应。
|
| 344 |
+
|
| 345 |
+
Environment variable: ANTIGRAVITY_STREAM2NOSTREAM
|
| 346 |
+
Database config key: antigravity_stream2nostream
|
| 347 |
+
Default: True
|
| 348 |
+
"""
|
| 349 |
+
env_value = os.getenv("ANTIGRAVITY_STREAM2NOSTREAM")
|
| 350 |
+
if env_value:
|
| 351 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 352 |
+
|
| 353 |
+
return bool(await get_config_value("antigravity_stream2nostream", True))
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
async def get_oauth_proxy_url() -> str:
|
| 357 |
+
"""
|
| 358 |
+
Get OAuth proxy URL setting.
|
| 359 |
+
|
| 360 |
+
用于Google OAuth2认证的代理URL。
|
| 361 |
+
|
| 362 |
+
Environment variable: OAUTH_PROXY_URL
|
| 363 |
+
Database config key: oauth_proxy_url
|
| 364 |
+
Default: https://oauth2.googleapis.com
|
| 365 |
+
"""
|
| 366 |
+
return str(
|
| 367 |
+
await get_config_value(
|
| 368 |
+
"oauth_proxy_url", "https://oauth2.googleapis.com", "OAUTH_PROXY_URL"
|
| 369 |
+
)
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
async def get_googleapis_proxy_url() -> str:
|
| 374 |
+
"""
|
| 375 |
+
Get Google APIs proxy URL setting.
|
| 376 |
+
|
| 377 |
+
用于Google APIs调用的代理URL。
|
| 378 |
+
|
| 379 |
+
Environment variable: GOOGLEAPIS_PROXY_URL
|
| 380 |
+
Database config key: googleapis_proxy_url
|
| 381 |
+
Default: https://www.googleapis.com
|
| 382 |
+
"""
|
| 383 |
+
return str(
|
| 384 |
+
await get_config_value(
|
| 385 |
+
"googleapis_proxy_url", "https://www.googleapis.com", "GOOGLEAPIS_PROXY_URL"
|
| 386 |
+
)
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
async def get_resource_manager_api_url() -> str:
|
| 391 |
+
"""
|
| 392 |
+
Get Google Cloud Resource Manager API URL setting.
|
| 393 |
+
|
| 394 |
+
用于Google Cloud Resource Manager API的URL。
|
| 395 |
+
|
| 396 |
+
Environment variable: RESOURCE_MANAGER_API_URL
|
| 397 |
+
Database config key: resource_manager_api_url
|
| 398 |
+
Default: https://cloudresourcemanager.googleapis.com
|
| 399 |
+
"""
|
| 400 |
+
return str(
|
| 401 |
+
await get_config_value(
|
| 402 |
+
"resource_manager_api_url",
|
| 403 |
+
"https://cloudresourcemanager.googleapis.com",
|
| 404 |
+
"RESOURCE_MANAGER_API_URL",
|
| 405 |
+
)
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
async def get_service_usage_api_url() -> str:
|
| 410 |
+
"""
|
| 411 |
+
Get Google Cloud Service Usage API URL setting.
|
| 412 |
+
|
| 413 |
+
用于Google Cloud Service Usage API的URL。
|
| 414 |
+
|
| 415 |
+
Environment variable: SERVICE_USAGE_API_URL
|
| 416 |
+
Database config key: service_usage_api_url
|
| 417 |
+
Default: https://serviceusage.googleapis.com
|
| 418 |
+
"""
|
| 419 |
+
return str(
|
| 420 |
+
await get_config_value(
|
| 421 |
+
"service_usage_api_url", "https://serviceusage.googleapis.com", "SERVICE_USAGE_API_URL"
|
| 422 |
+
)
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
async def get_keepalive_url() -> str:
|
| 427 |
+
"""
|
| 428 |
+
Get keep-alive URL setting.
|
| 429 |
+
|
| 430 |
+
配置后保活服务会定期向该URL发送GET请求。
|
| 431 |
+
留空表示禁用保活服务。
|
| 432 |
+
|
| 433 |
+
Environment variable: KEEPALIVE_URL
|
| 434 |
+
Database config key: keepalive_url
|
| 435 |
+
Default: "" (disabled)
|
| 436 |
+
"""
|
| 437 |
+
return str(await get_config_value("keepalive_url", "", "KEEPALIVE_URL"))
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
async def get_keepalive_interval() -> int:
|
| 441 |
+
"""
|
| 442 |
+
Get keep-alive interval in seconds.
|
| 443 |
+
|
| 444 |
+
保活请求发送间隔(秒)。
|
| 445 |
+
|
| 446 |
+
Environment variable: KEEPALIVE_INTERVAL
|
| 447 |
+
Database config key: keepalive_interval
|
| 448 |
+
Default: 60
|
| 449 |
+
"""
|
| 450 |
+
env_value = os.getenv("KEEPALIVE_INTERVAL")
|
| 451 |
+
if env_value:
|
| 452 |
+
try:
|
| 453 |
+
return int(env_value)
|
| 454 |
+
except ValueError:
|
| 455 |
+
pass
|
| 456 |
+
|
| 457 |
+
return int(await get_config_value("keepalive_interval", 60))
|
darwin-install.sh
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# macOS 安装脚本 (支持 Intel 和 Apple Silicon)
|
| 3 |
+
|
| 4 |
+
# 确保 Homebrew 已安装
|
| 5 |
+
if ! command -v brew &> /dev/null; then
|
| 6 |
+
echo "未检测到 Homebrew,开始安装..."
|
| 7 |
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
| 8 |
+
|
| 9 |
+
# 检测 Homebrew 安装路径并设置环境变量
|
| 10 |
+
if [[ -f "/opt/homebrew/bin/brew" ]]; then
|
| 11 |
+
# Apple Silicon Mac
|
| 12 |
+
eval "$(/opt/homebrew/bin/brew shellenv)"
|
| 13 |
+
elif [[ -f "/usr/local/bin/brew" ]]; then
|
| 14 |
+
# Intel Mac
|
| 15 |
+
eval "$(/usr/local/bin/brew shellenv)"
|
| 16 |
+
fi
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
# 更新 brew 并安装 git
|
| 20 |
+
brew update
|
| 21 |
+
brew install git
|
| 22 |
+
|
| 23 |
+
# 安装 uv (Python 环境管理工具)
|
| 24 |
+
curl -Ls https://astral.sh/uv/install.sh | sh
|
| 25 |
+
|
| 26 |
+
# 确保 uv 在 PATH 中
|
| 27 |
+
export PATH="$HOME/.local/bin:$PATH"
|
| 28 |
+
|
| 29 |
+
# 克隆或进入项目目录
|
| 30 |
+
if [ -f "./web.py" ]; then
|
| 31 |
+
# 已经在目标目录
|
| 32 |
+
:
|
| 33 |
+
elif [ -f "./gcli2api/web.py" ]; then
|
| 34 |
+
cd ./gcli2api
|
| 35 |
+
else
|
| 36 |
+
git clone https://github.com/su-kaka/gcli2api.git
|
| 37 |
+
cd ./gcli2api
|
| 38 |
+
fi
|
| 39 |
+
|
| 40 |
+
# 拉取最新代码
|
| 41 |
+
git pull
|
| 42 |
+
|
| 43 |
+
# 创建并同步虚拟环境
|
| 44 |
+
uv sync
|
| 45 |
+
|
| 46 |
+
# 激活虚拟环境
|
| 47 |
+
if [ -f ".venv/bin/activate" ]; then
|
| 48 |
+
source .venv/bin/activate
|
| 49 |
+
else
|
| 50 |
+
echo "❌ 未找到虚拟环境,请检查 uv 是否安装成功"
|
| 51 |
+
exit 1
|
| 52 |
+
fi
|
| 53 |
+
|
| 54 |
+
# 启动项目
|
| 55 |
+
python3 web.py
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
gcli2api:
|
| 5 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 6 |
+
container_name: gcli2api
|
| 7 |
+
restart: unless-stopped
|
| 8 |
+
network_mode: host
|
| 9 |
+
environment:
|
| 10 |
+
# Password configuration (choose one)
|
| 11 |
+
# Option 1: Use common password
|
| 12 |
+
- PASSWORD=${PASSWORD:-pwd}
|
| 13 |
+
# Option 2: Use separate passwords (uncomment if needed)
|
| 14 |
+
# - API_PASSWORD=${API_PASSWORD:-your_api_password}
|
| 15 |
+
# - PANEL_PASSWORD=${PANEL_PASSWORD:-your_panel_password}
|
| 16 |
+
|
| 17 |
+
# Server configuration
|
| 18 |
+
- PORT=${PORT:-7861}
|
| 19 |
+
- HOST=${HOST:-0.0.0.0}
|
| 20 |
+
|
| 21 |
+
# Optional: Google credentials from environment
|
| 22 |
+
# - GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS}
|
| 23 |
+
|
| 24 |
+
# Optional: Logging configuration
|
| 25 |
+
# - LOG_LEVEL=${LOG_LEVEL:-info}
|
| 26 |
+
|
| 27 |
+
# Optional: Redis configuration (for distributed storage)
|
| 28 |
+
# - REDIS_URI=${REDIS_URI}
|
| 29 |
+
# - REDIS_DATABASE=${REDIS_DATABASE:-0}
|
| 30 |
+
|
| 31 |
+
# Optional: MongoDB configuration (for distributed storage)
|
| 32 |
+
# - MONGODB_URI=${MONGODB_URI}
|
| 33 |
+
# - MONGODB_DATABASE=${MONGODB_DATABASE:-gcli2api}
|
| 34 |
+
|
| 35 |
+
# Optional: PostgreSQL configuration (for distributed storage)
|
| 36 |
+
# - POSTGRES_DSN=${POSTGRES_DSN}
|
| 37 |
+
|
| 38 |
+
# Optional: Proxy configuration
|
| 39 |
+
# - PROXY=${PROXY}
|
| 40 |
+
volumes:
|
| 41 |
+
- ./data/creds:/app/creds
|
| 42 |
+
|
| 43 |
+
# Example with Redis for distributed storage
|
| 44 |
+
# redis:
|
| 45 |
+
# image: redis:7-alpine
|
| 46 |
+
# container_name: gcli2api-redis
|
| 47 |
+
# restart: unless-stopped
|
| 48 |
+
# ports:
|
| 49 |
+
# - "6379:6379"
|
| 50 |
+
# volumes:
|
| 51 |
+
# - redis_data:/data
|
| 52 |
+
# command: redis-server --appendonly yes
|
| 53 |
+
# healthcheck:
|
| 54 |
+
# test: ["CMD", "redis-cli", "ping"]
|
| 55 |
+
# interval: 10s
|
| 56 |
+
# timeout: 3s
|
| 57 |
+
# retries: 3
|
| 58 |
+
|
| 59 |
+
# Example with MongoDB for distributed storage
|
| 60 |
+
# mongodb:
|
| 61 |
+
# image: mongo:7
|
| 62 |
+
# container_name: gcli2api-mongodb
|
| 63 |
+
# restart: unless-stopped
|
| 64 |
+
# environment:
|
| 65 |
+
# MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
|
| 66 |
+
# MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password}
|
| 67 |
+
# ports:
|
| 68 |
+
# - "27017:27017"
|
| 69 |
+
# volumes:
|
| 70 |
+
# - mongodb_data:/data/db
|
| 71 |
+
# healthcheck:
|
| 72 |
+
# test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
| 73 |
+
# interval: 10s
|
| 74 |
+
# timeout: 5s
|
| 75 |
+
# retries: 3
|
| 76 |
+
|
| 77 |
+
#volumes:
|
| 78 |
+
# redis_data:
|
| 79 |
+
# mongodb_data:
|
docs/README_EN.md
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GeminiCLI to API
|
| 2 |
+
|
| 3 |
+
**Convert GeminiCLI and Antigravity to OpenAI, GEMINI, and Claude API Compatible Interfaces**
|
| 4 |
+
|
| 5 |
+
[](https://www.python.org/downloads/)
|
| 6 |
+
[](../LICENSE)
|
| 7 |
+
[](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
|
| 8 |
+
|
| 9 |
+
[中文](../README.md) | English | [日本語](./README_JA.md)
|
| 10 |
+
|
| 11 |
+
## 🚀 Quick Deploy
|
| 12 |
+
|
| 13 |
+
[](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
|
| 14 |
+
[](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## ⚠️ License Declaration
|
| 18 |
+
|
| 19 |
+
**This project is licensed under the Cooperative Non-Commercial License (CNC-1.0)**
|
| 20 |
+
|
| 21 |
+
This is a strict anti-commercial open source license. Please refer to the [LICENSE](../LICENSE) file for details.
|
| 22 |
+
|
| 23 |
+
### ✅ Permitted Uses:
|
| 24 |
+
- Personal learning, research, and educational purposes
|
| 25 |
+
- Non-profit organization use
|
| 26 |
+
- Open source project integration (must comply with the same license)
|
| 27 |
+
- Academic research and publication
|
| 28 |
+
|
| 29 |
+
### ❌ Prohibited Uses:
|
| 30 |
+
- Any form of commercial use
|
| 31 |
+
- Enterprise use with annual revenue exceeding $1 million
|
| 32 |
+
- Venture capital-backed or publicly traded companies
|
| 33 |
+
- Providing paid services or products
|
| 34 |
+
- Commercial competitive use
|
| 35 |
+
|
| 36 |
+
## Core Features
|
| 37 |
+
|
| 38 |
+
### 🔄 API Endpoints and Format Support
|
| 39 |
+
|
| 40 |
+
**Multi-endpoint Multi-format Support**
|
| 41 |
+
- **OpenAI Compatible Endpoints**: `/v1/chat/completions` and `/v1/models`
|
| 42 |
+
- Supports standard OpenAI format (messages structure)
|
| 43 |
+
- Supports Gemini native format (contents structure)
|
| 44 |
+
- Automatic format detection and conversion, no manual switching required
|
| 45 |
+
- Supports multimodal input (text + images)
|
| 46 |
+
- **Gemini Native Endpoints**: `/v1/models/{model}:generateContent` and `streamGenerateContent`
|
| 47 |
+
- Supports complete Gemini native API specifications
|
| 48 |
+
- Multiple authentication methods: Bearer Token, x-goog-api-key header, URL parameter key
|
| 49 |
+
- **Claude Format Compatibility**: Full support for Claude API format
|
| 50 |
+
- Endpoint: `/v1/messages` (follows Claude API specification)
|
| 51 |
+
- Supports Claude standard messages format
|
| 52 |
+
- Supports system parameter and Claude-specific features
|
| 53 |
+
- Automatically converts to backend-supported format
|
| 54 |
+
- **Antigravity API Support**: Supports OpenAI, Gemini, and Claude formats
|
| 55 |
+
- OpenAI format endpoint: `/antigravity/v1/chat/completions`
|
| 56 |
+
- Gemini format endpoint: `/antigravity/v1/models/{model}:generateContent` and `streamGenerateContent`
|
| 57 |
+
- Claude format endpoint: `/antigravity/v1/messages`
|
| 58 |
+
- Supports all Antigravity models (Claude, Gemini, etc.)
|
| 59 |
+
- Automatic model name mapping and thinking mode detection
|
| 60 |
+
|
| 61 |
+
### 🔐 Authentication and Security Management
|
| 62 |
+
|
| 63 |
+
**Flexible Password Management**
|
| 64 |
+
- **Separate Password Support**: API password (chat endpoints) and control panel password can be set independently
|
| 65 |
+
- **Multiple Authentication Methods**: Supports Authorization Bearer, x-goog-api-key header, URL parameters, etc.
|
| 66 |
+
- **JWT Token Authentication**: Control panel supports JWT token authentication
|
| 67 |
+
- **User Email Retrieval**: Automatically retrieves and displays Google account email addresses
|
| 68 |
+
|
| 69 |
+
### 📊 Intelligent Credential Management System
|
| 70 |
+
|
| 71 |
+
**Advanced Credential Management**
|
| 72 |
+
- Multiple Google OAuth credential automatic rotation
|
| 73 |
+
- Enhanced stability through redundant authentication
|
| 74 |
+
- Load balancing and concurrent request support
|
| 75 |
+
- Automatic failure detection and credential disabling
|
| 76 |
+
- Credential usage statistics and quota management
|
| 77 |
+
- Support for manual enable/disable credential files
|
| 78 |
+
- Batch credential file operations (enable, disable, delete)
|
| 79 |
+
|
| 80 |
+
**Credential Status Monitoring**
|
| 81 |
+
- Real-time credential health checks
|
| 82 |
+
- Error code tracking (429, 403, 500, etc.)
|
| 83 |
+
- Automatic banning mechanism (configurable)
|
| 84 |
+
|
| 85 |
+
### 🌊 Streaming and Response Processing
|
| 86 |
+
|
| 87 |
+
**Multiple Streaming Support**
|
| 88 |
+
- True real-time streaming responses
|
| 89 |
+
- Fake streaming mode (for compatibility)
|
| 90 |
+
- Streaming anti-truncation feature (prevents answer truncation)
|
| 91 |
+
- Asynchronous task management and timeout handling
|
| 92 |
+
|
| 93 |
+
**Response Optimization**
|
| 94 |
+
- Thinking chain content separation
|
| 95 |
+
- Reasoning process (reasoning_content) handling
|
| 96 |
+
- Multi-turn conversation context management
|
| 97 |
+
- Compatibility mode (converts system messages to user messages)
|
| 98 |
+
|
| 99 |
+
### 🎛️ Web Management Console
|
| 100 |
+
|
| 101 |
+
**Full-featured Web Interface**
|
| 102 |
+
- OAuth authentication flow management (supports GCLI and Antigravity dual modes)
|
| 103 |
+
- Credential file upload, download, and management
|
| 104 |
+
- Real-time log viewing (WebSocket)
|
| 105 |
+
- System configuration management
|
| 106 |
+
- Usage statistics and monitoring dashboard
|
| 107 |
+
- Mobile-friendly interface
|
| 108 |
+
|
| 109 |
+
**Batch Operation Support**
|
| 110 |
+
- ZIP file batch credential upload (GCLI and Antigravity)
|
| 111 |
+
- Batch enable/disable/delete credentials
|
| 112 |
+
- Batch user email retrieval
|
| 113 |
+
- Batch configuration management
|
| 114 |
+
- Unified batch upload interface for all credential types
|
| 115 |
+
|
| 116 |
+
### 📈 Usage Monitoring
|
| 117 |
+
|
| 118 |
+
**Real-time Monitoring**
|
| 119 |
+
- WebSocket real-time log streams
|
| 120 |
+
- System status monitoring
|
| 121 |
+
- Credential health status
|
| 122 |
+
|
| 123 |
+
### 🔧 Advanced Configuration and Customization
|
| 124 |
+
|
| 125 |
+
**Network and Proxy Configuration**
|
| 126 |
+
- HTTP/HTTPS proxy support
|
| 127 |
+
- Proxy endpoint configuration (OAuth, Google APIs, metadata service)
|
| 128 |
+
- Timeout and retry configuration
|
| 129 |
+
- Network error handling and recovery
|
| 130 |
+
|
| 131 |
+
**Performance and Stability Configuration**
|
| 132 |
+
- 429 error automatic retry (configurable interval and attempts)
|
| 133 |
+
- Anti-truncation maximum retry attempts
|
| 134 |
+
|
| 135 |
+
**Logging and Debugging**
|
| 136 |
+
- Multi-level logging system (DEBUG, INFO, WARNING, ERROR)
|
| 137 |
+
- Log file management
|
| 138 |
+
- Real-time log streams
|
| 139 |
+
- Log download and clearing
|
| 140 |
+
|
| 141 |
+
### 🔄 Environment Variables and Configuration Management
|
| 142 |
+
|
| 143 |
+
**Flexible Configuration Methods**
|
| 144 |
+
- Environment variable configuration
|
| 145 |
+
- Hot configuration updates (partial configuration items)
|
| 146 |
+
- Configuration locking (environment variable priority)
|
| 147 |
+
|
| 148 |
+
## Supported Models
|
| 149 |
+
|
| 150 |
+
All models have 1M context window capacity. Each credential file provides 1000 request quota.
|
| 151 |
+
|
| 152 |
+
### 🤖 Base Models
|
| 153 |
+
- `gemini-2.5-pro`
|
| 154 |
+
- `gemini-3-pro-preview`
|
| 155 |
+
- `gemini-3.1-pro-preview`
|
| 156 |
+
|
| 157 |
+
### 🧠 Thinking Models
|
| 158 |
+
- `gemini-2.5-pro-high`: Thinking mode
|
| 159 |
+
- `gemini-2.5-pro-low`: Low thinking mode
|
| 160 |
+
- Supports custom thinking budget configuration
|
| 161 |
+
- Automatic separation of thinking content and final answers
|
| 162 |
+
|
| 163 |
+
### 🔍 Search-Enhanced Models
|
| 164 |
+
- `gemini-2.5-pro-search`: Model with integrated search functionality
|
| 165 |
+
|
| 166 |
+
### 🖼️ Image Generation Models (Antigravity)
|
| 167 |
+
- `gemini-3.1-flash-image`: Base image generation model
|
| 168 |
+
- **Resolution Suffixes**:
|
| 169 |
+
- `-2k`: 2K resolution
|
| 170 |
+
- `-4k`: 4K HD resolution
|
| 171 |
+
- **Aspect Ratio Suffixes**:
|
| 172 |
+
- `-1x1`: Square (avatar)
|
| 173 |
+
- `-16x9`: Landscape (desktop wallpaper)
|
| 174 |
+
- `-9x16`: Portrait (mobile wallpaper)
|
| 175 |
+
- `-21x9`: Ultra-wide (ultrawide monitor)
|
| 176 |
+
- `-4x3`: Traditional display
|
| 177 |
+
- `-3x4`: Portrait poster
|
| 178 |
+
- **Combination Examples**:
|
| 179 |
+
- `gemini-3.1-flash-image-4k-16x9`: 4K landscape
|
| 180 |
+
- `gemini-3.1-flash-image-2k-9x16`: 2K portrait
|
| 181 |
+
- When no ratio is specified, the API automatically decides the aspect ratio
|
| 182 |
+
|
| 183 |
+
### 🌊 Special Feature Variants
|
| 184 |
+
- **Fake Streaming Mode**: Add `-假流式` suffix to any model name
|
| 185 |
+
- Example: `gemini-2.5-pro-假流式`
|
| 186 |
+
- For scenarios requiring streaming responses but server doesn't support true streaming
|
| 187 |
+
- **Streaming Anti-truncation Mode**: Add `流式抗截断/` prefix to model name
|
| 188 |
+
- Example: `流式抗截断/gemini-2.5-pro`
|
| 189 |
+
- Automatically detects response truncation and retries to ensure complete answers
|
| 190 |
+
|
| 191 |
+
### 🔧 Automatic Model Feature Detection
|
| 192 |
+
- System automatically recognizes feature identifiers in model names
|
| 193 |
+
- Transparently handles feature mode transitions
|
| 194 |
+
- Supports feature combination usage
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## Installation Guide
|
| 200 |
+
|
| 201 |
+
### Termux Environment
|
| 202 |
+
|
| 203 |
+
**Initial Installation**
|
| 204 |
+
```bash
|
| 205 |
+
curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
**Restart Service**
|
| 209 |
+
```bash
|
| 210 |
+
cd gcli2api
|
| 211 |
+
bash termux-start.sh
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
### Windows Environment
|
| 215 |
+
|
| 216 |
+
**Initial Installation**
|
| 217 |
+
```powershell
|
| 218 |
+
iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
**Restart Service**
|
| 222 |
+
Double-click to execute `start.bat`
|
| 223 |
+
|
| 224 |
+
### Linux Environment
|
| 225 |
+
|
| 226 |
+
**Initial Installation**
|
| 227 |
+
```bash
|
| 228 |
+
curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
**Restart Service**
|
| 232 |
+
```bash
|
| 233 |
+
cd gcli2api
|
| 234 |
+
bash start.sh
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
### macOS Environment
|
| 238 |
+
|
| 239 |
+
**Initial Installation**
|
| 240 |
+
```bash
|
| 241 |
+
curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
**Restart Service**
|
| 245 |
+
```bash
|
| 246 |
+
cd gcli2api
|
| 247 |
+
bash start.sh
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
### Docker Environment
|
| 251 |
+
|
| 252 |
+
**Docker Run Command**
|
| 253 |
+
```bash
|
| 254 |
+
# Using universal password
|
| 255 |
+
docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
|
| 256 |
+
|
| 257 |
+
# Using separate passwords
|
| 258 |
+
docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
**Docker Mac**
|
| 262 |
+
```bash
|
| 263 |
+
# Using universal password
|
| 264 |
+
docker run -d \
|
| 265 |
+
--name gcli2api \
|
| 266 |
+
-p 7861:7861 \
|
| 267 |
+
-p 8080:8080 \
|
| 268 |
+
-e PASSWORD=pwd \
|
| 269 |
+
-e PORT=7861 \
|
| 270 |
+
-v "$(pwd)/data/creds":/app/creds \
|
| 271 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
```bash
|
| 275 |
+
# Using separate passwords
|
| 276 |
+
docker run -d \
|
| 277 |
+
--name gcli2api \
|
| 278 |
+
-p 7861:7861 \
|
| 279 |
+
-p 8080:8080 \
|
| 280 |
+
-e API_PASSWORD=api_pwd \
|
| 281 |
+
-e PANEL_PASSWORD=panel_pwd \
|
| 282 |
+
-e PORT=7861 \
|
| 283 |
+
-v $(pwd)/data/creds:/app/creds \
|
| 284 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
**Docker Compose Run Command**
|
| 288 |
+
1. Save the following content as `docker-compose.yml` file:
|
| 289 |
+
```yaml
|
| 290 |
+
version: '3.8'
|
| 291 |
+
|
| 292 |
+
services:
|
| 293 |
+
gcli2api:
|
| 294 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 295 |
+
container_name: gcli2api
|
| 296 |
+
restart: unless-stopped
|
| 297 |
+
network_mode: host
|
| 298 |
+
environment:
|
| 299 |
+
# Using universal password (recommended for simple deployment)
|
| 300 |
+
- PASSWORD=pwd
|
| 301 |
+
- PORT=7861
|
| 302 |
+
# Or use separate passwords (recommended for production)
|
| 303 |
+
# - API_PASSWORD=your_api_password
|
| 304 |
+
# - PANEL_PASSWORD=your_panel_password
|
| 305 |
+
volumes:
|
| 306 |
+
- ./data/creds:/app/creds
|
| 307 |
+
healthcheck:
|
| 308 |
+
test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
|
| 309 |
+
interval: 30s
|
| 310 |
+
timeout: 10s
|
| 311 |
+
retries: 3
|
| 312 |
+
start_period: 40s
|
| 313 |
+
```
|
| 314 |
+
2. Start the service:
|
| 315 |
+
```bash
|
| 316 |
+
docker-compose up -d
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
---
|
| 320 |
+
|
| 321 |
+
## Configuration Instructions
|
| 322 |
+
|
| 323 |
+
1. Visit `http://127.0.0.1:7861` (default port, modifiable via PORT environment variable)
|
| 324 |
+
2. Complete OAuth authentication flow (default password: `pwd`, modifiable via environment variables)
|
| 325 |
+
- **GCLI Mode**: For obtaining Google Cloud Gemini API credentials
|
| 326 |
+
- **Antigravity Mode**: For obtaining Google Antigravity API credentials
|
| 327 |
+
3. Configure client:
|
| 328 |
+
|
| 329 |
+
**OpenAI Compatible Client:**
|
| 330 |
+
- **Endpoint Address**: `http://127.0.0.1:7861/v1`
|
| 331 |
+
- **API Key**: `pwd` (default value, modifiable via API_PASSWORD or PASSWORD environment variables)
|
| 332 |
+
|
| 333 |
+
**Gemini Native Client:**
|
| 334 |
+
- **Endpoint Address**: `http://127.0.0.1:7861`
|
| 335 |
+
- **Authentication Methods**:
|
| 336 |
+
- `Authorization: Bearer your_api_password`
|
| 337 |
+
- `x-goog-api-key: your_api_password`
|
| 338 |
+
- URL parameter: `?key=your_api_password`
|
| 339 |
+
|
| 340 |
+
### 🌟 Dual Authentication Mode Support
|
| 341 |
+
|
| 342 |
+
**GCLI Authentication Mode**
|
| 343 |
+
- Standard Google Cloud Gemini API authentication
|
| 344 |
+
- Supports OAuth2.0 authentication flow
|
| 345 |
+
- Automatically enables required Google Cloud APIs
|
| 346 |
+
|
| 347 |
+
**Antigravity Authentication Mode**
|
| 348 |
+
- Dedicated authentication for Google Antigravity API
|
| 349 |
+
- Independent credential management system
|
| 350 |
+
- Supports batch upload and management
|
| 351 |
+
- Completely isolated from GCLI credentials
|
| 352 |
+
|
| 353 |
+
**Unified Management Interface**
|
| 354 |
+
- Manage both credential types in the "Batch Upload" tab
|
| 355 |
+
- Upper section: GCLI credential batch upload (blue theme)
|
| 356 |
+
- Lower section: Antigravity credential batch upload (green theme)
|
| 357 |
+
- Separate credential management tabs for each type
|
| 358 |
+
|
| 359 |
+
## 💾 Data Storage Mode
|
| 360 |
+
|
| 361 |
+
### 🌟 Storage Backend Support
|
| 362 |
+
|
| 363 |
+
gcli2api supports two storage backends: **Local SQLite (Default)** and **MongoDB (Cloud Distributed Storage)**
|
| 364 |
+
|
| 365 |
+
### 📁 Local SQLite Storage (Default)
|
| 366 |
+
|
| 367 |
+
**Default Storage Method**
|
| 368 |
+
- No configuration required, works out of the box
|
| 369 |
+
- Data is stored in a local SQLite database
|
| 370 |
+
- Suitable for single-machine deployment and personal use
|
| 371 |
+
- Automatically creates and manages database files
|
| 372 |
+
|
| 373 |
+
### 🍃 MongoDB Cloud Storage Mode
|
| 374 |
+
|
| 375 |
+
**Cloud Distributed Storage Solution**
|
| 376 |
+
|
| 377 |
+
When multi-instance deployment or cloud storage is needed, MongoDB storage mode can be enabled.
|
| 378 |
+
|
| 379 |
+
### ⚙️ Enable MongoDB Mode
|
| 380 |
+
|
| 381 |
+
**Step 1: Configure MongoDB Connection**
|
| 382 |
+
```bash
|
| 383 |
+
# Local MongoDB
|
| 384 |
+
export MONGODB_URI="mongodb://localhost:27017"
|
| 385 |
+
|
| 386 |
+
# MongoDB Atlas cloud service
|
| 387 |
+
export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
|
| 388 |
+
|
| 389 |
+
# MongoDB with authentication
|
| 390 |
+
export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
|
| 391 |
+
|
| 392 |
+
# Optional: Custom database name (default: gcli2api)
|
| 393 |
+
export MONGODB_DATABASE="my_gcli_db"
|
| 394 |
+
```
|
| 395 |
+
|
| 396 |
+
**Step 2: Start Application**
|
| 397 |
+
```bash
|
| 398 |
+
# Application will automatically detect MongoDB configuration and use MongoDB storage
|
| 399 |
+
python web.py
|
| 400 |
+
```
|
| 401 |
+
|
| 402 |
+
**Docker Environment using MongoDB**
|
| 403 |
+
```bash
|
| 404 |
+
# Single MongoDB deployment
|
| 405 |
+
docker run -d --name gcli2api \
|
| 406 |
+
-e MONGODB_URI="mongodb://mongodb:27017" \
|
| 407 |
+
-e API_PASSWORD=your_password \
|
| 408 |
+
--network your_network \
|
| 409 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 410 |
+
|
| 411 |
+
# Using MongoDB Atlas
|
| 412 |
+
docker run -d --name gcli2api \
|
| 413 |
+
-e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
|
| 414 |
+
-e API_PASSWORD=your_password \
|
| 415 |
+
-p 7861:7861 \
|
| 416 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 417 |
+
```
|
| 418 |
+
|
| 419 |
+
**Docker Compose Example**
|
| 420 |
+
```yaml
|
| 421 |
+
version: '3.8'
|
| 422 |
+
|
| 423 |
+
services:
|
| 424 |
+
mongodb:
|
| 425 |
+
image: mongo:7
|
| 426 |
+
container_name: gcli2api-mongodb
|
| 427 |
+
restart: unless-stopped
|
| 428 |
+
environment:
|
| 429 |
+
MONGO_INITDB_ROOT_USERNAME: admin
|
| 430 |
+
MONGO_INITDB_ROOT_PASSWORD: password123
|
| 431 |
+
volumes:
|
| 432 |
+
- mongodb_data:/data/db
|
| 433 |
+
ports:
|
| 434 |
+
- "27017:27017"
|
| 435 |
+
|
| 436 |
+
gcli2api:
|
| 437 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 438 |
+
container_name: gcli2api
|
| 439 |
+
restart: unless-stopped
|
| 440 |
+
depends_on:
|
| 441 |
+
- mongodb
|
| 442 |
+
environment:
|
| 443 |
+
- MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
|
| 444 |
+
- MONGODB_DATABASE=gcli2api
|
| 445 |
+
- API_PASSWORD=your_api_password
|
| 446 |
+
- PORT=7861
|
| 447 |
+
ports:
|
| 448 |
+
- "7861:7861"
|
| 449 |
+
|
| 450 |
+
volumes:
|
| 451 |
+
mongodb_data:
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
### 🔧 Advanced Configuration
|
| 456 |
+
|
| 457 |
+
**MongoDB Connection Optimization**
|
| 458 |
+
```bash
|
| 459 |
+
# Connection pool and timeout configuration
|
| 460 |
+
export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
|
| 461 |
+
|
| 462 |
+
# Replica set configuration
|
| 463 |
+
export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
|
| 464 |
+
|
| 465 |
+
# Read-write separation configuration
|
| 466 |
+
export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
|
| 467 |
+
```
|
| 468 |
+
|
| 469 |
+
### Environment Variable Configuration
|
| 470 |
+
|
| 471 |
+
**Basic Configuration**
|
| 472 |
+
- `PORT`: Service port (default: 7861)
|
| 473 |
+
- `HOST`: Server listen address (default: 0.0.0.0)
|
| 474 |
+
|
| 475 |
+
**Password Configuration**
|
| 476 |
+
- `API_PASSWORD`: Chat API access password (default: inherits PASSWORD or pwd)
|
| 477 |
+
- `PANEL_PASSWORD`: Control panel access password (default: inherits PASSWORD or pwd)
|
| 478 |
+
- `PASSWORD`: Universal password, overrides the above two when set (default: pwd)
|
| 479 |
+
|
| 480 |
+
**Performance and Stability Configuration**
|
| 481 |
+
- `RETRY_429_ENABLED`: Enable 429 error automatic retry (default: true)
|
| 482 |
+
- `RETRY_429_MAX_RETRIES`: Maximum retry attempts for 429 errors (default: 3)
|
| 483 |
+
- `RETRY_429_INTERVAL`: Retry interval for 429 errors, in seconds (default: 1.0)
|
| 484 |
+
- `ANTI_TRUNCATION_MAX_ATTEMPTS`: Maximum retry attempts for anti-truncation (default: 3)
|
| 485 |
+
|
| 486 |
+
**Network and Proxy Configuration**
|
| 487 |
+
- `PROXY`: HTTP/HTTPS proxy address (format: `http://host:port`)
|
| 488 |
+
- `OAUTH_PROXY_URL`: OAuth authentication proxy endpoint
|
| 489 |
+
- `GOOGLEAPIS_PROXY_URL`: Google APIs proxy endpoint
|
| 490 |
+
- `METADATA_SERVICE_URL`: Metadata service proxy endpoint
|
| 491 |
+
|
| 492 |
+
**Automation Configuration**
|
| 493 |
+
- `AUTO_BAN`: Enable automatic credential banning (default: true)
|
| 494 |
+
- `AUTO_LOAD_ENV_CREDS`: Automatically load environment variable credentials at startup (default: false)
|
| 495 |
+
|
| 496 |
+
**Compatibility Configuration**
|
| 497 |
+
- `COMPATIBILITY_MODE`: Enable compatibility mode, converts system messages to user messages (default: false)
|
| 498 |
+
|
| 499 |
+
**Logging Configuration**
|
| 500 |
+
- `LOG_LEVEL`: Log level (DEBUG/INFO/WARNING/ERROR, default: INFO)
|
| 501 |
+
- `LOG_FILE`: Log file path (default: log.txt)
|
| 502 |
+
|
| 503 |
+
**Storage Configuration**
|
| 504 |
+
|
| 505 |
+
**SQLite Configuration (Default)**
|
| 506 |
+
- No configuration required, automatically uses local SQLite database
|
| 507 |
+
- Database files are automatically created in the project directory
|
| 508 |
+
|
| 509 |
+
**MongoDB Configuration (Optional Cloud Storage)**
|
| 510 |
+
- `MONGODB_URI`: MongoDB connection string (enables MongoDB mode when set)
|
| 511 |
+
- `MONGODB_DATABASE`: MongoDB database name (default: gcli2api)
|
| 512 |
+
|
| 513 |
+
**Docker Usage Example**
|
| 514 |
+
```bash
|
| 515 |
+
# Using universal password
|
| 516 |
+
docker run -d --name gcli2api \
|
| 517 |
+
-e PASSWORD=mypassword \
|
| 518 |
+
-e PORT=7861 \
|
| 519 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 520 |
+
|
| 521 |
+
# Using separate passwords
|
| 522 |
+
docker run -d --name gcli2api \
|
| 523 |
+
-e API_PASSWORD=my_api_password \
|
| 524 |
+
-e PANEL_PASSWORD=my_panel_password \
|
| 525 |
+
-e PORT=7861 \
|
| 526 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 527 |
+
```
|
| 528 |
+
|
| 529 |
+
Note: When credential environment variables are set, the system will prioritize using credentials from environment variables and ignore files in the `creds` directory.
|
| 530 |
+
|
| 531 |
+
### API Usage Methods
|
| 532 |
+
|
| 533 |
+
This service supports multiple complete sets of API endpoints:
|
| 534 |
+
|
| 535 |
+
#### 1. OpenAI Compatible Endpoints (GCLI)
|
| 536 |
+
|
| 537 |
+
**Endpoint:** `/v1/chat/completions`
|
| 538 |
+
**Authentication:** `Authorization: Bearer your_api_password`
|
| 539 |
+
|
| 540 |
+
Supports two request formats with automatic detection and processing:
|
| 541 |
+
|
| 542 |
+
**OpenAI Format:**
|
| 543 |
+
```json
|
| 544 |
+
{
|
| 545 |
+
"model": "gemini-2.5-pro",
|
| 546 |
+
"messages": [
|
| 547 |
+
{"role": "system", "content": "You are a helpful assistant"},
|
| 548 |
+
{"role": "user", "content": "Hello"}
|
| 549 |
+
],
|
| 550 |
+
"temperature": 0.7,
|
| 551 |
+
"stream": true
|
| 552 |
+
}
|
| 553 |
+
```
|
| 554 |
+
|
| 555 |
+
**Gemini Native Format:**
|
| 556 |
+
```json
|
| 557 |
+
{
|
| 558 |
+
"model": "gemini-2.5-pro",
|
| 559 |
+
"contents": [
|
| 560 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 561 |
+
],
|
| 562 |
+
"systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
|
| 563 |
+
"generationConfig": {
|
| 564 |
+
"temperature": 0.7
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
```
|
| 568 |
+
|
| 569 |
+
#### 2. Gemini Native Endpoints (GCLI)
|
| 570 |
+
|
| 571 |
+
**Non-streaming Endpoint:** `/v1/models/{model}:generateContent`
|
| 572 |
+
**Streaming Endpoint:** `/v1/models/{model}:streamGenerateContent`
|
| 573 |
+
**Model List:** `/v1/models`
|
| 574 |
+
|
| 575 |
+
**Authentication Methods (choose one):**
|
| 576 |
+
- `Authorization: Bearer your_api_password`
|
| 577 |
+
- `x-goog-api-key: your_api_password`
|
| 578 |
+
- URL parameter: `?key=your_api_password`
|
| 579 |
+
|
| 580 |
+
**Request Examples:**
|
| 581 |
+
```bash
|
| 582 |
+
# Using x-goog-api-key header
|
| 583 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
|
| 584 |
+
-H "x-goog-api-key: your_api_password" \
|
| 585 |
+
-H "Content-Type: application/json" \
|
| 586 |
+
-d '{
|
| 587 |
+
"contents": [
|
| 588 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 589 |
+
]
|
| 590 |
+
}'
|
| 591 |
+
|
| 592 |
+
# Using URL parameter
|
| 593 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
|
| 594 |
+
-H "Content-Type: application/json" \
|
| 595 |
+
-d '{
|
| 596 |
+
"contents": [
|
| 597 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 598 |
+
]
|
| 599 |
+
}'
|
| 600 |
+
```
|
| 601 |
+
|
| 602 |
+
#### 3. Claude API Format Endpoints
|
| 603 |
+
|
| 604 |
+
**Endpoint:** `/v1/messages`
|
| 605 |
+
**Authentication:** `x-api-key: your_api_password` or `Authorization: Bearer your_api_password`
|
| 606 |
+
|
| 607 |
+
**Request Example:**
|
| 608 |
+
```bash
|
| 609 |
+
curl -X POST "http://127.0.0.1:7861/v1/messages" \
|
| 610 |
+
-H "x-api-key: your_api_password" \
|
| 611 |
+
-H "anthropic-version: 2023-06-01" \
|
| 612 |
+
-H "Content-Type: application/json" \
|
| 613 |
+
-d '{
|
| 614 |
+
"model": "gemini-2.5-pro",
|
| 615 |
+
"max_tokens": 1024,
|
| 616 |
+
"messages": [
|
| 617 |
+
{"role": "user", "content": "Hello, Claude!"}
|
| 618 |
+
]
|
| 619 |
+
}'
|
| 620 |
+
```
|
| 621 |
+
|
| 622 |
+
**Support for system parameter:**
|
| 623 |
+
```json
|
| 624 |
+
{
|
| 625 |
+
"model": "gemini-2.5-pro",
|
| 626 |
+
"max_tokens": 1024,
|
| 627 |
+
"system": "You are a helpful assistant",
|
| 628 |
+
"messages": [
|
| 629 |
+
{"role": "user", "content": "Hello"}
|
| 630 |
+
]
|
| 631 |
+
}
|
| 632 |
+
```
|
| 633 |
+
|
| 634 |
+
**Notes:**
|
| 635 |
+
- Fully compatible with Claude API format specification
|
| 636 |
+
- Automatically converts to Gemini format for backend calls
|
| 637 |
+
- Supports all Claude standard parameters
|
| 638 |
+
- Response format follows Claude API specification
|
| 639 |
+
|
| 640 |
+
## 📋 Complete API Reference
|
| 641 |
+
|
| 642 |
+
### Web Console API
|
| 643 |
+
|
| 644 |
+
**Authentication Endpoints**
|
| 645 |
+
- `POST /auth/login` - User login
|
| 646 |
+
- `POST /auth/start` - Start OAuth authentication (supports GCLI and Antigravity modes)
|
| 647 |
+
- `POST /auth/callback` - Handle OAuth callback
|
| 648 |
+
- `POST /auth/callback-url` - Complete authentication directly from callback URL
|
| 649 |
+
- `GET /auth/status/{project_id}` - Check authentication status
|
| 650 |
+
|
| 651 |
+
**Credential Management Endpoints** (supports `mode=geminicli` or `mode=antigravity` parameter)
|
| 652 |
+
- `POST /creds/upload` - Batch upload credential files (supports JSON and ZIP)
|
| 653 |
+
- `GET /creds/status` - Get credential status list (supports pagination and filtering)
|
| 654 |
+
- `GET /creds/detail/{filename}` - Get single credential details
|
| 655 |
+
- `POST /creds/action` - Single credential operation (enable/disable/delete)
|
| 656 |
+
- `POST /creds/batch-action` - Batch credential operations
|
| 657 |
+
- `GET /creds/download/{filename}` - Download single credential file
|
| 658 |
+
- `GET /creds/download-all` - Package download all credentials
|
| 659 |
+
- `POST /creds/fetch-email/{filename}` - Get user email
|
| 660 |
+
- `POST /creds/refresh-all-emails` - Batch refresh user emails
|
| 661 |
+
- `POST /creds/deduplicate-by-email` - Deduplicate credentials by email
|
| 662 |
+
- `POST /creds/verify-project/{filename}` - Verify credential Project ID
|
| 663 |
+
- `GET /creds/quota/{filename}` - Get credential quota information (Antigravity only)
|
| 664 |
+
|
| 665 |
+
**Configuration Management Endpoints**
|
| 666 |
+
- `GET /config/get` - Get current configuration
|
| 667 |
+
- `POST /config/save` - Save configuration
|
| 668 |
+
|
| 669 |
+
**Log Management Endpoints**
|
| 670 |
+
- `POST /logs/clear` - Clear logs
|
| 671 |
+
- `GET /logs/download` - Download log file
|
| 672 |
+
- `WebSocket /logs/stream` - Real-time log stream
|
| 673 |
+
|
| 674 |
+
**Version Information Endpoints**
|
| 675 |
+
- `GET /version/info` - Get version information (optional `check_update=true` parameter to check for updates)
|
| 676 |
+
|
| 677 |
+
### Chat API Features
|
| 678 |
+
|
| 679 |
+
**Multimodal Support**
|
| 680 |
+
```json
|
| 681 |
+
{
|
| 682 |
+
"model": "gemini-2.5-pro",
|
| 683 |
+
"messages": [
|
| 684 |
+
{
|
| 685 |
+
"role": "user",
|
| 686 |
+
"content": [
|
| 687 |
+
{"type": "text", "text": "Describe this image"},
|
| 688 |
+
{
|
| 689 |
+
"type": "image_url",
|
| 690 |
+
"image_url": {
|
| 691 |
+
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
]
|
| 695 |
+
}
|
| 696 |
+
]
|
| 697 |
+
}
|
| 698 |
+
```
|
| 699 |
+
|
| 700 |
+
**Thinking Mode Support**
|
| 701 |
+
```json
|
| 702 |
+
{
|
| 703 |
+
"model": "gemini-2.5-pro-high",
|
| 704 |
+
"messages": [
|
| 705 |
+
{"role": "user", "content": "Complex math problem"}
|
| 706 |
+
]
|
| 707 |
+
}
|
| 708 |
+
```
|
| 709 |
+
|
| 710 |
+
Response will include separated thinking content:
|
| 711 |
+
```json
|
| 712 |
+
{
|
| 713 |
+
"choices": [{
|
| 714 |
+
"message": {
|
| 715 |
+
"role": "assistant",
|
| 716 |
+
"content": "Final answer",
|
| 717 |
+
"reasoning_content": "Detailed thought process..."
|
| 718 |
+
}
|
| 719 |
+
}]
|
| 720 |
+
}
|
| 721 |
+
```
|
| 722 |
+
|
| 723 |
+
**Streaming Anti-truncation Usage**
|
| 724 |
+
```json
|
| 725 |
+
{
|
| 726 |
+
"model": "流式抗截断/gemini-2.5-pro",
|
| 727 |
+
"messages": [
|
| 728 |
+
{"role": "user", "content": "Write a long article"}
|
| 729 |
+
],
|
| 730 |
+
"stream": true
|
| 731 |
+
}
|
| 732 |
+
```
|
| 733 |
+
|
| 734 |
+
**Compatibility Mode**
|
| 735 |
+
```bash
|
| 736 |
+
# Enable compatibility mode
|
| 737 |
+
export COMPATIBILITY_MODE=true
|
| 738 |
+
```
|
| 739 |
+
In this mode, all `system` messages are converted to `user` messages, improving compatibility with certain clients.
|
| 740 |
+
|
| 741 |
+
---
|
| 742 |
+
|
| 743 |
+
## 💬 Community
|
| 744 |
+
|
| 745 |
+
Welcome to join the QQ group for discussion!
|
| 746 |
+
|
| 747 |
+
**QQ Group: 1083250744**
|
| 748 |
+
|
| 749 |
+
<img src="qq群.jpg" width="200" alt="QQ Group QR Code">
|
| 750 |
+
|
| 751 |
+
---
|
| 752 |
+
|
| 753 |
+
## License and Disclaimer
|
| 754 |
+
|
| 755 |
+
This project is for learning and research purposes only. Using this project indicates that you agree to:
|
| 756 |
+
- Not use this project for any commercial purposes
|
| 757 |
+
- Bear all risks and responsibilities of using this project
|
| 758 |
+
- Comply with relevant terms of service and legal regulations
|
| 759 |
+
|
| 760 |
+
The project authors are not responsible for any direct or indirect losses arising from the use of this project.
|
docs/README_JA.md
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GeminiCLI to API
|
| 2 |
+
|
| 3 |
+
**GeminiCLIおよびAntigravityをOpenAI、GEMINI、Claude API互換インターフェースに変換**
|
| 4 |
+
|
| 5 |
+
[](https://www.python.org/downloads/)
|
| 6 |
+
[](../LICENSE)
|
| 7 |
+
[](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
|
| 8 |
+
|
| 9 |
+
[中文](../README.md) | [English](README_EN.md) | 日本語
|
| 10 |
+
|
| 11 |
+
## 🚀 クイックデプロイ
|
| 12 |
+
|
| 13 |
+
[](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
|
| 14 |
+
[](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## ⚠️ ライセンスについて
|
| 18 |
+
|
| 19 |
+
**本プロジェクトはCooperative Non-Commercial License (CNC-1.0) の下でライセンスされています**
|
| 20 |
+
|
| 21 |
+
これは厳格な非商用オープンソースライセンスです。詳細は [LICENSE](../LICENSE) ファイルをご参照ください。
|
| 22 |
+
|
| 23 |
+
### ✅ 許可される用途:
|
| 24 |
+
- 個人の学習、研究、教育目的
|
| 25 |
+
- 非営利団体での利用
|
| 26 |
+
- オープンソースプロジェクトへの統合(同一ライセンスの遵守が必要)
|
| 27 |
+
- 学術研究および論文発表
|
| 28 |
+
|
| 29 |
+
### ❌ 禁止される用途:
|
| 30 |
+
- あらゆる形態の商用利用
|
| 31 |
+
- 年間売上が100万ドルを超える企業での利用
|
| 32 |
+
- ベンチャーキャピタルの出資を受けた企業または上場企業
|
| 33 |
+
- 有料サービスまたは製品の提供
|
| 34 |
+
- 商業的な競合利用
|
| 35 |
+
|
| 36 |
+
## コア機能
|
| 37 |
+
|
| 38 |
+
### 🔄 APIエンドポイントとフォーマット対応
|
| 39 |
+
|
| 40 |
+
**マルチエンドポイント・マルチフォーマット対応**
|
| 41 |
+
- **OpenAI互換エンドポイント**: `/v1/chat/completions` および `/v1/models`
|
| 42 |
+
- 標準OpenAIフォーマット(messages構造)に対応
|
| 43 |
+
- Geminiネイティブフォーマット(contents構造)に対応
|
| 44 |
+
- フォーマットの自動検出・変換、手動切替不要
|
| 45 |
+
- マルチモーダル入力に対応(テキスト+画像)
|
| 46 |
+
- **Geminiネイティブエンドポイント**: `/v1/models/{model}:generateContent` および `streamGenerateContent`
|
| 47 |
+
- Geminiネイティブ API仕様に完全対応
|
| 48 |
+
- 複数の認証方式: Bearer Token、x-goog-api-keyヘッダー、URLパラメータkey
|
| 49 |
+
- **Claudeフォーマット互換**: Claude APIフォーマットに完全対応
|
| 50 |
+
- エンドポイント: `/v1/messages`(Claude API仕様に準拠)
|
| 51 |
+
- Claude標準messagesフォーマットに対応
|
| 52 |
+
- systemパラメータおよびClaude固有機能に対応
|
| 53 |
+
- バックエンド対応フォーマットへの自動変換
|
| 54 |
+
- **Antigravity API対応**: OpenAI、Gemini、Claudeフォーマットに対応
|
| 55 |
+
- OpenAIフォーマットエンドポイント: `/antigravity/v1/chat/completions`
|
| 56 |
+
- Geminiフォーマットエンドポイント: `/antigravity/v1/models/{model}:generateContent` および `streamGenerateContent`
|
| 57 |
+
- Claudeフォーマットエンドポイント: `/antigravity/v1/messages`
|
| 58 |
+
- 全Antigravityモデルに対応(Claude、Geminiなど)
|
| 59 |
+
- モデル名の自動マッピングおよびThinkingモード検出
|
| 60 |
+
|
| 61 |
+
### 🔐 認証とセキュリティ管理
|
| 62 |
+
|
| 63 |
+
**柔軟なパスワード管理**
|
| 64 |
+
- **個別パスワード対応**: APIパスワード(チャットエンドポイント)とコントロールパネルパスワードを個別に設定可能
|
| 65 |
+
- **複数の認証方式**: Authorization Bearer、x-goog-api-keyヘッダー、URLパラメータなどに対応
|
| 66 |
+
- **JWTトークン認証**: コントロールパネルはJWTトークン認証に対応
|
| 67 |
+
- **ユーザーメール取得**: Googleアカウントのメールアドレスを自動取得・表示
|
| 68 |
+
|
| 69 |
+
### 📊 インテリジェントなクレデンシャル管理システム
|
| 70 |
+
|
| 71 |
+
**高度なクレデンシャル管理**
|
| 72 |
+
- 複数のGoogle OAuthクレデンシャルの自動ローテーション
|
| 73 |
+
- 冗長認証による安定性の向上
|
| 74 |
+
- ロードバランシングと同時リクエスト対応
|
| 75 |
+
- 自動障害検出とクレデンシャル無効化
|
| 76 |
+
- クレデンシャル使用統計とクォータ管理
|
| 77 |
+
- クレデンシャルファイルの手動有効化/無効化に対応
|
| 78 |
+
- クレデンシャルファイルの一括操作(有効化、無効化、削除)
|
| 79 |
+
|
| 80 |
+
**クレデンシャルステータス監視**
|
| 81 |
+
- リアルタイムのクレデンシャルヘルスチェック
|
| 82 |
+
- エラーコードの追跡(429、403、500など)
|
| 83 |
+
- 自動BAN機能(設定可能)
|
| 84 |
+
|
| 85 |
+
### 🌊 ストリーミングとレスポンス処理
|
| 86 |
+
|
| 87 |
+
**複数のストリーミング対応**
|
| 88 |
+
- リアルタイムストリーミングレスポンス
|
| 89 |
+
- 疑似ストリーミングモード(互換性向上用)
|
| 90 |
+
- ストリーミング途切れ防止機能(回答の途切れを防止)
|
| 91 |
+
- 非同期タスク管理とタイムアウト処理
|
| 92 |
+
|
| 93 |
+
**レスポンス最適化**
|
| 94 |
+
- 思考チェーン内容の分離
|
| 95 |
+
- 推論プロセス(reasoning_content)の処理
|
| 96 |
+
- マルチターン会話のコンテキスト管理
|
| 97 |
+
- 互換モード(systemメッセージをuserメッセージに変換)
|
| 98 |
+
|
| 99 |
+
### 🎛️ Web管理コンソール
|
| 100 |
+
|
| 101 |
+
**フル機能のWebインターフェース**
|
| 102 |
+
- OAuth認証フロー管理(GCLIおよびAntigravityデュアルモード対応)
|
| 103 |
+
- クレデンシャルファイルのアップロード、ダウンロード、管理
|
| 104 |
+
- リアルタイムログ表示(WebSocket)
|
| 105 |
+
- システム設定管理
|
| 106 |
+
- 使用統計と監視ダッシュボード
|
| 107 |
+
- モバイル対応インターフェース
|
| 108 |
+
|
| 109 |
+
**一括操作対応**
|
| 110 |
+
- ZIPファイルによるクレデンシャル一括アップロード(GCLIおよびAntigravity)
|
| 111 |
+
- クレデンシャルの一括有効化/無効化/削除
|
| 112 |
+
- ユーザーメールの一括取得
|
| 113 |
+
- 設定の一括管理
|
| 114 |
+
- 全クレデンシャルタイプ統合一括アップロードインターフェース
|
| 115 |
+
|
| 116 |
+
### 📈 使用状況モニタリング
|
| 117 |
+
|
| 118 |
+
**リアルタイム監視**
|
| 119 |
+
- WebSocketリアルタイムログストリーム
|
| 120 |
+
- システムステータス監視
|
| 121 |
+
- クレデンシャルヘルスステータス
|
| 122 |
+
|
| 123 |
+
### 🔧 高度な設定とカスタマイズ
|
| 124 |
+
|
| 125 |
+
**ネットワークとプロキシ設定**
|
| 126 |
+
- HTTP/HTTPSプロキシ対応
|
| 127 |
+
- プロキシエンドポイント設定(OAuth、Google APIs、メタデータサービス)
|
| 128 |
+
- タイムアウトとリトライ設定
|
| 129 |
+
- ネットワークエラー処理とリカバリ
|
| 130 |
+
|
| 131 |
+
**パフォーマンスと安定性の設定**
|
| 132 |
+
- 429エラーの自動リトライ(間隔と回数を設定可能)
|
| 133 |
+
- 途切れ防止の最大リトライ回数
|
| 134 |
+
|
| 135 |
+
**ログとデバッグ**
|
| 136 |
+
- マルチレベルログシステム(DEBUG、INFO、WARNING、ERROR)
|
| 137 |
+
- ログファイル管理
|
| 138 |
+
- リアルタイムログストリーム
|
| 139 |
+
- ログのダウンロードとクリア
|
| 140 |
+
|
| 141 |
+
### 🔄 環境変数と設定管理
|
| 142 |
+
|
| 143 |
+
**柔軟な設定方法**
|
| 144 |
+
- 環境変数による設定
|
| 145 |
+
- ホット設定更新(一部設定項目)
|
| 146 |
+
- 設定ロック(環境変数優先)
|
| 147 |
+
|
| 148 |
+
## 対応モデル
|
| 149 |
+
|
| 150 |
+
全モデルが100万トークンのコンテキストウィンドウに対応。各クレデンシャルファイルで1000リクエストのクォータを提供。
|
| 151 |
+
|
| 152 |
+
### 🤖 基本モデル
|
| 153 |
+
- `gemini-2.5-pro`
|
| 154 |
+
- `gemini-3-pro-preview`
|
| 155 |
+
- `gemini-3.1-pro-preview`
|
| 156 |
+
|
| 157 |
+
### 🧠 Thinkingモデル
|
| 158 |
+
- `gemini-2.5-pro-high`: Thinkingモード
|
| 159 |
+
- `gemini-2.5-pro-low`: 低Thinkingモード
|
| 160 |
+
- カスタムThinkingバジェット設定に対応
|
| 161 |
+
- 思考内容と最終回答の自動分離
|
| 162 |
+
|
| 163 |
+
### 🔍 検索拡張モデル
|
| 164 |
+
- `gemini-2.5-pro-search`: 検索機能統合モデル
|
| 165 |
+
|
| 166 |
+
### 🖼️ 画像生成モデル(Antigravity)
|
| 167 |
+
- `gemini-3.1-flash-image`: 基本画像生成モデル
|
| 168 |
+
- **解像度サフィックス**:
|
| 169 |
+
- `-2k`: 2K解像度
|
| 170 |
+
- `-4k`: 4K HD解像度
|
| 171 |
+
- **アスペクト比サフィックス**:
|
| 172 |
+
- `-1x1`: 正方形(アバター)
|
| 173 |
+
- `-16x9`: 横長(デスクトップ壁紙)
|
| 174 |
+
- `-9x16`: 縦長(モバイル壁紙)
|
| 175 |
+
- `-21x9`: ウルトラワイド(ウルトラワイドモニター)
|
| 176 |
+
- `-4x3`: 従来のディスプレイ
|
| 177 |
+
- `-3x4`: 縦型ポスター
|
| 178 |
+
- **組み合わせ例**:
|
| 179 |
+
- `gemini-3.1-flash-image-4k-16x9`: 4K横長
|
| 180 |
+
- `gemini-3.1-flash-image-2k-9x16`: 2K縦長
|
| 181 |
+
- 比率未指定時はAPIが自動的にアスペクト比を決定
|
| 182 |
+
|
| 183 |
+
### 🌊 特殊機能バリアント
|
| 184 |
+
- **疑似ストリーミングモード**: 任意のモデル名に `-假流式` サフィックスを追加
|
| 185 |
+
- 例: `gemini-2.5-pro-假流式`
|
| 186 |
+
- ストリーミングレスポンスが必要だがサーバーが真のストリーミングに対応していない場合に使用
|
| 187 |
+
- **ストリーミング途切れ防止モード**: モデル名に `流式抗截断/` プレフィックスを追加
|
| 188 |
+
- 例: `流式抗截断/gemini-2.5-pro`
|
| 189 |
+
- レスポンスの途切れを自動検出しリトライして完全な回答を保証
|
| 190 |
+
|
| 191 |
+
### 🔧 モデル機能の自動検出
|
| 192 |
+
- システムがモデル名内の機能識別子を自動認識
|
| 193 |
+
- 機能モード切替を透過的に処理
|
| 194 |
+
- 機能の組み合わせ使用に対応
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## インストールガイド
|
| 200 |
+
|
| 201 |
+
### Termux環境
|
| 202 |
+
|
| 203 |
+
**初期インストール**
|
| 204 |
+
```bash
|
| 205 |
+
curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
**サービス再起動**
|
| 209 |
+
```bash
|
| 210 |
+
cd gcli2api
|
| 211 |
+
bash termux-start.sh
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
### Windows環境
|
| 215 |
+
|
| 216 |
+
**初期インストール**
|
| 217 |
+
```powershell
|
| 218 |
+
iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
**サービス再起動**
|
| 222 |
+
`start.bat` をダブルクリックして実行
|
| 223 |
+
|
| 224 |
+
### Linux環境
|
| 225 |
+
|
| 226 |
+
**初期インストール**
|
| 227 |
+
```bash
|
| 228 |
+
curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
**サービス再起動**
|
| 232 |
+
```bash
|
| 233 |
+
cd gcli2api
|
| 234 |
+
bash start.sh
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
### macOS環境
|
| 238 |
+
|
| 239 |
+
**初期インストー���**
|
| 240 |
+
```bash
|
| 241 |
+
curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
**サービス再起動**
|
| 245 |
+
```bash
|
| 246 |
+
cd gcli2api
|
| 247 |
+
bash start.sh
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
### Docker環境
|
| 251 |
+
|
| 252 |
+
**Docker Runコマンド**
|
| 253 |
+
```bash
|
| 254 |
+
# 共通パスワードを使用
|
| 255 |
+
docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
|
| 256 |
+
|
| 257 |
+
# 個別パスワードを使用
|
| 258 |
+
docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
**Docker Mac**
|
| 262 |
+
```bash
|
| 263 |
+
# 共通パスワードを使用
|
| 264 |
+
docker run -d \
|
| 265 |
+
--name gcli2api \
|
| 266 |
+
-p 7861:7861 \
|
| 267 |
+
-p 8080:8080 \
|
| 268 |
+
-e PASSWORD=pwd \
|
| 269 |
+
-e PORT=7861 \
|
| 270 |
+
-v "$(pwd)/data/creds":/app/creds \
|
| 271 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
```bash
|
| 275 |
+
# 個別パスワードを使用
|
| 276 |
+
docker run -d \
|
| 277 |
+
--name gcli2api \
|
| 278 |
+
-p 7861:7861 \
|
| 279 |
+
-p 8080:8080 \
|
| 280 |
+
-e API_PASSWORD=api_pwd \
|
| 281 |
+
-e PANEL_PASSWORD=panel_pwd \
|
| 282 |
+
-e PORT=7861 \
|
| 283 |
+
-v $(pwd)/data/creds:/app/creds \
|
| 284 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
**Docker Compose Runコマンド**
|
| 288 |
+
1. 以下の内容を `docker-compose.yml` ファイルとして保存:
|
| 289 |
+
```yaml
|
| 290 |
+
version: '3.8'
|
| 291 |
+
|
| 292 |
+
services:
|
| 293 |
+
gcli2api:
|
| 294 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 295 |
+
container_name: gcli2api
|
| 296 |
+
restart: unless-stopped
|
| 297 |
+
network_mode: host
|
| 298 |
+
environment:
|
| 299 |
+
# 共通パスワードを使用(シンプルなデプロイに推奨)
|
| 300 |
+
- PASSWORD=pwd
|
| 301 |
+
- PORT=7861
|
| 302 |
+
# または個別パスワードを使用(本番環境に推奨)
|
| 303 |
+
# - API_PASSWORD=your_api_password
|
| 304 |
+
# - PANEL_PASSWORD=your_panel_password
|
| 305 |
+
volumes:
|
| 306 |
+
- ./data/creds:/app/creds
|
| 307 |
+
healthcheck:
|
| 308 |
+
test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
|
| 309 |
+
interval: 30s
|
| 310 |
+
timeout: 10s
|
| 311 |
+
retries: 3
|
| 312 |
+
start_period: 40s
|
| 313 |
+
```
|
| 314 |
+
2. サービスを起動:
|
| 315 |
+
```bash
|
| 316 |
+
docker-compose up -d
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
---
|
| 320 |
+
|
| 321 |
+
## 設定手順
|
| 322 |
+
|
| 323 |
+
1. `http://127.0.0.1:7861` にアクセス(デフォルトポート、PORT環境変数で変更可能)
|
| 324 |
+
2. OAuth認証フローを完了(デフォルトパスワード: `pwd`、環境変数で変更可能)
|
| 325 |
+
- **GCLIモード**: Google Cloud Gemini APIクレデンシャルの取得用
|
| 326 |
+
- **Antigravityモード**: Google Antigravity APIクレデンシャルの取得用
|
| 327 |
+
3. クライアントを設定:
|
| 328 |
+
|
| 329 |
+
**OpenAI互換クライアント:**
|
| 330 |
+
- **エンドポイントアドレス**: `http://127.0.0.1:7861/v1`
|
| 331 |
+
- **APIキー**: `pwd`(デフォルト値、API_PASSWORDまたはPASSWORD環境変数で変更可能)
|
| 332 |
+
|
| 333 |
+
**Geminiネイティブクライアント:**
|
| 334 |
+
- **エンドポイントアドレス**: `http://127.0.0.1:7861`
|
| 335 |
+
- **認証方式**:
|
| 336 |
+
- `Authorization: Bearer your_api_password`
|
| 337 |
+
- `x-goog-api-key: your_api_password`
|
| 338 |
+
- URLパラメータ: `?key=your_api_password`
|
| 339 |
+
|
| 340 |
+
### 🌟 デュアル認証モード対応
|
| 341 |
+
|
| 342 |
+
**GCLI認証モード**
|
| 343 |
+
- 標準Google Cloud Gemini API認証
|
| 344 |
+
- OAuth2.0認証フローに対応
|
| 345 |
+
- 必要なGoogle Cloud APIを自動的に有効化
|
| 346 |
+
|
| 347 |
+
**Antigravity認証モード**
|
| 348 |
+
- Google Antigravity API専用認証
|
| 349 |
+
- 独立したクレデンシャル管理システム
|
| 350 |
+
- 一括アップロードと管理に対応
|
| 351 |
+
- GCLIクレデンシャルとは完全に分離
|
| 352 |
+
|
| 353 |
+
**統合管理インターフェース**
|
| 354 |
+
- 「一括アップロード」タブで両方のクレデンシャルタイプを管理
|
| 355 |
+
- 上部セクション: GCLIクレデンシャル一括アップロード(青テーマ)
|
| 356 |
+
- 下部セクション: Antigravityクレデンシャル一括アップロード(緑テーマ)
|
| 357 |
+
- 各タイプ別のクレデンシャル管理タブ
|
| 358 |
+
|
| 359 |
+
## 💾 データストレージモード
|
| 360 |
+
|
| 361 |
+
### 🌟 ストレージバックエンド対応
|
| 362 |
+
|
| 363 |
+
gcli2apiは2つのストレージバックエンドに対応: **ローカルSQLite(デフォルト)** と **MongoDB(クラウド分散ストレージ)**
|
| 364 |
+
|
| 365 |
+
### 📁 ローカルSQLiteストレージ(デフォルト)
|
| 366 |
+
|
| 367 |
+
**デフォルトストレージ方式**
|
| 368 |
+
- 設定不要、すぐに利用可能
|
| 369 |
+
- データはローカルSQLiteデータベースに保存
|
| 370 |
+
- 単一マシンデプロイおよび個人利用に最適
|
| 371 |
+
- データベースファイルの自動作成・管理
|
| 372 |
+
|
| 373 |
+
### 🍃 MongoDBクラウドストレージモード
|
| 374 |
+
|
| 375 |
+
**クラウド分散ストレージソリューション**
|
| 376 |
+
|
| 377 |
+
マルチインスタンスデプロイやクラウドストレージが必要な場合、MongoDBストレージモードを有効にできます。
|
| 378 |
+
|
| 379 |
+
### ⚙️ MongoDBモードの有効化
|
| 380 |
+
|
| 381 |
+
**ステップ1: MongoDB接続の設定**
|
| 382 |
+
```bash
|
| 383 |
+
# ローカルMongoDB
|
| 384 |
+
export MONGODB_URI="mongodb://localhost:27017"
|
| 385 |
+
|
| 386 |
+
# MongoDB Atlasクラウドサービス
|
| 387 |
+
export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
|
| 388 |
+
|
| 389 |
+
# 認証付きMongoDB
|
| 390 |
+
export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
|
| 391 |
+
|
| 392 |
+
# オプション: カスタムデータベース名(デフォルト: gcli2api)
|
| 393 |
+
export MONGODB_DATABASE="my_gcli_db"
|
| 394 |
+
```
|
| 395 |
+
|
| 396 |
+
**ステップ2: アプリケーションの起動**
|
| 397 |
+
```bash
|
| 398 |
+
# アプリケーションがMongoDB設定を自動検出し、MongoDBストレージを使用します
|
| 399 |
+
python web.py
|
| 400 |
+
```
|
| 401 |
+
|
| 402 |
+
**Docker環境でのMongoDB使用**
|
| 403 |
+
```bash
|
| 404 |
+
# 単一MongoDBデプロイ
|
| 405 |
+
docker run -d --name gcli2api \
|
| 406 |
+
-e MONGODB_URI="mongodb://mongodb:27017" \
|
| 407 |
+
-e API_PASSWORD=your_password \
|
| 408 |
+
--network your_network \
|
| 409 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 410 |
+
|
| 411 |
+
# MongoDB Atlasの使用
|
| 412 |
+
docker run -d --name gcli2api \
|
| 413 |
+
-e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
|
| 414 |
+
-e API_PASSWORD=your_password \
|
| 415 |
+
-p 7861:7861 \
|
| 416 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 417 |
+
```
|
| 418 |
+
|
| 419 |
+
**Docker Composeの例**
|
| 420 |
+
```yaml
|
| 421 |
+
version: '3.8'
|
| 422 |
+
|
| 423 |
+
services:
|
| 424 |
+
mongodb:
|
| 425 |
+
image: mongo:7
|
| 426 |
+
container_name: gcli2api-mongodb
|
| 427 |
+
restart: unless-stopped
|
| 428 |
+
environment:
|
| 429 |
+
MONGO_INITDB_ROOT_USERNAME: admin
|
| 430 |
+
MONGO_INITDB_ROOT_PASSWORD: password123
|
| 431 |
+
volumes:
|
| 432 |
+
- mongodb_data:/data/db
|
| 433 |
+
ports:
|
| 434 |
+
- "27017:27017"
|
| 435 |
+
|
| 436 |
+
gcli2api:
|
| 437 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 438 |
+
container_name: gcli2api
|
| 439 |
+
restart: unless-stopped
|
| 440 |
+
depends_on:
|
| 441 |
+
- mongodb
|
| 442 |
+
environment:
|
| 443 |
+
- MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
|
| 444 |
+
- MONGODB_DATABASE=gcli2api
|
| 445 |
+
- API_PASSWORD=your_api_password
|
| 446 |
+
- PORT=7861
|
| 447 |
+
ports:
|
| 448 |
+
- "7861:7861"
|
| 449 |
+
|
| 450 |
+
volumes:
|
| 451 |
+
mongodb_data:
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
### 🔧 高度な設定
|
| 456 |
+
|
| 457 |
+
**MongoDB接続の最適化**
|
| 458 |
+
```bash
|
| 459 |
+
# コネクションプールとタイムアウト設定
|
| 460 |
+
export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
|
| 461 |
+
|
| 462 |
+
# レプリカセット設定
|
| 463 |
+
export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
|
| 464 |
+
|
| 465 |
+
# リード・ライト分離設定
|
| 466 |
+
export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
|
| 467 |
+
```
|
| 468 |
+
|
| 469 |
+
### 環境変数設定
|
| 470 |
+
|
| 471 |
+
**基本設定**
|
| 472 |
+
- `PORT`: サービスポート(デフォルト: 7861)
|
| 473 |
+
- `HOST`: サーバーリッスンアドレス(デフォルト: 0.0.0.0)
|
| 474 |
+
|
| 475 |
+
**パスワード設定**
|
| 476 |
+
- `API_PASSWORD`: チャットAPIアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
|
| 477 |
+
- `PANEL_PASSWORD`: コントロールパネルアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
|
| 478 |
+
- `PASSWORD`: 共通パスワード、設定時に上記2つを上書き(デフォルト: pwd)
|
| 479 |
+
|
| 480 |
+
**パフォーマンスと安定性の設定**
|
| 481 |
+
- `RETRY_429_ENABLED`: 429エラー自動リトライの有効化(デフォルト: true)
|
| 482 |
+
- `RETRY_429_MAX_RETRIES`: 429エラーの最大リトライ回数(デフォルト: 3)
|
| 483 |
+
- `RETRY_429_INTERVAL`: 429エラーのリトライ間隔、秒単位(デフォルト: 1.0)
|
| 484 |
+
- `ANTI_TRUNCATION_MAX_ATTEMPTS`: 途切れ防止の最大リトライ回数(デフォルト: 3)
|
| 485 |
+
|
| 486 |
+
**ネットワークとプロキシ設定**
|
| 487 |
+
- `PROXY`: HTTP/HTTPSプロキシアドレス(形式: `http://host:port`)
|
| 488 |
+
- `OAUTH_PROXY_URL`: OAuth認証プロキシエンドポイント
|
| 489 |
+
- `GOOGLEAPIS_PROXY_URL`: Google APIsプロキシエンドポイント
|
| 490 |
+
- `METADATA_SERVICE_URL`: メタデータサービスプロキシエンドポイント
|
| 491 |
+
|
| 492 |
+
**自動化設定**
|
| 493 |
+
- `AUTO_BAN`: クレデンシャル自動BANの有効化(デフォルト: true)
|
| 494 |
+
- `AUTO_LOAD_ENV_CREDS`: 起動時に環境変数クレデンシャルを自動ロード(デフォルト: false)
|
| 495 |
+
|
| 496 |
+
**互換性設定**
|
| 497 |
+
- `COMPATIBILITY_MODE`: 互換モードの有効化、systemメッセージをuserメッセージに変換(デフォルト: false)
|
| 498 |
+
|
| 499 |
+
**ログ設定**
|
| 500 |
+
- `LOG_LEVEL`: ログレベル(DEBUG/INFO/WARNING/ERROR、デフォルト: INFO)
|
| 501 |
+
- `LOG_FILE`: ログファイルパス(デフォルト: log.txt)
|
| 502 |
+
|
| 503 |
+
**ストレージ設定**
|
| 504 |
+
|
| 505 |
+
**SQLite設定(デフォルト)**
|
| 506 |
+
- 設定不要、自動的にローカルSQLiteデータベースを使用
|
| 507 |
+
- データベースファイルはプロジェクトディレクトリに自動作成
|
| 508 |
+
|
| 509 |
+
**MongoDB設定(オプションのクラウドストレージ)**
|
| 510 |
+
- `MONGODB_URI`: MongoDB接続文字列(設定時にMongoDBモードを有効化)
|
| 511 |
+
- `MONGODB_DATABASE`: MongoDBデータベース名(デフォルト: gcli2api)
|
| 512 |
+
|
| 513 |
+
**Docker使用例**
|
| 514 |
+
```bash
|
| 515 |
+
# 共通パスワードを使用
|
| 516 |
+
docker run -d --name gcli2api \
|
| 517 |
+
-e PASSWORD=mypassword \
|
| 518 |
+
-e PORT=7861 \
|
| 519 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 520 |
+
|
| 521 |
+
# 個別パスワードを使用
|
| 522 |
+
docker run -d --name gcli2api \
|
| 523 |
+
-e API_PASSWORD=my_api_password \
|
| 524 |
+
-e PANEL_PASSWORD=my_panel_password \
|
| 525 |
+
-e PORT=7861 \
|
| 526 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 527 |
+
```
|
| 528 |
+
|
| 529 |
+
注意: クレデンシャル環境変数が設定されている場合、システムは環境変数のクレデンシャルを優先的に使用し、`creds` ディレクトリ内のファイルを無視します。
|
| 530 |
+
|
| 531 |
+
### API使用方法
|
| 532 |
+
|
| 533 |
+
本サービスは複数の完全なAPIエンドポイントセットに対応しています:
|
| 534 |
+
|
| 535 |
+
#### 1. OpenAI互換エンドポイント(GCLI)
|
| 536 |
+
|
| 537 |
+
**エンドポイント:** `/v1/chat/completions`
|
| 538 |
+
**認証:** `Authorization: Bearer your_api_password`
|
| 539 |
+
|
| 540 |
+
2つのリクエストフォーマットに対応し、自動検出・処理を行います:
|
| 541 |
+
|
| 542 |
+
**OpenAIフォーマット:**
|
| 543 |
+
```json
|
| 544 |
+
{
|
| 545 |
+
"model": "gemini-2.5-pro",
|
| 546 |
+
"messages": [
|
| 547 |
+
{"role": "system", "content": "You are a helpful assistant"},
|
| 548 |
+
{"role": "user", "content": "Hello"}
|
| 549 |
+
],
|
| 550 |
+
"temperature": 0.7,
|
| 551 |
+
"stream": true
|
| 552 |
+
}
|
| 553 |
+
```
|
| 554 |
+
|
| 555 |
+
**Geminiネイティブフォーマット:**
|
| 556 |
+
```json
|
| 557 |
+
{
|
| 558 |
+
"model": "gemini-2.5-pro",
|
| 559 |
+
"contents": [
|
| 560 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 561 |
+
],
|
| 562 |
+
"systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
|
| 563 |
+
"generationConfig": {
|
| 564 |
+
"temperature": 0.7
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
```
|
| 568 |
+
|
| 569 |
+
#### 2. Geminiネイティブエンドポイント(GCLI)
|
| 570 |
+
|
| 571 |
+
**非ストリーミングエンドポイント:** `/v1/models/{model}:generateContent`
|
| 572 |
+
**ストリーミングエンドポイント:** `/v1/models/{model}:streamGenerateContent`
|
| 573 |
+
**モデル一覧:** `/v1/models`
|
| 574 |
+
|
| 575 |
+
**認証方式(いずれか1つを選択):**
|
| 576 |
+
- `Authorization: Bearer your_api_password`
|
| 577 |
+
- `x-goog-api-key: your_api_password`
|
| 578 |
+
- URLパラメータ: `?key=your_api_password`
|
| 579 |
+
|
| 580 |
+
**リクエスト例:**
|
| 581 |
+
```bash
|
| 582 |
+
# x-goog-api-keyヘッダーを使用
|
| 583 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
|
| 584 |
+
-H "x-goog-api-key: your_api_password" \
|
| 585 |
+
-H "Content-Type: application/json" \
|
| 586 |
+
-d '{
|
| 587 |
+
"contents": [
|
| 588 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 589 |
+
]
|
| 590 |
+
}'
|
| 591 |
+
|
| 592 |
+
# URLパラメータを使用
|
| 593 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
|
| 594 |
+
-H "Content-Type: application/json" \
|
| 595 |
+
-d '{
|
| 596 |
+
"contents": [
|
| 597 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 598 |
+
]
|
| 599 |
+
}'
|
| 600 |
+
```
|
| 601 |
+
|
| 602 |
+
#### 3. Claude APIフォーマットエンドポイント
|
| 603 |
+
|
| 604 |
+
**エンドポイント:** `/v1/messages`
|
| 605 |
+
**認証:** `x-api-key: your_api_password` または `Authorization: Bearer your_api_password`
|
| 606 |
+
|
| 607 |
+
**リクエスト例:**
|
| 608 |
+
```bash
|
| 609 |
+
curl -X POST "http://127.0.0.1:7861/v1/messages" \
|
| 610 |
+
-H "x-api-key: your_api_password" \
|
| 611 |
+
-H "anthropic-version: 2023-06-01" \
|
| 612 |
+
-H "Content-Type: application/json" \
|
| 613 |
+
-d '{
|
| 614 |
+
"model": "gemini-2.5-pro",
|
| 615 |
+
"max_tokens": 1024,
|
| 616 |
+
"messages": [
|
| 617 |
+
{"role": "user", "content": "Hello, Claude!"}
|
| 618 |
+
]
|
| 619 |
+
}'
|
| 620 |
+
```
|
| 621 |
+
|
| 622 |
+
**systemパラメータの対応:**
|
| 623 |
+
```json
|
| 624 |
+
{
|
| 625 |
+
"model": "gemini-2.5-pro",
|
| 626 |
+
"max_tokens": 1024,
|
| 627 |
+
"system": "You are a helpful assistant",
|
| 628 |
+
"messages": [
|
| 629 |
+
{"role": "user", "content": "Hello"}
|
| 630 |
+
]
|
| 631 |
+
}
|
| 632 |
+
```
|
| 633 |
+
|
| 634 |
+
**注意事項:**
|
| 635 |
+
- Claude APIフォーマット仕様に完全互換
|
| 636 |
+
- バックエンド呼び出し時にGeminiフォーマットへ自動変換
|
| 637 |
+
- すべてのClaude標準パラメータに対応
|
| 638 |
+
- レスポンスフォーマットはClaude API仕様に準拠
|
| 639 |
+
|
| 640 |
+
## 📋 完全なAPIリファレンス
|
| 641 |
+
|
| 642 |
+
### Webコンソール API
|
| 643 |
+
|
| 644 |
+
**認証エンドポイント**
|
| 645 |
+
- `POST /auth/login` - ユーザーログイン
|
| 646 |
+
- `POST /auth/start` - OAuth認証の開始(GCLIおよびAntigravityモード対応)
|
| 647 |
+
- `POST /auth/callback` - OAuthコールバックの処理
|
| 648 |
+
- `POST /auth/callback-url` - コールバックURLから直接認証を完了
|
| 649 |
+
- `GET /auth/status/{project_id}` - 認証ステータスの確認
|
| 650 |
+
|
| 651 |
+
**クレデンシャル管理エンドポイント**(`mode=geminicli` または `mode=antigravity` パラメータ対応)
|
| 652 |
+
- `POST /creds/upload` - クレデンシャルファイルの一括アップロード(JSONおよびZIP対応)
|
| 653 |
+
- `GET /creds/status` - クレデンシャルステータス一覧の取得(ページネーションとフィルタリング対応)
|
| 654 |
+
- `GET /creds/detail/{filename}` - 単一クレデンシャルの詳細取得
|
| 655 |
+
- `POST /creds/action` - 単一クレデンシャル操作(有効化/無効化/削除)
|
| 656 |
+
- `POST /creds/batch-action` - クレデンシャルの一括操作
|
| 657 |
+
- `GET /creds/download/{filename}` - 単一クレデンシャルファイルのダウンロード
|
| 658 |
+
- `GET /creds/download-all` - 全クレデンシャルの一括ダウンロード
|
| 659 |
+
- `POST /creds/fetch-email/{filename}` - ユーザーメールの取得
|
| 660 |
+
- `POST /creds/refresh-all-emails` - ユーザーメールの一括更新
|
| 661 |
+
- `POST /creds/deduplicate-by-email` - メールによるクレデンシャルの重複排除
|
| 662 |
+
- `POST /creds/verify-project/{filename}` - クレデンシャルのProject ID検証
|
| 663 |
+
- `GET /creds/quota/{filename}` - クレデンシャルのクォータ情報取得(Antigravityのみ)
|
| 664 |
+
|
| 665 |
+
**設定管理エンドポイント**
|
| 666 |
+
- `GET /config/get` - 現在の���定の取得
|
| 667 |
+
- `POST /config/save` - 設定の保存
|
| 668 |
+
|
| 669 |
+
**ログ管理エンドポイント**
|
| 670 |
+
- `POST /logs/clear` - ログのクリア
|
| 671 |
+
- `GET /logs/download` - ログファイルのダウンロード
|
| 672 |
+
- `WebSocket /logs/stream` - リアルタイムログストリーム
|
| 673 |
+
|
| 674 |
+
**バージョン情報エンドポイント**
|
| 675 |
+
- `GET /version/info` - バージョン情報の取得(オプション `check_update=true` パラメータで更新確認)
|
| 676 |
+
|
| 677 |
+
### チャットAPI機能
|
| 678 |
+
|
| 679 |
+
**マルチモーダル対応**
|
| 680 |
+
```json
|
| 681 |
+
{
|
| 682 |
+
"model": "gemini-2.5-pro",
|
| 683 |
+
"messages": [
|
| 684 |
+
{
|
| 685 |
+
"role": "user",
|
| 686 |
+
"content": [
|
| 687 |
+
{"type": "text", "text": "この画像を説明してください"},
|
| 688 |
+
{
|
| 689 |
+
"type": "image_url",
|
| 690 |
+
"image_url": {
|
| 691 |
+
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
]
|
| 695 |
+
}
|
| 696 |
+
]
|
| 697 |
+
}
|
| 698 |
+
```
|
| 699 |
+
|
| 700 |
+
**Thinkingモード対応**
|
| 701 |
+
```json
|
| 702 |
+
{
|
| 703 |
+
"model": "gemini-2.5-pro-high",
|
| 704 |
+
"messages": [
|
| 705 |
+
{"role": "user", "content": "複雑な数学の問題"}
|
| 706 |
+
]
|
| 707 |
+
}
|
| 708 |
+
```
|
| 709 |
+
|
| 710 |
+
レスポンスには分離された思考内容が含まれます:
|
| 711 |
+
```json
|
| 712 |
+
{
|
| 713 |
+
"choices": [{
|
| 714 |
+
"message": {
|
| 715 |
+
"role": "assistant",
|
| 716 |
+
"content": "最終回答",
|
| 717 |
+
"reasoning_content": "詳細な思考プロセス..."
|
| 718 |
+
}
|
| 719 |
+
}]
|
| 720 |
+
}
|
| 721 |
+
```
|
| 722 |
+
|
| 723 |
+
**ストリーミング途切れ防止の使用方法**
|
| 724 |
+
```json
|
| 725 |
+
{
|
| 726 |
+
"model": "流式抗截断/gemini-2.5-pro",
|
| 727 |
+
"messages": [
|
| 728 |
+
{"role": "user", "content": "長い記事を書いてください"}
|
| 729 |
+
],
|
| 730 |
+
"stream": true
|
| 731 |
+
}
|
| 732 |
+
```
|
| 733 |
+
|
| 734 |
+
**互換モード**
|
| 735 |
+
```bash
|
| 736 |
+
# 互換モードを有効化
|
| 737 |
+
export COMPATIBILITY_MODE=true
|
| 738 |
+
```
|
| 739 |
+
このモードでは、すべての `system` メッセージが `user` メッセージに変換され、特定のクライアントとの互換性が向上します。
|
| 740 |
+
|
| 741 |
+
---
|
| 742 |
+
|
| 743 |
+
## 💬 コミュニティ
|
| 744 |
+
|
| 745 |
+
QQグループへの参加をお待ちしています!
|
| 746 |
+
|
| 747 |
+
**QQグループ: 1083250744**
|
| 748 |
+
|
| 749 |
+
<img src="qq群.jpg" width="200" alt="QQグループQRコード">
|
| 750 |
+
|
| 751 |
+
---
|
| 752 |
+
|
| 753 |
+
## ライセンスと免責事項
|
| 754 |
+
|
| 755 |
+
本プロジェクトは学習および研究目的のみに使用できます。本プロジェクトの使用は、以下に同意したことを意味します:
|
| 756 |
+
- 本プロジェクトをいかなる商用目的にも使用しないこと
|
| 757 |
+
- 本プロジェクトの使用に伴うすべてのリスクと責任を負うこと
|
| 758 |
+
- 関連するサービス利用規約および法的規制を遵守すること
|
| 759 |
+
|
| 760 |
+
プロジェクトの作者は、本プロジェクトの使用から生じるいかなる直接的または間接的な損害についても責任を負いません。
|
front/common.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
front/control_panel.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
front/control_panel_mobile.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
install.ps1
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 检测是否为管理员
|
| 2 |
+
$IsElevated = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).
|
| 3 |
+
IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
| 4 |
+
|
| 5 |
+
# Skip Scoop install if already present to avoid stopping the script
|
| 6 |
+
if (Get-Command scoop -ErrorAction SilentlyContinue) {
|
| 7 |
+
Write-Host "Scoop is already installed. Skipping installation."
|
| 8 |
+
} else {
|
| 9 |
+
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
|
| 10 |
+
if ($IsElevated) {
|
| 11 |
+
# 管理员:使用官方一行命令并传入 -RunAsAdmin
|
| 12 |
+
Invoke-Expression "& {$(Invoke-RestMethod get.scoop.sh)} -RunAsAdmin"
|
| 13 |
+
} else {
|
| 14 |
+
# 普通用户安装
|
| 15 |
+
Invoke-WebRequest -useb get.scoop.sh | Invoke-Expression
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
scoop install git uv
|
| 20 |
+
if (Test-Path -LiteralPath "./web.py") {
|
| 21 |
+
# Already in target directory; skip clone and cd
|
| 22 |
+
}
|
| 23 |
+
elseif (Test-Path -LiteralPath "./gcli2api/web.py") {
|
| 24 |
+
Set-Location ./gcli2api
|
| 25 |
+
}
|
| 26 |
+
else {
|
| 27 |
+
git clone https://github.com/su-kaka/gcli2api.git
|
| 28 |
+
Set-Location ./gcli2api
|
| 29 |
+
}
|
| 30 |
+
# Create relocatable virtual environment to ensure portability
|
| 31 |
+
$env:UV_VENV_CLEAR = "1"
|
| 32 |
+
uv venv --relocatable
|
| 33 |
+
uv sync
|
| 34 |
+
.venv/Scripts/activate.ps1
|
| 35 |
+
python web.py
|
install.sh
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e # Exit on error
|
| 3 |
+
set -u # Exit on undefined variable
|
| 4 |
+
set -o pipefail # Exit on pipe failure
|
| 5 |
+
|
| 6 |
+
# Color codes for output
|
| 7 |
+
RED='\033[0;31m'
|
| 8 |
+
GREEN='\033[0;32m'
|
| 9 |
+
YELLOW='\033[1;33m'
|
| 10 |
+
BLUE='\033[0;34m'
|
| 11 |
+
NC='\033[0m' # No Color
|
| 12 |
+
|
| 13 |
+
# Logging functions
|
| 14 |
+
log_info() {
|
| 15 |
+
echo -e "${GREEN}[INFO]${NC} $1"
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
log_error() {
|
| 19 |
+
echo -e "${RED}[ERROR]${NC} $1" >&2
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
log_warn() {
|
| 23 |
+
echo -e "${YELLOW}[WARN]${NC} $1"
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
log_debug() {
|
| 27 |
+
echo -e "${BLUE}[DEBUG]${NC} $1"
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Cleanup function for error handling
|
| 31 |
+
cleanup() {
|
| 32 |
+
local exit_code=$?
|
| 33 |
+
if [ $exit_code -ne 0 ]; then
|
| 34 |
+
log_error "Installation failed with exit code $exit_code"
|
| 35 |
+
fi
|
| 36 |
+
exit $exit_code
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
trap cleanup EXIT
|
| 40 |
+
|
| 41 |
+
# Detect OS and distribution
|
| 42 |
+
detect_os() {
|
| 43 |
+
log_info "Detecting operating system..."
|
| 44 |
+
|
| 45 |
+
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
| 46 |
+
if [ -f /etc/os-release ]; then
|
| 47 |
+
. /etc/os-release
|
| 48 |
+
OS_NAME=$ID
|
| 49 |
+
OS_VERSION=$VERSION_ID
|
| 50 |
+
log_info "Detected: $NAME $VERSION_ID"
|
| 51 |
+
elif [ -f /etc/lsb-release ]; then
|
| 52 |
+
. /etc/lsb-release
|
| 53 |
+
OS_NAME=$DISTRIB_ID
|
| 54 |
+
OS_VERSION=$DISTRIB_RELEASE
|
| 55 |
+
log_info "Detected: $DISTRIB_ID $DISTRIB_RELEASE"
|
| 56 |
+
else
|
| 57 |
+
OS_NAME="linux"
|
| 58 |
+
OS_VERSION="unknown"
|
| 59 |
+
log_warn "Could not determine specific Linux distribution"
|
| 60 |
+
fi
|
| 61 |
+
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
| 62 |
+
OS_NAME="macos"
|
| 63 |
+
OS_VERSION=$(sw_vers -productVersion)
|
| 64 |
+
log_info "Detected: macOS $OS_VERSION"
|
| 65 |
+
elif [[ "$OSTYPE" == "freebsd"* ]]; then
|
| 66 |
+
OS_NAME="freebsd"
|
| 67 |
+
OS_VERSION=$(freebsd-version)
|
| 68 |
+
log_info "Detected: FreeBSD $OS_VERSION"
|
| 69 |
+
else
|
| 70 |
+
log_error "Unsupported operating system: $OSTYPE"
|
| 71 |
+
exit 1
|
| 72 |
+
fi
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# Check for root privileges (only for Linux package managers that need it)
|
| 76 |
+
check_root_if_needed() {
|
| 77 |
+
if [[ "$OS_NAME" == "ubuntu" ]] || [[ "$OS_NAME" == "debian" ]] || [[ "$OS_NAME" == "linuxmint" ]] || [[ "$OS_NAME" == "kali" ]]; then
|
| 78 |
+
if [ "$EUID" -ne 0 ]; then
|
| 79 |
+
log_error "This script requires root privileges for apt. Please run with sudo."
|
| 80 |
+
exit 1
|
| 81 |
+
fi
|
| 82 |
+
elif [[ "$OS_NAME" == "fedora" ]] || [[ "$OS_NAME" == "rhel" ]] || [[ "$OS_NAME" == "centos" ]] || [[ "$OS_NAME" == "rocky" ]] || [[ "$OS_NAME" == "almalinux" ]]; then
|
| 83 |
+
if [ "$EUID" -ne 0 ]; then
|
| 84 |
+
log_error "This script requires root privileges for dnf/yum. Please run with sudo."
|
| 85 |
+
exit 1
|
| 86 |
+
fi
|
| 87 |
+
elif [[ "$OS_NAME" == "arch" ]] || [[ "$OS_NAME" == "manjaro" ]]; then
|
| 88 |
+
if [ "$EUID" -ne 0 ]; then
|
| 89 |
+
log_error "This script requires root privileges for pacman. Please run with sudo."
|
| 90 |
+
exit 1
|
| 91 |
+
fi
|
| 92 |
+
fi
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
# Update package manager
|
| 96 |
+
update_packages() {
|
| 97 |
+
log_info "Updating package manager..."
|
| 98 |
+
|
| 99 |
+
case "$OS_NAME" in
|
| 100 |
+
ubuntu|debian|linuxmint|kali|pop)
|
| 101 |
+
if ! apt update; then
|
| 102 |
+
log_error "Failed to update apt package lists"
|
| 103 |
+
exit 1
|
| 104 |
+
fi
|
| 105 |
+
;;
|
| 106 |
+
fedora|rhel|centos|rocky|almalinux)
|
| 107 |
+
if command -v dnf &> /dev/null; then
|
| 108 |
+
if ! dnf check-update; then
|
| 109 |
+
# dnf check-update returns 100 if updates are available, which is not an error
|
| 110 |
+
if [ $? -ne 100 ]; then
|
| 111 |
+
log_warn "dnf check-update returned non-standard exit code"
|
| 112 |
+
fi
|
| 113 |
+
fi
|
| 114 |
+
else
|
| 115 |
+
if ! yum check-update; then
|
| 116 |
+
if [ $? -ne 100 ]; then
|
| 117 |
+
log_warn "yum check-update returned non-standard exit code"
|
| 118 |
+
fi
|
| 119 |
+
fi
|
| 120 |
+
fi
|
| 121 |
+
;;
|
| 122 |
+
arch|manjaro)
|
| 123 |
+
if ! pacman -Syu; then
|
| 124 |
+
log_error "Failed to update pacman database"
|
| 125 |
+
exit 1
|
| 126 |
+
fi
|
| 127 |
+
;;
|
| 128 |
+
macos)
|
| 129 |
+
if command -v brew &> /dev/null; then
|
| 130 |
+
log_info "Updating Homebrew..."
|
| 131 |
+
brew update
|
| 132 |
+
else
|
| 133 |
+
log_warn "Homebrew not installed. Skipping package manager update."
|
| 134 |
+
fi
|
| 135 |
+
;;
|
| 136 |
+
*)
|
| 137 |
+
log_warn "Unknown package manager for $OS_NAME. Skipping update."
|
| 138 |
+
;;
|
| 139 |
+
esac
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
# Install git based on OS
|
| 143 |
+
install_git() {
|
| 144 |
+
if ! command -v git &> /dev/null; then
|
| 145 |
+
log_info "Installing git..."
|
| 146 |
+
|
| 147 |
+
case "$OS_NAME" in
|
| 148 |
+
ubuntu|debian|linuxmint|kali|pop)
|
| 149 |
+
if ! apt install git -y; then
|
| 150 |
+
log_error "Failed to install git"
|
| 151 |
+
exit 1
|
| 152 |
+
fi
|
| 153 |
+
;;
|
| 154 |
+
fedora|rhel|centos|rocky|almalinux)
|
| 155 |
+
if command -v dnf &> /dev/null; then
|
| 156 |
+
if ! dnf install git -y; then
|
| 157 |
+
log_error "Failed to install git"
|
| 158 |
+
exit 1
|
| 159 |
+
fi
|
| 160 |
+
else
|
| 161 |
+
if ! yum install git -y; then
|
| 162 |
+
log_error "Failed to install git"
|
| 163 |
+
exit 1
|
| 164 |
+
fi
|
| 165 |
+
fi
|
| 166 |
+
;;
|
| 167 |
+
arch|manjaro)
|
| 168 |
+
if ! pacman -S git --noconfirm; then
|
| 169 |
+
log_error "Failed to install git"
|
| 170 |
+
exit 1
|
| 171 |
+
fi
|
| 172 |
+
;;
|
| 173 |
+
macos)
|
| 174 |
+
if command -v brew &> /dev/null; then
|
| 175 |
+
if ! brew install git; then
|
| 176 |
+
log_error "Failed to install git"
|
| 177 |
+
exit 1
|
| 178 |
+
fi
|
| 179 |
+
else
|
| 180 |
+
log_error "Homebrew is required for macOS. Install from https://brew.sh/"
|
| 181 |
+
exit 1
|
| 182 |
+
fi
|
| 183 |
+
;;
|
| 184 |
+
*)
|
| 185 |
+
log_error "Don't know how to install git on $OS_NAME"
|
| 186 |
+
exit 1
|
| 187 |
+
;;
|
| 188 |
+
esac
|
| 189 |
+
else
|
| 190 |
+
log_info "Git is already installed ($(git --version))"
|
| 191 |
+
fi
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
# Detect OS first
|
| 195 |
+
detect_os
|
| 196 |
+
|
| 197 |
+
# Check root if needed
|
| 198 |
+
check_root_if_needed
|
| 199 |
+
|
| 200 |
+
log_info "Starting installation process..."
|
| 201 |
+
|
| 202 |
+
# Update package lists
|
| 203 |
+
update_packages
|
| 204 |
+
|
| 205 |
+
# Install git
|
| 206 |
+
install_git
|
| 207 |
+
|
| 208 |
+
# Install uv if not present
|
| 209 |
+
if ! command -v uv &> /dev/null; then
|
| 210 |
+
log_info "Installing uv package manager..."
|
| 211 |
+
if ! curl -Ls https://astral.sh/uv/install.sh | sh; then
|
| 212 |
+
log_error "Failed to install uv"
|
| 213 |
+
exit 1
|
| 214 |
+
fi
|
| 215 |
+
|
| 216 |
+
# Source environment
|
| 217 |
+
if [ -f "$HOME/.local/bin/env" ]; then
|
| 218 |
+
source "$HOME/.local/bin/env"
|
| 219 |
+
elif [ -f "$HOME/.cargo/env" ]; then
|
| 220 |
+
source "$HOME/.cargo/env"
|
| 221 |
+
fi
|
| 222 |
+
|
| 223 |
+
# Verify uv installation
|
| 224 |
+
if ! command -v uv &> /dev/null; then
|
| 225 |
+
log_error "uv installation failed - command not found after install"
|
| 226 |
+
exit 1
|
| 227 |
+
fi
|
| 228 |
+
else
|
| 229 |
+
log_info "uv is already installed"
|
| 230 |
+
fi
|
| 231 |
+
|
| 232 |
+
# Determine working directory
|
| 233 |
+
log_info "Checking project directory..."
|
| 234 |
+
if [ -f "./web.py" ]; then
|
| 235 |
+
log_info "Already in target directory"
|
| 236 |
+
elif [ -f "./gcli2api/web.py" ]; then
|
| 237 |
+
log_info "Changing to gcli2api directory"
|
| 238 |
+
cd ./gcli2api || exit 1
|
| 239 |
+
else
|
| 240 |
+
log_info "Cloning repository..."
|
| 241 |
+
if [ -d "./gcli2api" ]; then
|
| 242 |
+
log_warn "gcli2api directory exists but web.py not found. Removing and re-cloning..."
|
| 243 |
+
rm -rf ./gcli2api
|
| 244 |
+
fi
|
| 245 |
+
|
| 246 |
+
if ! git clone https://github.com/su-kaka/gcli2api.git; then
|
| 247 |
+
log_error "Failed to clone repository"
|
| 248 |
+
exit 1
|
| 249 |
+
fi
|
| 250 |
+
|
| 251 |
+
cd ./gcli2api || exit 1
|
| 252 |
+
fi
|
| 253 |
+
|
| 254 |
+
# Update repository if it's a git repo
|
| 255 |
+
if [ -d ".git" ]; then
|
| 256 |
+
log_info "Updating repository..."
|
| 257 |
+
if ! git pull; then
|
| 258 |
+
log_warn "Git pull failed, continuing anyway..."
|
| 259 |
+
fi
|
| 260 |
+
else
|
| 261 |
+
log_warn "Not a git repository, skipping update"
|
| 262 |
+
fi
|
| 263 |
+
|
| 264 |
+
# Create relocatable virtual environment to ensure portability
|
| 265 |
+
log_info "Creating relocatable virtual environment..."
|
| 266 |
+
export UV_VENV_CLEAR=1
|
| 267 |
+
if ! uv venv --relocatable; then
|
| 268 |
+
log_error "Failed to create virtual environment"
|
| 269 |
+
exit 1
|
| 270 |
+
fi
|
| 271 |
+
|
| 272 |
+
# Sync dependencies
|
| 273 |
+
log_info "Syncing dependencies with uv..."
|
| 274 |
+
if ! uv sync; then
|
| 275 |
+
log_error "Failed to sync dependencies"
|
| 276 |
+
exit 1
|
| 277 |
+
fi
|
| 278 |
+
|
| 279 |
+
# Activate virtual environment
|
| 280 |
+
log_info "Activating virtual environment..."
|
| 281 |
+
if [ -f ".venv/bin/activate" ]; then
|
| 282 |
+
source .venv/bin/activate
|
| 283 |
+
else
|
| 284 |
+
log_error "Virtual environment not found at .venv/bin/activate"
|
| 285 |
+
exit 1
|
| 286 |
+
fi
|
| 287 |
+
|
| 288 |
+
# Verify Python is available
|
| 289 |
+
if ! command -v python3 &> /dev/null; then
|
| 290 |
+
log_error "python3 not found in virtual environment"
|
| 291 |
+
exit 1
|
| 292 |
+
fi
|
| 293 |
+
|
| 294 |
+
# Check if web.py exists
|
| 295 |
+
if [ ! -f "web.py" ]; then
|
| 296 |
+
log_error "web.py not found in current directory"
|
| 297 |
+
exit 1
|
| 298 |
+
fi
|
| 299 |
+
|
| 300 |
+
# Start the application
|
| 301 |
+
log_info "Starting application..."
|
| 302 |
+
python3 web.py
|
log.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
日志模块 - 使用环境变量配置
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
import threading
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from collections import deque
|
| 10 |
+
import atexit
|
| 11 |
+
|
| 12 |
+
# 日志级别定义
|
| 13 |
+
LOG_LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3, "critical": 4}
|
| 14 |
+
|
| 15 |
+
# 文件写入状态标志(仅由 writer 线程修改,无需锁保护)
|
| 16 |
+
_file_writing_disabled = False
|
| 17 |
+
_disable_reason = None
|
| 18 |
+
|
| 19 |
+
# 全局文件句柄(仅由 writer 线程访问,无需文件锁)
|
| 20 |
+
_log_file_handle = None
|
| 21 |
+
|
| 22 |
+
# -----------------------------------------------------------------
|
| 23 |
+
# 高性能无锁队列:用 deque + Condition 替代 Queue
|
| 24 |
+
# deque.append / deque.popleft 在 CPython 中受 GIL 保护,是原子操作,
|
| 25 |
+
# 不需要额外的 Lock 做入队保护,只用 Condition 做"有数据"通知。
|
| 26 |
+
# -----------------------------------------------------------------
|
| 27 |
+
_log_deque: deque = deque()
|
| 28 |
+
_deque_condition = threading.Condition(threading.Lock())
|
| 29 |
+
_writer_thread = None
|
| 30 |
+
_writer_running = False
|
| 31 |
+
|
| 32 |
+
# -----------------------------------------------------------------
|
| 33 |
+
# 缓存日志级别,避免每次都读 os.getenv(高并发热路径)
|
| 34 |
+
# -----------------------------------------------------------------
|
| 35 |
+
_cached_log_level: int = LOG_LEVELS["info"]
|
| 36 |
+
_cached_log_file: str = "log.txt"
|
| 37 |
+
# ENABLE_LOG=0/false/no/off 时彻底关闭日志
|
| 38 |
+
_log_enabled: bool = True
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _refresh_config():
|
| 42 |
+
"""从环境变量刷新缓存配置(模块加载时及需要时调用)"""
|
| 43 |
+
global _cached_log_level, _cached_log_file, _log_enabled
|
| 44 |
+
level = os.getenv("LOG_LEVEL", "info").lower()
|
| 45 |
+
_cached_log_level = LOG_LEVELS.get(level, LOG_LEVELS["info"])
|
| 46 |
+
_cached_log_file = os.getenv("LOG_FILE", "log.txt")
|
| 47 |
+
_log_enabled = os.getenv("ENABLE_LOG", "1").strip().lower() not in ("0", "false", "no", "off")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _get_current_log_level() -> int:
|
| 51 |
+
return _cached_log_level
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _get_log_file_path() -> str:
|
| 55 |
+
return _cached_log_file
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# -----------------------------------------------------------------
|
| 59 |
+
# 文件句柄管理(仅在 writer 线程内调用,不需要 _file_lock)
|
| 60 |
+
# -----------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def _close_log_file():
|
| 63 |
+
global _log_file_handle
|
| 64 |
+
if _log_file_handle is not None:
|
| 65 |
+
try:
|
| 66 |
+
_log_file_handle.flush()
|
| 67 |
+
_log_file_handle.close()
|
| 68 |
+
except Exception:
|
| 69 |
+
pass
|
| 70 |
+
finally:
|
| 71 |
+
_log_file_handle = None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _open_log_file(mode: str = "a") -> bool:
|
| 75 |
+
global _log_file_handle, _file_writing_disabled, _disable_reason
|
| 76 |
+
_close_log_file()
|
| 77 |
+
try:
|
| 78 |
+
# 使用较大缓冲区(64 KB),由 writer 线程定期 flush,减少系统调用
|
| 79 |
+
_log_file_handle = open(_cached_log_file, mode, encoding="utf-8", buffering=65536)
|
| 80 |
+
return True
|
| 81 |
+
except (PermissionError, OSError, IOError) as e:
|
| 82 |
+
_file_writing_disabled = True
|
| 83 |
+
_disable_reason = str(e)
|
| 84 |
+
print(f"Warning: Cannot open log file, disabling file writing: {e}", file=sys.stderr)
|
| 85 |
+
print("Log messages will continue to display in console only.", file=sys.stderr)
|
| 86 |
+
return False
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"Warning: Failed to open log file: {e}", file=sys.stderr)
|
| 89 |
+
return False
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _clear_log_file():
|
| 93 |
+
"""清空日志文件(启动时调用,此时 writer 线程尚未启动,直接操作安全)"""
|
| 94 |
+
global _file_writing_disabled, _disable_reason
|
| 95 |
+
try:
|
| 96 |
+
with open(_cached_log_file, "w", encoding="utf-8") as f:
|
| 97 |
+
pass # 覆盖清空
|
| 98 |
+
_open_log_file("a")
|
| 99 |
+
except (PermissionError, OSError, IOError) as e:
|
| 100 |
+
_file_writing_disabled = True
|
| 101 |
+
_disable_reason = str(e)
|
| 102 |
+
print(
|
| 103 |
+
f"Warning: File system appears to be read-only or permission denied. "
|
| 104 |
+
f"Disabling log file writing: {e}",
|
| 105 |
+
file=sys.stderr,
|
| 106 |
+
)
|
| 107 |
+
print("Log messages will continue to display in console only.", file=sys.stderr)
|
| 108 |
+
except Exception as e:
|
| 109 |
+
print(f"Warning: Failed to clear log file: {e}", file=sys.stderr)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# -----------------------------------------------------------------
|
| 113 |
+
# Writer 线程:批量从 deque 取出并写入,减少系统调用次数
|
| 114 |
+
# -----------------------------------------------------------------
|
| 115 |
+
_BATCH_SIZE = 1000 # 单次最多批量写入条数
|
| 116 |
+
_FLUSH_INTERVAL = 2 # 秒:无新消息时强制 flush 周期
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _log_writer_worker():
|
| 120 |
+
global _writer_running
|
| 121 |
+
|
| 122 |
+
last_flush_time = 0.0
|
| 123 |
+
|
| 124 |
+
while True:
|
| 125 |
+
# 等待数据或超时
|
| 126 |
+
with _deque_condition:
|
| 127 |
+
if not _log_deque and _writer_running:
|
| 128 |
+
_deque_condition.wait(timeout=_FLUSH_INTERVAL)
|
| 129 |
+
|
| 130 |
+
# 批量取出
|
| 131 |
+
batch = []
|
| 132 |
+
for _ in range(_BATCH_SIZE):
|
| 133 |
+
if _log_deque:
|
| 134 |
+
batch.append(_log_deque.popleft())
|
| 135 |
+
else:
|
| 136 |
+
break
|
| 137 |
+
|
| 138 |
+
if batch and not _file_writing_disabled:
|
| 139 |
+
# 一次 write 调用��定整批,最大化减少系统调用
|
| 140 |
+
chunk = "\n".join(batch) + "\n"
|
| 141 |
+
try:
|
| 142 |
+
if _log_file_handle is None:
|
| 143 |
+
_open_log_file("a")
|
| 144 |
+
if _log_file_handle is not None:
|
| 145 |
+
_log_file_handle.write(chunk)
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"Warning: Failed to write log batch: {e}", file=sys.stderr)
|
| 148 |
+
_close_log_file()
|
| 149 |
+
try:
|
| 150 |
+
_open_log_file("a")
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
|
| 154 |
+
# 定时 flush
|
| 155 |
+
now = _now_ts()
|
| 156 |
+
if now - last_flush_time >= _FLUSH_INTERVAL:
|
| 157 |
+
if _log_file_handle is not None:
|
| 158 |
+
try:
|
| 159 |
+
_log_file_handle.flush()
|
| 160 |
+
except Exception:
|
| 161 |
+
pass
|
| 162 |
+
last_flush_time = now
|
| 163 |
+
|
| 164 |
+
# 退出条件:已停止 + deque 已清空
|
| 165 |
+
if not _writer_running and not _log_deque:
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
# 最终 flush & close
|
| 169 |
+
if _log_file_handle is not None:
|
| 170 |
+
try:
|
| 171 |
+
_log_file_handle.flush()
|
| 172 |
+
except Exception:
|
| 173 |
+
pass
|
| 174 |
+
_close_log_file()
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _now_ts() -> float:
|
| 178 |
+
import time
|
| 179 |
+
return time.monotonic()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _start_writer_thread():
|
| 183 |
+
global _writer_thread, _writer_running
|
| 184 |
+
|
| 185 |
+
if _writer_thread is None or not _writer_thread.is_alive():
|
| 186 |
+
_writer_running = True
|
| 187 |
+
_writer_thread = threading.Thread(target=_log_writer_worker, daemon=True, name="LogWriter")
|
| 188 |
+
_writer_thread.start()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _stop_writer_thread():
|
| 192 |
+
global _writer_running
|
| 193 |
+
|
| 194 |
+
_writer_running = False
|
| 195 |
+
# 唤醒 writer 线程让它能感知退出信号
|
| 196 |
+
with _deque_condition:
|
| 197 |
+
_deque_condition.notify_all()
|
| 198 |
+
|
| 199 |
+
if _writer_thread and _writer_thread.is_alive():
|
| 200 |
+
_writer_thread.join(timeout=3.0)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# -----------------------------------------------------------------
|
| 204 |
+
# 入队(热路径,极轻量)
|
| 205 |
+
# -----------------------------------------------------------------
|
| 206 |
+
_MAX_QUEUE_SIZE = 5000 # 防止极端情况内存无限增长
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _write_to_file(message: str):
|
| 210 |
+
if _file_writing_disabled:
|
| 211 |
+
return
|
| 212 |
+
# deque.append 在 CPython 受 GIL 保护,无需额外锁
|
| 213 |
+
if len(_log_deque) >= _MAX_QUEUE_SIZE:
|
| 214 |
+
return # 过载保护:丢弃而非阻塞
|
| 215 |
+
_log_deque.append(message)
|
| 216 |
+
# 非阻塞通知 writer(acquire 失败直接跳过,不影响主线程)
|
| 217 |
+
if _deque_condition.acquire(blocking=False):
|
| 218 |
+
try:
|
| 219 |
+
_deque_condition.notify()
|
| 220 |
+
finally:
|
| 221 |
+
_deque_condition.release()
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# -----------------------------------------------------------------
|
| 225 |
+
# 核心日志函数(热路径)
|
| 226 |
+
# -----------------------------------------------------------------
|
| 227 |
+
|
| 228 |
+
def _log(level: str, message: str):
|
| 229 |
+
# 最快短路:日志整体已禁用时直接返回,零开销
|
| 230 |
+
if not _log_enabled:
|
| 231 |
+
return
|
| 232 |
+
|
| 233 |
+
level = level.lower()
|
| 234 |
+
level_val = LOG_LEVELS.get(level)
|
| 235 |
+
if level_val is None:
|
| 236 |
+
print(f"Warning: Unknown log level '{level}'", file=sys.stderr)
|
| 237 |
+
return
|
| 238 |
+
|
| 239 |
+
# 热路径:直接与缓存值比较,无函数调用开销
|
| 240 |
+
if level_val < _cached_log_level:
|
| 241 |
+
return
|
| 242 |
+
|
| 243 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 244 |
+
entry = f"[{timestamp}] [{level.upper()}] {message}"
|
| 245 |
+
|
| 246 |
+
if level in ("error", "critical"):
|
| 247 |
+
print(entry, file=sys.stderr)
|
| 248 |
+
else:
|
| 249 |
+
print(entry)
|
| 250 |
+
|
| 251 |
+
_write_to_file(entry)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def set_log_level(level: str):
|
| 255 |
+
"""动态设置日志级别(同时更新缓存)"""
|
| 256 |
+
global _cached_log_level
|
| 257 |
+
level = level.lower()
|
| 258 |
+
if level not in LOG_LEVELS:
|
| 259 |
+
print(f"Warning: Unknown log level '{level}'. Valid levels: {', '.join(LOG_LEVELS.keys())}")
|
| 260 |
+
return False
|
| 261 |
+
_cached_log_level = LOG_LEVELS[level]
|
| 262 |
+
return True
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class Logger:
|
| 266 |
+
"""支持 log('info', 'msg') 和 log.info('msg') 两种调用方式"""
|
| 267 |
+
|
| 268 |
+
def __call__(self, level: str, message: str):
|
| 269 |
+
_log(level, message)
|
| 270 |
+
|
| 271 |
+
def debug(self, message: str):
|
| 272 |
+
_log("debug", message)
|
| 273 |
+
|
| 274 |
+
def info(self, message: str):
|
| 275 |
+
_log("info", message)
|
| 276 |
+
|
| 277 |
+
def warning(self, message: str):
|
| 278 |
+
_log("warning", message)
|
| 279 |
+
|
| 280 |
+
def error(self, message: str):
|
| 281 |
+
_log("error", message)
|
| 282 |
+
|
| 283 |
+
def critical(self, message: str):
|
| 284 |
+
_log("critical", message)
|
| 285 |
+
|
| 286 |
+
def get_current_level(self) -> str:
|
| 287 |
+
current_level = _get_current_log_level()
|
| 288 |
+
for name, value in LOG_LEVELS.items():
|
| 289 |
+
if value == current_level:
|
| 290 |
+
return name
|
| 291 |
+
return "info"
|
| 292 |
+
|
| 293 |
+
def get_log_file(self) -> str:
|
| 294 |
+
return _get_log_file_path()
|
| 295 |
+
|
| 296 |
+
def close(self):
|
| 297 |
+
"""手动关闭(优雅退出用)"""
|
| 298 |
+
_stop_writer_thread()
|
| 299 |
+
|
| 300 |
+
def get_queue_size(self) -> int:
|
| 301 |
+
return len(_log_deque)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# 导出全局日志实例
|
| 305 |
+
log = Logger()
|
| 306 |
+
|
| 307 |
+
# 导出的公共接口
|
| 308 |
+
__all__ = ["log", "set_log_level", "LOG_LEVELS"]
|
| 309 |
+
|
| 310 |
+
# 模块加载时:读取配置���存 → 清空日志文件 → 启动 writer 线程
|
| 311 |
+
_refresh_config()
|
| 312 |
+
if _log_enabled:
|
| 313 |
+
_clear_log_file()
|
| 314 |
+
_start_writer_thread()
|
| 315 |
+
|
| 316 |
+
# 注册退出清理
|
| 317 |
+
atexit.register(_stop_writer_thread)
|
| 318 |
+
|
| 319 |
+
# 使用说明:
|
| 320 |
+
# 1. 设置日志级别: export LOG_LEVEL=debug (或在 .env 中设置)
|
| 321 |
+
# 2. 设置日志文件: export LOG_FILE=log.txt (或在 .env 中设置)
|
| 322 |
+
# 3. 日志级别已缓存,热路径零 os.getenv 调用
|
| 323 |
+
# 4. 写入线程批量处理(最多 200 条/次),64 KB 缓冲区,每 0.5 s flush 一次
|
| 324 |
+
# 5. 队列上限 5000 条,超出时丢弃新日志(过载保护,不阻塞主线程)
|
| 325 |
+
# 6. 动态调整级别:set_log_level('debug') 立即生效
|
| 326 |
+
# 7. 彻底关闭日志(最高性能):export ENABLE_LOG=0 (或 false/no/off)
|
| 327 |
+
# 关闭后不会启动 writer 线程、不写文件、不打印控制台,_log 直接 return
|
pyproject.toml
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "gcli2api"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Convert GeminiCLI to OpenAI and Gemini API interfaces"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12"
|
| 7 |
+
license = {text = "CNC-1.0"}
|
| 8 |
+
authors = [
|
| 9 |
+
{name = "su-kaka"}
|
| 10 |
+
]
|
| 11 |
+
keywords = ["gemini", "openai", "api", "converter", "cli"]
|
| 12 |
+
classifiers = [
|
| 13 |
+
"Development Status :: 4 - Beta",
|
| 14 |
+
"Intended Audience :: Developers",
|
| 15 |
+
"License :: Other/Proprietary License",
|
| 16 |
+
"Programming Language :: Python :: 3",
|
| 17 |
+
"Programming Language :: Python :: 3.13",
|
| 18 |
+
]
|
| 19 |
+
dependencies = [
|
| 20 |
+
"aiofiles>=24.1.0",
|
| 21 |
+
"fastapi>=0.116.1",
|
| 22 |
+
"httpx[socks]>=0.28.1",
|
| 23 |
+
"hypercorn>=0.17.3",
|
| 24 |
+
"motor>=3.7.1",
|
| 25 |
+
"oauthlib>=3.3.1",
|
| 26 |
+
"pydantic>=2.11.7",
|
| 27 |
+
"pyjwt>=2.10.1",
|
| 28 |
+
"python-dotenv>=1.1.1",
|
| 29 |
+
"python-multipart>=0.0.20",
|
| 30 |
+
"pypinyin>=0.51.0",
|
| 31 |
+
"aiosqlite>=0.20.0",
|
| 32 |
+
"redis>=7.2.0",
|
| 33 |
+
"asyncpg>=0.31.0",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
[project.optional-dependencies]
|
| 37 |
+
dev = [
|
| 38 |
+
"pytest>=8.0.0",
|
| 39 |
+
"pytest-asyncio>=0.23.0",
|
| 40 |
+
"pytest-cov>=4.1.0",
|
| 41 |
+
"black>=24.0.0",
|
| 42 |
+
"flake8>=7.0.0",
|
| 43 |
+
"mypy>=1.8.0",
|
| 44 |
+
"pre-commit>=3.6.0",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
[tool.pytest.ini_options]
|
| 48 |
+
minversion = "8.0"
|
| 49 |
+
testpaths = ["."]
|
| 50 |
+
python_files = ["test_*.py"]
|
| 51 |
+
python_classes = ["Test*"]
|
| 52 |
+
python_functions = ["test_*"]
|
| 53 |
+
asyncio_mode = "auto"
|
| 54 |
+
addopts = [
|
| 55 |
+
"-v",
|
| 56 |
+
"--strict-markers",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
[tool.black]
|
| 60 |
+
line-length = 100
|
| 61 |
+
target-version = ["py313"]
|
| 62 |
+
include = '\.pyi?$'
|
| 63 |
+
extend-exclude = '''
|
| 64 |
+
/(
|
| 65 |
+
# directories
|
| 66 |
+
\.eggs
|
| 67 |
+
| \.git
|
| 68 |
+
| \.hg
|
| 69 |
+
| \.mypy_cache
|
| 70 |
+
| \.tox
|
| 71 |
+
| \.venv
|
| 72 |
+
| build
|
| 73 |
+
| dist
|
| 74 |
+
)/
|
| 75 |
+
'''
|
| 76 |
+
|
| 77 |
+
[tool.mypy]
|
| 78 |
+
python_version = "3.13"
|
| 79 |
+
warn_return_any = true
|
| 80 |
+
warn_unused_configs = true
|
| 81 |
+
disallow_untyped_defs = false
|
| 82 |
+
ignore_missing_imports = true
|
| 83 |
+
exclude = [
|
| 84 |
+
"build",
|
| 85 |
+
"dist",
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
[tool.coverage.run]
|
| 89 |
+
source = ["src"]
|
| 90 |
+
omit = [
|
| 91 |
+
"*/tests/*",
|
| 92 |
+
"*/test_*.py",
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
[tool.coverage.report]
|
| 96 |
+
exclude_lines = [
|
| 97 |
+
"pragma: no cover",
|
| 98 |
+
"def __repr__",
|
| 99 |
+
"raise AssertionError",
|
| 100 |
+
"raise NotImplementedError",
|
| 101 |
+
"if __name__ == .__main__.:",
|
| 102 |
+
"if TYPE_CHECKING:",
|
| 103 |
+
]
|
render.yaml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
- type: web
|
| 3 |
+
name: gcli2api
|
| 4 |
+
runtime: docker
|
| 5 |
+
dockerfilePath: ./Dockerfile
|
| 6 |
+
dockerContext: .
|
| 7 |
+
plan: free
|
| 8 |
+
region: singapore
|
| 9 |
+
healthCheckPath: /
|
| 10 |
+
|
| 11 |
+
envVars:
|
| 12 |
+
# ========== 必填:访问密码 ==========
|
| 13 |
+
- key: PASSWORD
|
| 14 |
+
sync: false # 部署时手动填写,不同步到代码库
|
| 15 |
+
|
| 16 |
+
# ========== 服务器配置 ==========
|
| 17 |
+
- key: HOST
|
| 18 |
+
value: 0.0.0.0
|
| 19 |
+
- key: PORT
|
| 20 |
+
value: "10000" # Render 要求 Web 服务监听 10000 端口
|
requirements-termux.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
httpx[socks]
|
| 3 |
+
pydantic==1.10.22
|
| 4 |
+
python-dotenv
|
| 5 |
+
hypercorn
|
| 6 |
+
aiofiles
|
| 7 |
+
python-multipart
|
| 8 |
+
PyJWT
|
| 9 |
+
oauthlib
|
| 10 |
+
motor
|
| 11 |
+
pypinyin
|
| 12 |
+
aiosqlite
|
| 13 |
+
redis
|
| 14 |
+
asyncpg
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.116.1
|
| 2 |
+
httpx[socks]>=0.28.1
|
| 3 |
+
pydantic>=2.11.7
|
| 4 |
+
python-dotenv>=1.1.1
|
| 5 |
+
hypercorn>=0.17.3
|
| 6 |
+
aiofiles>=24.1.0
|
| 7 |
+
python-multipart>=0.0.20
|
| 8 |
+
PyJWT>=2.10.1
|
| 9 |
+
oauthlib>=3.3.1
|
| 10 |
+
motor>=3.7.1
|
| 11 |
+
aiosqlite>=0.20.0
|
| 12 |
+
pypinyin>=0.51.0
|
| 13 |
+
redis>=4.2.0
|
| 14 |
+
asyncpg
|
src/api/Response_example.txt
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
================================================================================
|
| 2 |
+
GeminiCli API 测试
|
| 3 |
+
================================================================================
|
| 4 |
+
|
| 5 |
+
================================================================================
|
| 6 |
+
【测试1】流式请求 (stream_request with native=False)
|
| 7 |
+
================================================================================
|
| 8 |
+
请求体: {
|
| 9 |
+
"model": "gemini-2.5-flash",
|
| 10 |
+
"request": {
|
| 11 |
+
"contents": [
|
| 12 |
+
{
|
| 13 |
+
"role": "user",
|
| 14 |
+
"parts": [
|
| 15 |
+
{
|
| 16 |
+
"text": "Hello, tell me a joke in one sentence."
|
| 17 |
+
}
|
| 18 |
+
]
|
| 19 |
+
}
|
| 20 |
+
]
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
流式响应数据 (每个chunk):
|
| 25 |
+
--------------------------------------------------------------------------------
|
| 26 |
+
[2026-01-10 09:55:29] [INFO] SQLite storage initialized at ./creds\credentials.db
|
| 27 |
+
[2026-01-10 09:55:29] [INFO] Using SQLite storage backend
|
| 28 |
+
[2026-01-10 09:55:31] [INFO] Token刷新成 功并已保存: my-project-9-481103-1765596755.json (mode=geminicli)
|
| 29 |
+
[2026-01-10 09:55:34] [INFO] [DB] 准备commit,总更新行数=1
|
| 30 |
+
[2026-01-10 09:55:34] [INFO] [DB] commit 完成
|
| 31 |
+
[2026-01-10 09:55:34] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1
|
| 32 |
+
|
| 33 |
+
Chunk #1:
|
| 34 |
+
类型: str
|
| 35 |
+
长度: 626
|
| 36 |
+
内容预览: 'data: {"response": {"candidates": [{"content": {"role": "model","parts": [{"text": "Why did the scarecrow win an award? Because he was outstanding in his field."}]},"finishReason": "STOP"}],"usageMeta'
|
| 37 |
+
解析后的JSON: {
|
| 38 |
+
"response": {
|
| 39 |
+
"candidates": [
|
| 40 |
+
{
|
| 41 |
+
"content": {
|
| 42 |
+
"role": "model",
|
| 43 |
+
"parts": [
|
| 44 |
+
{
|
| 45 |
+
"text": "Why did the scarecrow win an award? Because he was outstanding in his field."
|
| 46 |
+
}
|
| 47 |
+
]
|
| 48 |
+
},
|
| 49 |
+
"finishReason": "STOP"
|
| 50 |
+
}
|
| 51 |
+
],
|
| 52 |
+
"usageMetadata": {
|
| 53 |
+
"promptTokenCount": 10,
|
| 54 |
+
"candidatesTokenCount": 17,
|
| 55 |
+
"totalTokenCount": 51,
|
| 56 |
+
"trafficType": "PROVISIONED_THROUGHPUT",
|
| 57 |
+
"promptTokensDetails": [
|
| 58 |
+
{
|
| 59 |
+
"modality": "TEXT",
|
| 60 |
+
"tokenCount": 10
|
| 61 |
+
}
|
| 62 |
+
],
|
| 63 |
+
"candidatesTokensDetails": [
|
| 64 |
+
{
|
| 65 |
+
"modality": "TEXT",
|
| 66 |
+
"tokenCount": 17
|
| 67 |
+
}
|
| 68 |
+
],
|
| 69 |
+
"thoughtsTokenCount": 24
|
| 70 |
+
},
|
| 71 |
+
"modelVersion": "gemini-2.5-flash",
|
| 72 |
+
"createTime": "2026-01-10T01:55:29.168589Z",
|
| 73 |
+
"responseId": "kbFhaY2lCr-ZseMPqMiDmAU"
|
| 74 |
+
},
|
| 75 |
+
"traceId": "55650653afd3c738"
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
Chunk #2:
|
| 79 |
+
类型: str
|
| 80 |
+
长度: 0
|
| 81 |
+
内容预览: ''
|
| 82 |
+
E:\projects\gcli2api\src\api\geminicli.py:491: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited
|
| 83 |
+
async for chunk in stream_request(body=test_body, native=False):
|
| 84 |
+
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
|
| 85 |
+
|
| 86 |
+
总共收到 2 个chunk
|
| 87 |
+
|
| 88 |
+
================================================================================
|
| 89 |
+
【测试2】非流式请求 (non_stream_request)
|
| 90 |
+
================================================================================
|
| 91 |
+
请求体: {
|
| 92 |
+
"model": "gemini-2.5-flash",
|
| 93 |
+
"request": {
|
| 94 |
+
"contents": [
|
| 95 |
+
{
|
| 96 |
+
"role": "user",
|
| 97 |
+
"parts": [
|
| 98 |
+
{
|
| 99 |
+
"text": "Hello, tell me a joke in one sentence."
|
| 100 |
+
}
|
| 101 |
+
]
|
| 102 |
+
}
|
| 103 |
+
]
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
[2026-01-10 09:55:35] [INFO] Token刷新成 功并已保存: gen-lang-client-0194852792-1767296759.json (mode=geminicli)
|
| 108 |
+
[2026-01-10 09:55:38] [INFO] [DB] 准备commit,总更新行数=1
|
| 109 |
+
[2026-01-10 09:55:38] [INFO] [DB] commit 完成
|
| 110 |
+
[2026-01-10 09:55:38] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1
|
| 111 |
+
E:\projects\gcli2api\src\api\geminicli.py:530: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited
|
| 112 |
+
response = await non_stream_request(body=test_body)
|
| 113 |
+
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
|
| 114 |
+
非流式响应数据:
|
| 115 |
+
--------------------------------------------------------------------------------
|
| 116 |
+
状态码: 200
|
| 117 |
+
Content-Type: application/json; charset=UTF-8
|
| 118 |
+
|
| 119 |
+
响应头: {'server': 'openresty', 'date': 'Sat, 10 Jan 2026 01:55:34 GMT', 'content-type': 'application/json; charset=UTF-8', 'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'x-cloudaicompanion-trace-id': 'bf3a5eb6636774d2', 'vary': 'Origin, X-Origin, Referer', 'content-encoding': 'gzip', 'x-xss-protection': '0', 'x-frame-options': 'SAMEORIGIN', 'x-content-type-options': 'nosniff', 'server-timing': 'gfet4t7; dur=1377', 'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'access-control-allow-headers': 'Content-Type, Authorization, X-Requested-With', 'cache-control': 'no-cache', 'content-length': '969'}
|
| 120 |
+
|
| 121 |
+
响应内容 (原始):
|
| 122 |
+
{
|
| 123 |
+
"response": {
|
| 124 |
+
"candidates": [
|
| 125 |
+
{
|
| 126 |
+
"content": {
|
| 127 |
+
"role": "model",
|
| 128 |
+
"parts": [
|
| 129 |
+
{
|
| 130 |
+
"text": "Why did the scarecrow win an award? Because he was outstanding in his field!"
|
| 131 |
+
}
|
| 132 |
+
]
|
| 133 |
+
},
|
| 134 |
+
"finishReason": "STOP",
|
| 135 |
+
"avgLogprobs": -0.54438119776108684
|
| 136 |
+
}
|
| 137 |
+
],
|
| 138 |
+
"usageMetadata": {
|
| 139 |
+
"promptTokenCount": 10,
|
| 140 |
+
"candidatesTokenCount": 17,
|
| 141 |
+
"totalTokenCount": 47,
|
| 142 |
+
"trafficType": "PROVISIONED_THROUGHPUT",
|
| 143 |
+
"promptTokensDetails": [
|
| 144 |
+
{
|
| 145 |
+
"modality": "TEXT",
|
| 146 |
+
"tokenCount": 10
|
| 147 |
+
}
|
| 148 |
+
],
|
| 149 |
+
"candidatesTokensDetails": [
|
| 150 |
+
{
|
| 151 |
+
"modality": "TEXT",
|
| 152 |
+
"tokenCount": 17
|
| 153 |
+
}
|
| 154 |
+
],
|
| 155 |
+
"thoughtsTokenCount": 20
|
| 156 |
+
},
|
| 157 |
+
"modelVersion": "gemini-2.5-flash",
|
| 158 |
+
"createTime": "2026-01-10T01:55:33.450396Z",
|
| 159 |
+
"responseId": "lbFhady-G7yi694PmLOP4As"
|
| 160 |
+
},
|
| 161 |
+
"traceId": "bf3a5eb6636774d2"
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
响应内容 (格式化JSON):
|
| 166 |
+
{
|
| 167 |
+
"response": {
|
| 168 |
+
"candidates": [
|
| 169 |
+
{
|
| 170 |
+
"content": {
|
| 171 |
+
"role": "model",
|
| 172 |
+
"parts": [
|
| 173 |
+
{
|
| 174 |
+
"text": "Why did the scarecrow win an award? Because he was outstanding in his field!"
|
| 175 |
+
}
|
| 176 |
+
]
|
| 177 |
+
},
|
| 178 |
+
"finishReason": "STOP",
|
| 179 |
+
"avgLogprobs": -0.5443811977610868
|
| 180 |
+
}
|
| 181 |
+
],
|
| 182 |
+
"usageMetadata": {
|
| 183 |
+
"promptTokenCount": 10,
|
| 184 |
+
"candidatesTokenCount": 17,
|
| 185 |
+
"totalTokenCount": 47,
|
| 186 |
+
"trafficType": "PROVISIONED_THROUGHPUT",
|
| 187 |
+
"promptTokensDetails": [
|
| 188 |
+
{
|
| 189 |
+
"modality": "TEXT",
|
| 190 |
+
"tokenCount": 10
|
| 191 |
+
}
|
| 192 |
+
],
|
| 193 |
+
"candidatesTokensDetails": [
|
| 194 |
+
{
|
| 195 |
+
"modality": "TEXT",
|
| 196 |
+
"tokenCount": 17
|
| 197 |
+
}
|
| 198 |
+
],
|
| 199 |
+
"thoughtsTokenCount": 20
|
| 200 |
+
},
|
| 201 |
+
"modelVersion": "gemini-2.5-flash",
|
| 202 |
+
"createTime": "2026-01-10T01:55:33.450396Z",
|
| 203 |
+
"responseId": "lbFhady-G7yi694PmLOP4As"
|
| 204 |
+
},
|
| 205 |
+
"traceId": "bf3a5eb6636774d2"
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
================================================================================
|
| 209 |
+
测试完成
|
| 210 |
+
================================================================================
|
src/api/antigravity.py
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Antigravity API Client - Handles communication with Google's Antigravity API
|
| 3 |
+
处理与 Google Antigravity API 的通信
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from typing import Any, Dict, List, Optional, Callable, Tuple
|
| 11 |
+
|
| 12 |
+
from fastapi import Response
|
| 13 |
+
from config import (
|
| 14 |
+
get_code_assist_endpoint,
|
| 15 |
+
get_antigravity_stream2nostream,
|
| 16 |
+
get_auto_ban_error_codes,
|
| 17 |
+
)
|
| 18 |
+
from log import log
|
| 19 |
+
|
| 20 |
+
from src.credential_manager import credential_manager
|
| 21 |
+
from src.httpx_client import stream_post_async, post_async
|
| 22 |
+
from src.models import Model, model_to_dict
|
| 23 |
+
from src.utils import ANTIGRAVITY_USER_AGENT
|
| 24 |
+
|
| 25 |
+
# 导入共同的基础功能
|
| 26 |
+
from src.api.utils import (
|
| 27 |
+
handle_error_with_retry,
|
| 28 |
+
get_retry_config,
|
| 29 |
+
record_api_call_success,
|
| 30 |
+
record_api_call_error,
|
| 31 |
+
parse_and_log_cooldown,
|
| 32 |
+
collect_streaming_response,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# ==================== 全局凭证管理器 ====================
|
| 36 |
+
|
| 37 |
+
# 使用全局单例 credential_manager,自动初始化
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ==================== 辅助函数 ====================
|
| 41 |
+
|
| 42 |
+
def build_antigravity_headers(access_token: str, model_name: str = "") -> Dict[str, str]:
|
| 43 |
+
"""
|
| 44 |
+
构建 Antigravity API 请求头
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
access_token: 访问令牌
|
| 48 |
+
model_name: 模型名称,用于判断 request_type
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
请求头字典
|
| 52 |
+
"""
|
| 53 |
+
headers = {
|
| 54 |
+
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
| 55 |
+
'Authorization': f'Bearer {access_token}',
|
| 56 |
+
'Content-Type': 'application/json',
|
| 57 |
+
'Accept-Encoding': 'gzip',
|
| 58 |
+
'requestId': f"req-{uuid.uuid4()}"
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# 根据模型名称判断 request_type
|
| 62 |
+
if model_name:
|
| 63 |
+
# 先判断是否是图片模型
|
| 64 |
+
if "image" in model_name.lower():
|
| 65 |
+
request_type = "image_gen"
|
| 66 |
+
headers['requestType'] = request_type
|
| 67 |
+
else:
|
| 68 |
+
request_type = "agent"
|
| 69 |
+
headers['requestType'] = request_type
|
| 70 |
+
|
| 71 |
+
return headers
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _is_retryable_status(status_code: int, disable_error_codes: List[int]) -> bool:
|
| 75 |
+
"""统一判断是否属于可重试状态码。"""
|
| 76 |
+
return status_code in (429, 503) or status_code in disable_error_codes
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def _switch_credential_for_retry(
|
| 80 |
+
*,
|
| 81 |
+
next_cred_task: Optional[asyncio.Task],
|
| 82 |
+
retry_interval: float,
|
| 83 |
+
refresh_credential_fast: Callable[[], Any],
|
| 84 |
+
apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool],
|
| 85 |
+
log_prefix: str,
|
| 86 |
+
) -> Tuple[bool, Optional[asyncio.Task]]:
|
| 87 |
+
"""优先使用预热凭证,失败后退回同步刷新。"""
|
| 88 |
+
if next_cred_task is not None:
|
| 89 |
+
try:
|
| 90 |
+
cred_result = await next_cred_task
|
| 91 |
+
next_cred_task = None
|
| 92 |
+
if cred_result and apply_cred_result(cred_result):
|
| 93 |
+
await asyncio.sleep(retry_interval)
|
| 94 |
+
return True, next_cred_task
|
| 95 |
+
except Exception as e:
|
| 96 |
+
log.warning(f"{log_prefix} 预热凭证任务失败: {e}")
|
| 97 |
+
next_cred_task = None
|
| 98 |
+
|
| 99 |
+
await asyncio.sleep(retry_interval)
|
| 100 |
+
if await refresh_credential_fast():
|
| 101 |
+
return True, next_cred_task
|
| 102 |
+
|
| 103 |
+
return False, next_cred_task
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ==================== 新的流式和非流式请求函数 ====================
|
| 107 |
+
|
| 108 |
+
async def stream_request(
|
| 109 |
+
body: Dict[str, Any],
|
| 110 |
+
native: bool = False,
|
| 111 |
+
headers: Optional[Dict[str, str]] = None,
|
| 112 |
+
):
|
| 113 |
+
"""
|
| 114 |
+
流式请求函数
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
body: 请求体
|
| 118 |
+
native: 是否返回原生bytes流,False则返回str流
|
| 119 |
+
headers: 额外的请求头
|
| 120 |
+
|
| 121 |
+
Yields:
|
| 122 |
+
Response对象(错误时)或 bytes流/str流(成功时)
|
| 123 |
+
"""
|
| 124 |
+
model_name = body.get("model", "")
|
| 125 |
+
|
| 126 |
+
# 1. 获取有效凭证
|
| 127 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 128 |
+
mode="antigravity", model_name=model_name
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
if not cred_result:
|
| 132 |
+
# 如果返回值是None,直接返回错误500
|
| 133 |
+
log.error("[ANTIGRAVITY STREAM] 当前无可用凭证")
|
| 134 |
+
yield Response(
|
| 135 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 136 |
+
status_code=500,
|
| 137 |
+
media_type="application/json"
|
| 138 |
+
)
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
current_file, credential_data = cred_result
|
| 142 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 143 |
+
project_id = credential_data.get("project_id", "")
|
| 144 |
+
|
| 145 |
+
if not access_token:
|
| 146 |
+
log.error(f"[ANTIGRAVITY STREAM] No access token in credential: {current_file}")
|
| 147 |
+
yield Response(
|
| 148 |
+
content=json.dumps({"error": "凭证中没有访问令牌"}),
|
| 149 |
+
status_code=500,
|
| 150 |
+
media_type="application/json"
|
| 151 |
+
)
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
# 2. 构建URL和请求头
|
| 155 |
+
antigravity_url = await get_code_assist_endpoint()
|
| 156 |
+
target_url = f"{antigravity_url}/v1internal:streamGenerateContent?alt=sse"
|
| 157 |
+
|
| 158 |
+
auth_headers = build_antigravity_headers(access_token, model_name)
|
| 159 |
+
|
| 160 |
+
# 合并自定义headers
|
| 161 |
+
if headers:
|
| 162 |
+
auth_headers.update(headers)
|
| 163 |
+
|
| 164 |
+
# 构建包含project的payload
|
| 165 |
+
final_payload = {
|
| 166 |
+
"model": body.get("model"),
|
| 167 |
+
"project": project_id,
|
| 168 |
+
"request": body.get("request", {}),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
# 仅当凭证明确开启积分消耗时注入 enabledCreditTypes
|
| 172 |
+
def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None:
|
| 173 |
+
if cred_data.get("enable_credit") is True:
|
| 174 |
+
final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"]
|
| 175 |
+
else:
|
| 176 |
+
final_payload.pop("enabledCreditTypes", None)
|
| 177 |
+
|
| 178 |
+
apply_enabled_credit_types(credential_data)
|
| 179 |
+
|
| 180 |
+
# 3. 调用stream_post_async进行请求
|
| 181 |
+
retry_config = await get_retry_config()
|
| 182 |
+
max_retries = retry_config["max_retries"]
|
| 183 |
+
retry_interval = retry_config["retry_interval"]
|
| 184 |
+
|
| 185 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 186 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 187 |
+
next_cred_task = None # 预热的下一个凭证任务
|
| 188 |
+
|
| 189 |
+
# 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
|
| 190 |
+
async def refresh_credential_fast():
|
| 191 |
+
nonlocal current_file, access_token, auth_headers, project_id, final_payload
|
| 192 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 193 |
+
mode="antigravity", model_name=model_name
|
| 194 |
+
)
|
| 195 |
+
if not cred_result:
|
| 196 |
+
return None
|
| 197 |
+
current_file, credential_data = cred_result
|
| 198 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 199 |
+
project_id = credential_data.get("project_id", "")
|
| 200 |
+
if not access_token:
|
| 201 |
+
return None
|
| 202 |
+
# 只更新token和project_id,不重建整个headers和payload
|
| 203 |
+
auth_headers["Authorization"] = f"Bearer {access_token}"
|
| 204 |
+
final_payload["project"] = project_id
|
| 205 |
+
apply_enabled_credit_types(credential_data)
|
| 206 |
+
return True
|
| 207 |
+
|
| 208 |
+
def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
|
| 209 |
+
nonlocal current_file, access_token, project_id, auth_headers, final_payload
|
| 210 |
+
current_file, credential_data = cred_result
|
| 211 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 212 |
+
project_id = credential_data.get("project_id", "")
|
| 213 |
+
if not access_token or not project_id:
|
| 214 |
+
return False
|
| 215 |
+
auth_headers["Authorization"] = f"Bearer {access_token}"
|
| 216 |
+
final_payload["project"] = project_id
|
| 217 |
+
apply_enabled_credit_types(credential_data)
|
| 218 |
+
return True
|
| 219 |
+
|
| 220 |
+
for attempt in range(max_retries + 1):
|
| 221 |
+
success_recorded = False # 标记是否已记录成功
|
| 222 |
+
need_retry = False # 标记是否需要重试
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
async for chunk in stream_post_async(
|
| 226 |
+
url=target_url,
|
| 227 |
+
body=final_payload,
|
| 228 |
+
native=native,
|
| 229 |
+
headers=auth_headers
|
| 230 |
+
):
|
| 231 |
+
# 判断是否是Response对象
|
| 232 |
+
if isinstance(chunk, Response):
|
| 233 |
+
status_code = chunk.status_code
|
| 234 |
+
last_error_response = chunk # 记录最后一次错误
|
| 235 |
+
|
| 236 |
+
# 缓存错误解析结果,避免重复decode
|
| 237 |
+
error_body = None
|
| 238 |
+
try:
|
| 239 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 240 |
+
except Exception:
|
| 241 |
+
error_body = ""
|
| 242 |
+
|
| 243 |
+
# 如果错误码是429、503或者在禁用码当中,做好记录后进行重试
|
| 244 |
+
if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
|
| 245 |
+
log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
|
| 246 |
+
|
| 247 |
+
# 解析冷却时间
|
| 248 |
+
cooldown_until = None
|
| 249 |
+
if (status_code == 429 or status_code == 503) and error_body:
|
| 250 |
+
try:
|
| 251 |
+
cooldown_until = await parse_and_log_cooldown(error_body, mode="antigravity")
|
| 252 |
+
except Exception:
|
| 253 |
+
pass
|
| 254 |
+
|
| 255 |
+
# 预热下一个凭证
|
| 256 |
+
if next_cred_task is None and attempt < max_retries:
|
| 257 |
+
next_cred_task = asyncio.create_task(
|
| 258 |
+
credential_manager.get_valid_credential(
|
| 259 |
+
mode="antigravity", model_name=model_name
|
| 260 |
+
)
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# 记录错误并切换凭证
|
| 264 |
+
await record_api_call_error(
|
| 265 |
+
credential_manager, current_file, status_code,
|
| 266 |
+
cooldown_until, mode="antigravity", model_name=model_name,
|
| 267 |
+
error_message=error_body
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# 检查是否应该重试
|
| 271 |
+
should_retry = await handle_error_with_retry(
|
| 272 |
+
credential_manager, status_code, current_file,
|
| 273 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 274 |
+
mode="antigravity"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
if should_retry and attempt < max_retries:
|
| 278 |
+
need_retry = True
|
| 279 |
+
break # 跳出内层循环,准备重试
|
| 280 |
+
else:
|
| 281 |
+
# 不重试,直接返回原始错误
|
| 282 |
+
log.error(f"[ANTIGRAVITY STREAM] 达到最大重试次数或不应重试,返回原始错误")
|
| 283 |
+
yield chunk
|
| 284 |
+
return
|
| 285 |
+
else:
|
| 286 |
+
# 错误码不在禁用码当中,直接返回,无需重试
|
| 287 |
+
log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
|
| 288 |
+
await record_api_call_error(
|
| 289 |
+
credential_manager, current_file, status_code,
|
| 290 |
+
None, mode="antigravity", model_name=model_name,
|
| 291 |
+
error_message=error_body
|
| 292 |
+
)
|
| 293 |
+
yield chunk
|
| 294 |
+
return
|
| 295 |
+
else:
|
| 296 |
+
# 不是Response,说明是真流,直接yield返回
|
| 297 |
+
# 只在第一个chunk时记录成功
|
| 298 |
+
if not success_recorded:
|
| 299 |
+
await record_api_call_success(
|
| 300 |
+
credential_manager, current_file, mode="antigravity", model_name=model_name
|
| 301 |
+
)
|
| 302 |
+
success_recorded = True
|
| 303 |
+
log.debug(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}")
|
| 304 |
+
|
| 305 |
+
# 记录原始chunk内容(用于调试)
|
| 306 |
+
if isinstance(chunk, bytes):
|
| 307 |
+
log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}")
|
| 308 |
+
else:
|
| 309 |
+
log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}")
|
| 310 |
+
|
| 311 |
+
yield chunk
|
| 312 |
+
|
| 313 |
+
# 流式请求完成,检查结果
|
| 314 |
+
if success_recorded:
|
| 315 |
+
log.debug(f"[ANTIGRAVITY STREAM] 流式响应完成,模型: {model_name}")
|
| 316 |
+
return
|
| 317 |
+
elif not need_retry:
|
| 318 |
+
# 没有收到任何数据(空回复),需要重试
|
| 319 |
+
log.warning(f"[ANTIGRAVITY STREAM] 收到空回复,无任何内容,凭证: {current_file}")
|
| 320 |
+
await record_api_call_error(
|
| 321 |
+
credential_manager, current_file, 200,
|
| 322 |
+
None, mode="antigravity", model_name=model_name,
|
| 323 |
+
error_message="Empty response from API"
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
if attempt < max_retries:
|
| 327 |
+
need_retry = True
|
| 328 |
+
else:
|
| 329 |
+
log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数")
|
| 330 |
+
yield Response(
|
| 331 |
+
content=json.dumps({"error": "服务返回空回复"}),
|
| 332 |
+
status_code=500,
|
| 333 |
+
media_type="application/json"
|
| 334 |
+
)
|
| 335 |
+
return
|
| 336 |
+
|
| 337 |
+
# 统一处理重试
|
| 338 |
+
if need_retry:
|
| 339 |
+
log.info(f"[ANTIGRAVITY STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 340 |
+
|
| 341 |
+
switched, next_cred_task = await _switch_credential_for_retry(
|
| 342 |
+
next_cred_task=next_cred_task,
|
| 343 |
+
retry_interval=retry_interval,
|
| 344 |
+
refresh_credential_fast=refresh_credential_fast,
|
| 345 |
+
apply_cred_result=apply_cred_result,
|
| 346 |
+
log_prefix="[ANTIGRAVITY STREAM]",
|
| 347 |
+
)
|
| 348 |
+
if not switched:
|
| 349 |
+
log.error("[ANTIGRAVITY STREAM] 重试时无可用凭证或令牌")
|
| 350 |
+
yield Response(
|
| 351 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 352 |
+
status_code=500,
|
| 353 |
+
media_type="application/json"
|
| 354 |
+
)
|
| 355 |
+
return
|
| 356 |
+
continue # 重试
|
| 357 |
+
|
| 358 |
+
except Exception as e:
|
| 359 |
+
log.error(f"[ANTIGRAVITY STREAM] 流式请求异常: {e}, 凭证: {current_file}")
|
| 360 |
+
if attempt < max_retries:
|
| 361 |
+
log.info(f"[ANTIGRAVITY STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 362 |
+
await asyncio.sleep(retry_interval)
|
| 363 |
+
continue
|
| 364 |
+
else:
|
| 365 |
+
# 所有重试都失败,返回最后一次的错误(如果有)
|
| 366 |
+
log.error(f"[ANTIGRAVITY STREAM] 所有重试均失败,最后异常: {e}")
|
| 367 |
+
if last_error_response:
|
| 368 |
+
yield last_error_response
|
| 369 |
+
else:
|
| 370 |
+
# 如果没有记录到错误响应,返回500错误
|
| 371 |
+
yield Response(
|
| 372 |
+
content=json.dumps({"error": f"流式请求异常: {str(e)}"}),
|
| 373 |
+
status_code=500,
|
| 374 |
+
media_type="application/json"
|
| 375 |
+
)
|
| 376 |
+
return
|
| 377 |
+
|
| 378 |
+
# 所有重试均已耗尽(for循环正常结束),返回最后记录的错误
|
| 379 |
+
log.error("[ANTIGRAVITY STREAM] 所有重试均失败")
|
| 380 |
+
if last_error_response:
|
| 381 |
+
yield last_error_response
|
| 382 |
+
else:
|
| 383 |
+
yield Response(
|
| 384 |
+
content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
|
| 385 |
+
status_code=429,
|
| 386 |
+
media_type="application/json"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
async def non_stream_request(
|
| 391 |
+
body: Dict[str, Any],
|
| 392 |
+
headers: Optional[Dict[str, str]] = None,
|
| 393 |
+
) -> Response:
|
| 394 |
+
"""
|
| 395 |
+
非流式请求函数
|
| 396 |
+
|
| 397 |
+
Args:
|
| 398 |
+
body: 请求体
|
| 399 |
+
headers: 额外的请求头
|
| 400 |
+
|
| 401 |
+
Returns:
|
| 402 |
+
Response对象
|
| 403 |
+
"""
|
| 404 |
+
# 检查是否启用流式收集模式
|
| 405 |
+
if await get_antigravity_stream2nostream():
|
| 406 |
+
log.debug("[ANTIGRAVITY] 使用流式收集模式实现非流式请求")
|
| 407 |
+
|
| 408 |
+
# 调用stream_request获取流
|
| 409 |
+
stream = stream_request(body=body, native=False, headers=headers)
|
| 410 |
+
|
| 411 |
+
# 收集流式响应
|
| 412 |
+
# stream_request是一个异步生成器,可能yield Response(错误)或流数据
|
| 413 |
+
# collect_streaming_response会自动处理这两种情况
|
| 414 |
+
return await collect_streaming_response(stream)
|
| 415 |
+
|
| 416 |
+
# 否则使用传统非流式模式
|
| 417 |
+
log.debug("[ANTIGRAVITY] 使用传统非流式模式")
|
| 418 |
+
|
| 419 |
+
model_name = body.get("model", "")
|
| 420 |
+
|
| 421 |
+
# 1. 获取有效凭证
|
| 422 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 423 |
+
mode="antigravity", model_name=model_name
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
if not cred_result:
|
| 427 |
+
# 如果返回值是None,直接返回错误500
|
| 428 |
+
log.error("[ANTIGRAVITY] 当前无可用凭证")
|
| 429 |
+
return Response(
|
| 430 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 431 |
+
status_code=500,
|
| 432 |
+
media_type="application/json"
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
current_file, credential_data = cred_result
|
| 436 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 437 |
+
project_id = credential_data.get("project_id", "")
|
| 438 |
+
|
| 439 |
+
if not access_token:
|
| 440 |
+
log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
|
| 441 |
+
return Response(
|
| 442 |
+
content=json.dumps({"error": "凭证中没有访问令牌"}),
|
| 443 |
+
status_code=500,
|
| 444 |
+
media_type="application/json"
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
# 2. 构建URL和请求头
|
| 448 |
+
antigravity_url = await get_code_assist_endpoint()
|
| 449 |
+
target_url = f"{antigravity_url}/v1internal:generateContent"
|
| 450 |
+
|
| 451 |
+
auth_headers = build_antigravity_headers(access_token, model_name)
|
| 452 |
+
|
| 453 |
+
# 合并自定义headers
|
| 454 |
+
if headers:
|
| 455 |
+
auth_headers.update(headers)
|
| 456 |
+
|
| 457 |
+
# 构建包含project的payload
|
| 458 |
+
final_payload = {
|
| 459 |
+
"model": body.get("model"),
|
| 460 |
+
"project": project_id,
|
| 461 |
+
"request": body.get("request", {}),
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
# 仅当凭证明确开启积分消耗时注入 enabledCreditTypes
|
| 465 |
+
def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None:
|
| 466 |
+
if cred_data.get("enable_credit") is True:
|
| 467 |
+
final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"]
|
| 468 |
+
else:
|
| 469 |
+
final_payload.pop("enabledCreditTypes", None)
|
| 470 |
+
|
| 471 |
+
apply_enabled_credit_types(credential_data)
|
| 472 |
+
|
| 473 |
+
# 3. 调用post_async进行请求
|
| 474 |
+
retry_config = await get_retry_config()
|
| 475 |
+
max_retries = retry_config["max_retries"]
|
| 476 |
+
retry_interval = retry_config["retry_interval"]
|
| 477 |
+
|
| 478 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 479 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 480 |
+
next_cred_task = None # 预热的下一个凭证任务
|
| 481 |
+
|
| 482 |
+
# 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
|
| 483 |
+
async def refresh_credential_fast():
|
| 484 |
+
nonlocal current_file, access_token, auth_headers, project_id, final_payload
|
| 485 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 486 |
+
mode="antigravity", model_name=model_name
|
| 487 |
+
)
|
| 488 |
+
if not cred_result:
|
| 489 |
+
return None
|
| 490 |
+
current_file, credential_data = cred_result
|
| 491 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 492 |
+
project_id = credential_data.get("project_id", "")
|
| 493 |
+
if not access_token:
|
| 494 |
+
return None
|
| 495 |
+
# 只更新token和project_id,不重建整个headers和payload
|
| 496 |
+
auth_headers["Authorization"] = f"Bearer {access_token}"
|
| 497 |
+
final_payload["project"] = project_id
|
| 498 |
+
apply_enabled_credit_types(credential_data)
|
| 499 |
+
return True
|
| 500 |
+
|
| 501 |
+
def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
|
| 502 |
+
nonlocal current_file, access_token, project_id, auth_headers, final_payload
|
| 503 |
+
current_file, credential_data = cred_result
|
| 504 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 505 |
+
project_id = credential_data.get("project_id", "")
|
| 506 |
+
if not access_token or not project_id:
|
| 507 |
+
return False
|
| 508 |
+
auth_headers["Authorization"] = f"Bearer {access_token}"
|
| 509 |
+
final_payload["project"] = project_id
|
| 510 |
+
apply_enabled_credit_types(credential_data)
|
| 511 |
+
return True
|
| 512 |
+
|
| 513 |
+
for attempt in range(max_retries + 1):
|
| 514 |
+
need_retry = False # 标记是否需要重试
|
| 515 |
+
|
| 516 |
+
try:
|
| 517 |
+
response = await post_async(
|
| 518 |
+
url=target_url,
|
| 519 |
+
json=final_payload,
|
| 520 |
+
headers=auth_headers,
|
| 521 |
+
timeout=300.0
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
status_code = response.status_code
|
| 525 |
+
|
| 526 |
+
# 成功
|
| 527 |
+
if status_code == 200:
|
| 528 |
+
# 检查是否为空回复
|
| 529 |
+
if not response.content or len(response.content) == 0:
|
| 530 |
+
log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}")
|
| 531 |
+
|
| 532 |
+
# 记录错误
|
| 533 |
+
await record_api_call_error(
|
| 534 |
+
credential_manager, current_file, 200,
|
| 535 |
+
None, mode="antigravity", model_name=model_name,
|
| 536 |
+
error_message="Empty response from API"
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
if attempt < max_retries:
|
| 540 |
+
need_retry = True
|
| 541 |
+
else:
|
| 542 |
+
log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数")
|
| 543 |
+
return Response(
|
| 544 |
+
content=json.dumps({"error": "服务返回空回复"}),
|
| 545 |
+
status_code=500,
|
| 546 |
+
media_type="application/json"
|
| 547 |
+
)
|
| 548 |
+
else:
|
| 549 |
+
# 正常响应
|
| 550 |
+
await record_api_call_success(
|
| 551 |
+
credential_manager, current_file, mode="antigravity", model_name=model_name
|
| 552 |
+
)
|
| 553 |
+
return Response(
|
| 554 |
+
content=response.content,
|
| 555 |
+
status_code=200,
|
| 556 |
+
headers=dict(response.headers)
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
# 失败 - 记录最后一次错误
|
| 560 |
+
if status_code != 200:
|
| 561 |
+
last_error_response = Response(
|
| 562 |
+
content=response.content,
|
| 563 |
+
status_code=status_code,
|
| 564 |
+
headers=dict(response.headers)
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
# 判断是否需要重试
|
| 568 |
+
# 缓存错误文本,避免重复解析
|
| 569 |
+
error_text = ""
|
| 570 |
+
try:
|
| 571 |
+
error_text = response.text
|
| 572 |
+
except Exception:
|
| 573 |
+
pass
|
| 574 |
+
|
| 575 |
+
if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
|
| 576 |
+
log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
|
| 577 |
+
|
| 578 |
+
# 解析冷却时间
|
| 579 |
+
cooldown_until = None
|
| 580 |
+
if (status_code == 429 or status_code == 503) and error_text:
|
| 581 |
+
try:
|
| 582 |
+
cooldown_until = await parse_and_log_cooldown(error_text, mode="antigravity")
|
| 583 |
+
except Exception:
|
| 584 |
+
pass
|
| 585 |
+
|
| 586 |
+
# 并行预热下一个凭证,不阻塞当前处理
|
| 587 |
+
if next_cred_task is None and attempt < max_retries:
|
| 588 |
+
next_cred_task = asyncio.create_task(
|
| 589 |
+
credential_manager.get_valid_credential(
|
| 590 |
+
mode="antigravity", model_name=model_name
|
| 591 |
+
)
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
# 记录错误并切换凭证
|
| 595 |
+
await record_api_call_error(
|
| 596 |
+
credential_manager, current_file, status_code,
|
| 597 |
+
cooldown_until, mode="antigravity", model_name=model_name,
|
| 598 |
+
error_message=error_text
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
# 检查是否应该重试
|
| 602 |
+
should_retry = await handle_error_with_retry(
|
| 603 |
+
credential_manager, status_code, current_file,
|
| 604 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 605 |
+
mode="antigravity"
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
if should_retry and attempt < max_retries:
|
| 609 |
+
need_retry = True
|
| 610 |
+
else:
|
| 611 |
+
# 不重试,直接返回原始错误
|
| 612 |
+
log.error(f"[ANTIGRAVITY] 达到最大重试次数或不应重试,返回原始错误")
|
| 613 |
+
return last_error_response
|
| 614 |
+
else:
|
| 615 |
+
# 错误码不在禁用码当中,直接返回,无需重试
|
| 616 |
+
log.error(f"[ANTIGRAVITY] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
|
| 617 |
+
await record_api_call_error(
|
| 618 |
+
credential_manager, current_file, status_code,
|
| 619 |
+
None, mode="antigravity", model_name=model_name,
|
| 620 |
+
error_message=error_text
|
| 621 |
+
)
|
| 622 |
+
return last_error_response
|
| 623 |
+
|
| 624 |
+
# 统一处理重试
|
| 625 |
+
if need_retry:
|
| 626 |
+
log.info(f"[ANTIGRAVITY] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 627 |
+
|
| 628 |
+
switched, next_cred_task = await _switch_credential_for_retry(
|
| 629 |
+
next_cred_task=next_cred_task,
|
| 630 |
+
retry_interval=retry_interval,
|
| 631 |
+
refresh_credential_fast=refresh_credential_fast,
|
| 632 |
+
apply_cred_result=apply_cred_result,
|
| 633 |
+
log_prefix="[ANTIGRAVITY]",
|
| 634 |
+
)
|
| 635 |
+
if not switched:
|
| 636 |
+
log.error("[ANTIGRAVITY] 重试时无可用凭证或令牌")
|
| 637 |
+
return Response(
|
| 638 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 639 |
+
status_code=500,
|
| 640 |
+
media_type="application/json"
|
| 641 |
+
)
|
| 642 |
+
continue # 重试
|
| 643 |
+
|
| 644 |
+
except Exception as e:
|
| 645 |
+
log.error(f"[ANTIGRAVITY] 非流式请求异常: {e}, 凭证: {current_file}")
|
| 646 |
+
if attempt < max_retries:
|
| 647 |
+
log.info(f"[ANTIGRAVITY] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 648 |
+
await asyncio.sleep(retry_interval)
|
| 649 |
+
continue
|
| 650 |
+
else:
|
| 651 |
+
# 所有重试都失败,返回最后一次的错误(如果有)或500错误
|
| 652 |
+
log.error(f"[ANTIGRAVITY] 所有重试均失败,最后异常: {e}")
|
| 653 |
+
if last_error_response:
|
| 654 |
+
return last_error_response
|
| 655 |
+
else:
|
| 656 |
+
return Response(
|
| 657 |
+
content=json.dumps({"error": f"非流式请求异常: {str(e)}"}),
|
| 658 |
+
status_code=500,
|
| 659 |
+
media_type="application/json"
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
# 所有重试都失败,返回最后一次的原始错误(如果有)或500错误
|
| 663 |
+
log.error("[ANTIGRAVITY] 所有重试均失败")
|
| 664 |
+
if last_error_response:
|
| 665 |
+
return last_error_response
|
| 666 |
+
else:
|
| 667 |
+
return Response(
|
| 668 |
+
content=json.dumps({"error": "所有重试均失败"}),
|
| 669 |
+
status_code=500,
|
| 670 |
+
media_type="application/json"
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
# ==================== 模型和配额查询 ====================
|
| 675 |
+
|
| 676 |
+
async def fetch_available_models() -> List[Dict[str, Any]]:
|
| 677 |
+
"""
|
| 678 |
+
获取可用模型列表,返回符合 OpenAI API 规范的格式
|
| 679 |
+
|
| 680 |
+
Returns:
|
| 681 |
+
模型列表,格式为字典列表(用于兼容现有代码)
|
| 682 |
+
|
| 683 |
+
Raises:
|
| 684 |
+
返回空列表如果获取失败
|
| 685 |
+
"""
|
| 686 |
+
# 获取凭证管理器和可用凭证
|
| 687 |
+
cred_result = await credential_manager.get_valid_credential(mode="antigravity")
|
| 688 |
+
if not cred_result:
|
| 689 |
+
log.error("[ANTIGRAVITY] No valid credentials available for fetching models")
|
| 690 |
+
return []
|
| 691 |
+
|
| 692 |
+
current_file, credential_data = cred_result
|
| 693 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 694 |
+
|
| 695 |
+
if not access_token:
|
| 696 |
+
log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
|
| 697 |
+
return []
|
| 698 |
+
|
| 699 |
+
# 构建请求头
|
| 700 |
+
headers = build_antigravity_headers(access_token)
|
| 701 |
+
|
| 702 |
+
try:
|
| 703 |
+
# 使用 POST 请求获取模型列表
|
| 704 |
+
antigravity_url = await get_code_assist_endpoint()
|
| 705 |
+
|
| 706 |
+
response = await post_async(
|
| 707 |
+
url=f"{antigravity_url}/v1internal:fetchAvailableModels",
|
| 708 |
+
json={}, # 空的请求体
|
| 709 |
+
headers=headers
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
if response.status_code == 200:
|
| 713 |
+
data = response.json()
|
| 714 |
+
log.debug(f"[ANTIGRAVITY] Raw models response: {json.dumps(data, ensure_ascii=False)[:500]}")
|
| 715 |
+
|
| 716 |
+
# 转换为 OpenAI 格式的模型列表,使用 Model 类
|
| 717 |
+
model_list = []
|
| 718 |
+
current_timestamp = int(datetime.now(timezone.utc).timestamp())
|
| 719 |
+
|
| 720 |
+
if 'models' in data and isinstance(data['models'], dict):
|
| 721 |
+
# 遍历模型字典
|
| 722 |
+
for model_id in data['models'].keys():
|
| 723 |
+
model = Model(
|
| 724 |
+
id=model_id,
|
| 725 |
+
object='model',
|
| 726 |
+
created=current_timestamp,
|
| 727 |
+
owned_by='google'
|
| 728 |
+
)
|
| 729 |
+
model_list.append(model_to_dict(model))
|
| 730 |
+
# 添加额外的 claude-sonnet-4-6-thinking 模型
|
| 731 |
+
if "claude-sonnet-4-6" in data.get('models', {}):
|
| 732 |
+
model = Model(
|
| 733 |
+
id='claude-sonnet-4-6-thinking',
|
| 734 |
+
object='model',
|
| 735 |
+
created=current_timestamp,
|
| 736 |
+
owned_by='google'
|
| 737 |
+
)
|
| 738 |
+
model_list.append(model_to_dict(model))
|
| 739 |
+
# 添加额外的 claude-opus-4-6 模型
|
| 740 |
+
if "claude-opus-4-6-thinking" in data.get('models', {}):
|
| 741 |
+
claude_opus_model = Model(
|
| 742 |
+
id='claude-opus-4-6',
|
| 743 |
+
object='model',
|
| 744 |
+
created=current_timestamp,
|
| 745 |
+
owned_by='google'
|
| 746 |
+
)
|
| 747 |
+
model_list.append(model_to_dict(claude_opus_model))
|
| 748 |
+
|
| 749 |
+
log.info(f"[ANTIGRAVITY] Fetched {len(model_list)} available models")
|
| 750 |
+
return model_list
|
| 751 |
+
else:
|
| 752 |
+
log.error(f"[ANTIGRAVITY] Failed to fetch models ({response.status_code}): {response.text[:500]}")
|
| 753 |
+
return []
|
| 754 |
+
|
| 755 |
+
except Exception as e:
|
| 756 |
+
import traceback
|
| 757 |
+
log.error(f"[ANTIGRAVITY] Failed to fetch models: {e}")
|
| 758 |
+
log.error(f"[ANTIGRAVITY] Traceback: {traceback.format_exc()}")
|
| 759 |
+
return []
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
async def fetch_quota_info(access_token: str) -> Dict[str, Any]:
|
| 763 |
+
"""
|
| 764 |
+
获取指定凭证的额度信息
|
| 765 |
+
|
| 766 |
+
Args:
|
| 767 |
+
access_token: Antigravity 访问令牌
|
| 768 |
+
|
| 769 |
+
Returns:
|
| 770 |
+
包含额度信息的字典,格式为:
|
| 771 |
+
{
|
| 772 |
+
"success": True/False,
|
| 773 |
+
"models": {
|
| 774 |
+
"model_name": {
|
| 775 |
+
"remaining": 0.95,
|
| 776 |
+
"resetTime": "12-20 10:30",
|
| 777 |
+
"resetTimeRaw": "2025-12-20T02:30:00Z"
|
| 778 |
+
}
|
| 779 |
+
},
|
| 780 |
+
"error": "错误信息" (仅在失败时)
|
| 781 |
+
}
|
| 782 |
+
"""
|
| 783 |
+
|
| 784 |
+
headers = build_antigravity_headers(access_token)
|
| 785 |
+
|
| 786 |
+
try:
|
| 787 |
+
antigravity_url = await get_code_assist_endpoint()
|
| 788 |
+
|
| 789 |
+
response = await post_async(
|
| 790 |
+
url=f"{antigravity_url}/v1internal:fetchAvailableModels",
|
| 791 |
+
json={},
|
| 792 |
+
headers=headers,
|
| 793 |
+
timeout=30.0
|
| 794 |
+
)
|
| 795 |
+
|
| 796 |
+
if response.status_code == 200:
|
| 797 |
+
data = response.json()
|
| 798 |
+
log.debug(f"[ANTIGRAVITY QUOTA] Raw response: {json.dumps(data, ensure_ascii=False)[:500]}")
|
| 799 |
+
|
| 800 |
+
quota_info = {}
|
| 801 |
+
|
| 802 |
+
if 'models' in data and isinstance(data['models'], dict):
|
| 803 |
+
for model_id, model_data in data['models'].items():
|
| 804 |
+
if isinstance(model_data, dict) and 'quotaInfo' in model_data:
|
| 805 |
+
quota = model_data['quotaInfo']
|
| 806 |
+
remaining = quota.get('remainingFraction', 0)
|
| 807 |
+
reset_time_raw = quota.get('resetTime', '')
|
| 808 |
+
|
| 809 |
+
# 转换为北京时间
|
| 810 |
+
reset_time_beijing = 'N/A'
|
| 811 |
+
if reset_time_raw:
|
| 812 |
+
try:
|
| 813 |
+
utc_date = datetime.fromisoformat(reset_time_raw.replace('Z', '+00:00'))
|
| 814 |
+
# 转换为北京时间 (UTC+8)
|
| 815 |
+
from datetime import timedelta
|
| 816 |
+
beijing_date = utc_date + timedelta(hours=8)
|
| 817 |
+
reset_time_beijing = beijing_date.strftime('%m-%d %H:%M')
|
| 818 |
+
except Exception as e:
|
| 819 |
+
log.warning(f"[ANTIGRAVITY QUOTA] Failed to parse reset time: {e}")
|
| 820 |
+
|
| 821 |
+
quota_info[model_id] = {
|
| 822 |
+
"remaining": remaining,
|
| 823 |
+
"resetTime": reset_time_beijing,
|
| 824 |
+
"resetTimeRaw": reset_time_raw
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
return {
|
| 828 |
+
"success": True,
|
| 829 |
+
"models": quota_info
|
| 830 |
+
}
|
| 831 |
+
else:
|
| 832 |
+
log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota ({response.status_code}): {response.text[:500]}")
|
| 833 |
+
return {
|
| 834 |
+
"success": False,
|
| 835 |
+
"error": f"API返回错误: {response.status_code}"
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
except Exception as e:
|
| 839 |
+
import traceback
|
| 840 |
+
log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota: {e}")
|
| 841 |
+
log.error(f"[ANTIGRAVITY QUOTA] Traceback: {traceback.format_exc()}")
|
| 842 |
+
return {
|
| 843 |
+
"success": False,
|
| 844 |
+
"error": str(e)
|
| 845 |
+
}
|
src/api/geminicli.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GeminiCli API Client - Handles all communication with GeminiCli API.
|
| 3 |
+
This module is used by both OpenAI compatibility layer and native Gemini endpoints.
|
| 4 |
+
GeminiCli API 客户端 - 处理与 GeminiCli API 的所有通信
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
# 添加项目根目录到Python路径(用于直接运行测试)
|
| 11 |
+
if __name__ == "__main__":
|
| 12 |
+
project_root = Path(__file__).resolve().parent.parent.parent
|
| 13 |
+
if str(project_root) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(project_root))
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import json
|
| 18 |
+
from typing import Any, Dict, Optional, Callable, Tuple
|
| 19 |
+
|
| 20 |
+
from fastapi import Response
|
| 21 |
+
from config import get_code_assist_endpoint, get_auto_ban_error_codes
|
| 22 |
+
from log import log
|
| 23 |
+
|
| 24 |
+
from src.credential_manager import credential_manager
|
| 25 |
+
from src.httpx_client import stream_post_async, post_async
|
| 26 |
+
|
| 27 |
+
# 导入共同的基础功能
|
| 28 |
+
from src.api.utils import (
|
| 29 |
+
handle_error_with_retry,
|
| 30 |
+
get_retry_config,
|
| 31 |
+
record_api_call_success,
|
| 32 |
+
record_api_call_error,
|
| 33 |
+
parse_and_log_cooldown,
|
| 34 |
+
)
|
| 35 |
+
from src.utils import get_geminicli_user_agent
|
| 36 |
+
|
| 37 |
+
# ==================== 全局凭证管理器 ====================
|
| 38 |
+
|
| 39 |
+
# 使用全局单例 credential_manager,自动初始化
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ==================== 请求准备 ====================
|
| 43 |
+
|
| 44 |
+
async def prepare_request_headers_and_payload(
|
| 45 |
+
payload: dict, credential_data: dict, target_url: str
|
| 46 |
+
):
|
| 47 |
+
"""
|
| 48 |
+
从凭证数据准备请求头和最终payload
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
payload: 原始请求payload
|
| 52 |
+
credential_data: 凭证数据字典
|
| 53 |
+
target_url: 目标URL
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
元组: (headers, final_payload, target_url)
|
| 57 |
+
|
| 58 |
+
Raises:
|
| 59 |
+
Exception: 如果凭证中缺少必要字段
|
| 60 |
+
"""
|
| 61 |
+
token = credential_data.get("token") or credential_data.get("access_token", "")
|
| 62 |
+
if not token:
|
| 63 |
+
raise Exception("凭证中没有找到有效的访问令牌(token或access_token字段)")
|
| 64 |
+
|
| 65 |
+
source_request = payload.get("request", {})
|
| 66 |
+
|
| 67 |
+
# 内部API使用Bearer Token和项目ID
|
| 68 |
+
headers = {
|
| 69 |
+
"Authorization": f"Bearer {token}",
|
| 70 |
+
"Content-Type": "application/json",
|
| 71 |
+
"User-Agent": get_geminicli_user_agent(payload.get("model", "")),
|
| 72 |
+
}
|
| 73 |
+
project_id = credential_data.get("project_id", "")
|
| 74 |
+
if not project_id:
|
| 75 |
+
raise Exception("项目ID不存在于凭证数据中")
|
| 76 |
+
final_payload = {
|
| 77 |
+
"model": payload.get("model"),
|
| 78 |
+
"project": project_id,
|
| 79 |
+
"request": source_request,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return headers, final_payload, target_url
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _is_retryable_status(status_code: int, disable_error_codes: list[int]) -> bool:
|
| 86 |
+
"""统一判断是否属于可重试状态码。"""
|
| 87 |
+
return status_code in (429, 503) or status_code in disable_error_codes
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def _switch_credential_for_retry(
|
| 91 |
+
*,
|
| 92 |
+
next_cred_task: Optional[asyncio.Task],
|
| 93 |
+
retry_interval: float,
|
| 94 |
+
refresh_credential_fast: Callable[[], Any],
|
| 95 |
+
apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool],
|
| 96 |
+
log_prefix: str,
|
| 97 |
+
) -> Tuple[bool, Optional[asyncio.Task]]:
|
| 98 |
+
"""优先使用预热凭证,失败后退回同步刷新。"""
|
| 99 |
+
if next_cred_task is not None:
|
| 100 |
+
try:
|
| 101 |
+
cred_result = await next_cred_task
|
| 102 |
+
next_cred_task = None
|
| 103 |
+
if cred_result and apply_cred_result(cred_result):
|
| 104 |
+
await asyncio.sleep(retry_interval)
|
| 105 |
+
return True, next_cred_task
|
| 106 |
+
except Exception as e:
|
| 107 |
+
log.warning(f"{log_prefix} 预热凭证任务失败: {e}")
|
| 108 |
+
next_cred_task = None
|
| 109 |
+
|
| 110 |
+
await asyncio.sleep(retry_interval)
|
| 111 |
+
if await refresh_credential_fast():
|
| 112 |
+
return True, next_cred_task
|
| 113 |
+
|
| 114 |
+
return False, next_cred_task
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ==================== 新的流式和非流式请求函数 ====================
|
| 118 |
+
|
| 119 |
+
async def stream_request(
|
| 120 |
+
body: Dict[str, Any],
|
| 121 |
+
native: bool = False,
|
| 122 |
+
headers: Optional[Dict[str, str]] = None,
|
| 123 |
+
):
|
| 124 |
+
"""
|
| 125 |
+
流式请求函数
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
body: 请求体
|
| 129 |
+
native: 是否返回原生bytes流,False则返回str流
|
| 130 |
+
headers: 额外的请求头
|
| 131 |
+
|
| 132 |
+
Yields:
|
| 133 |
+
Response对象(错误时)或 bytes流/str流(成功时)
|
| 134 |
+
"""
|
| 135 |
+
# 获取有效凭证
|
| 136 |
+
model_name = body.get("model", "")
|
| 137 |
+
|
| 138 |
+
# 1. 获取有效凭证
|
| 139 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 140 |
+
mode="geminicli", model_name=model_name
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
if not cred_result:
|
| 144 |
+
# 如果返回值是None,直接返回错误500
|
| 145 |
+
yield Response(
|
| 146 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 147 |
+
status_code=500,
|
| 148 |
+
media_type="application/json"
|
| 149 |
+
)
|
| 150 |
+
return
|
| 151 |
+
|
| 152 |
+
current_file, credential_data = cred_result
|
| 153 |
+
|
| 154 |
+
# 2. 构建URL和请求头
|
| 155 |
+
try:
|
| 156 |
+
auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
|
| 157 |
+
body, credential_data,
|
| 158 |
+
f"{await get_code_assist_endpoint()}/v1internal:streamGenerateContent?alt=sse"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# 合并自定义headers
|
| 162 |
+
if headers:
|
| 163 |
+
auth_headers.update(headers)
|
| 164 |
+
|
| 165 |
+
except Exception as e:
|
| 166 |
+
log.error(f"准备请求失败: {e}")
|
| 167 |
+
yield Response(
|
| 168 |
+
content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
|
| 169 |
+
status_code=500,
|
| 170 |
+
media_type="application/json"
|
| 171 |
+
)
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
# 3. 调用stream_post_async进行请求
|
| 175 |
+
retry_config = await get_retry_config()
|
| 176 |
+
max_retries = retry_config["max_retries"]
|
| 177 |
+
retry_interval = retry_config["retry_interval"]
|
| 178 |
+
|
| 179 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 180 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 181 |
+
next_cred_task = None # 预热的下一个凭证任务
|
| 182 |
+
|
| 183 |
+
# 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
|
| 184 |
+
async def refresh_credential_fast():
|
| 185 |
+
nonlocal current_file, credential_data, auth_headers, final_payload
|
| 186 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 187 |
+
mode="geminicli", model_name=model_name
|
| 188 |
+
)
|
| 189 |
+
if not cred_result:
|
| 190 |
+
return None
|
| 191 |
+
current_file, credential_data = cred_result
|
| 192 |
+
try:
|
| 193 |
+
# 只更新token和project_id,不重建整个headers和payload
|
| 194 |
+
token = credential_data.get("token") or credential_data.get("access_token", "")
|
| 195 |
+
project_id = credential_data.get("project_id", "")
|
| 196 |
+
if not token or not project_id:
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
# 直接更新现有的headers和payload
|
| 200 |
+
auth_headers["Authorization"] = f"Bearer {token}"
|
| 201 |
+
final_payload["project"] = project_id
|
| 202 |
+
return True
|
| 203 |
+
except Exception:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
|
| 207 |
+
nonlocal current_file, credential_data, auth_headers, final_payload
|
| 208 |
+
current_file, credential_data = cred_result
|
| 209 |
+
token = credential_data.get("token") or credential_data.get("access_token", "")
|
| 210 |
+
project_id = credential_data.get("project_id", "")
|
| 211 |
+
if not token or not project_id:
|
| 212 |
+
return False
|
| 213 |
+
auth_headers["Authorization"] = f"Bearer {token}"
|
| 214 |
+
final_payload["project"] = project_id
|
| 215 |
+
return True
|
| 216 |
+
|
| 217 |
+
for attempt in range(max_retries + 1):
|
| 218 |
+
success_recorded = False # 标记是否已记录成功
|
| 219 |
+
need_retry = False # 标记是否需要重试
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
async for chunk in stream_post_async(
|
| 223 |
+
url=target_url,
|
| 224 |
+
body=final_payload,
|
| 225 |
+
native=native,
|
| 226 |
+
headers=auth_headers
|
| 227 |
+
):
|
| 228 |
+
# 判断是否是Response对象
|
| 229 |
+
if isinstance(chunk, Response):
|
| 230 |
+
status_code = chunk.status_code
|
| 231 |
+
last_error_response = chunk # 记录最后一次错误
|
| 232 |
+
|
| 233 |
+
# 缓存错误解析结果,避免重复decode
|
| 234 |
+
error_body = None
|
| 235 |
+
try:
|
| 236 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 237 |
+
except Exception:
|
| 238 |
+
error_body = ""
|
| 239 |
+
|
| 240 |
+
# 如果错误码是429、503或者在禁用码当中,做好记录后进行重试
|
| 241 |
+
if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
|
| 242 |
+
log.warning(f"[GEMINICLI STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
|
| 243 |
+
|
| 244 |
+
# 解析冷却时间
|
| 245 |
+
cooldown_until = None
|
| 246 |
+
if (status_code == 429 or status_code == 503) and error_body:
|
| 247 |
+
try:
|
| 248 |
+
cooldown_until = await parse_and_log_cooldown(error_body, mode="geminicli")
|
| 249 |
+
except Exception:
|
| 250 |
+
pass
|
| 251 |
+
|
| 252 |
+
# 预热下一个凭证
|
| 253 |
+
if next_cred_task is None and attempt < max_retries:
|
| 254 |
+
next_cred_task = asyncio.create_task(
|
| 255 |
+
credential_manager.get_valid_credential(
|
| 256 |
+
mode="geminicli", model_name=model_name
|
| 257 |
+
)
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# 记录错误并切换凭证
|
| 261 |
+
await record_api_call_error(
|
| 262 |
+
credential_manager, current_file, status_code,
|
| 263 |
+
cooldown_until, mode="geminicli", model_name=model_name,
|
| 264 |
+
error_message=error_body
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# 检查是否应该重试
|
| 268 |
+
should_retry = await handle_error_with_retry(
|
| 269 |
+
credential_manager, status_code, current_file,
|
| 270 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 271 |
+
mode="geminicli"
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
if should_retry and attempt < max_retries:
|
| 275 |
+
need_retry = True
|
| 276 |
+
break # 跳出内层循环,准备重试
|
| 277 |
+
else:
|
| 278 |
+
# 不重试,直接返回原始错误
|
| 279 |
+
log.error(f"[GEMINICLI STREAM] 达到最大重试次数或不应重试,返回原始错误")
|
| 280 |
+
yield chunk
|
| 281 |
+
return
|
| 282 |
+
elif status_code == 404 and "preview" in model_name.lower():
|
| 283 |
+
# 特殊处理:preview模型返回404,说明该凭证不支持preview模型
|
| 284 |
+
log.warning(f"[GEMINICLI STREAM] Preview模型404错误,凭证不支持preview: {current_file}")
|
| 285 |
+
|
| 286 |
+
# 将该凭证的preview状态设置为False
|
| 287 |
+
try:
|
| 288 |
+
await credential_manager.update_credential_state(
|
| 289 |
+
current_file, {"preview": False}, mode="geminicli"
|
| 290 |
+
)
|
| 291 |
+
log.info(f"[GEMINICLI STREAM] 已将凭证 {current_file} 的preview状态设置为False")
|
| 292 |
+
except Exception as e:
|
| 293 |
+
log.error(f"[GEMINICLI STREAM] 更新凭证preview状态失败: {e}")
|
| 294 |
+
|
| 295 |
+
# 记录404错误
|
| 296 |
+
await record_api_call_error(
|
| 297 |
+
credential_manager, current_file, status_code,
|
| 298 |
+
None, mode="geminicli", model_name=model_name,
|
| 299 |
+
error_message=error_body
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# 预热下一个凭证(会自动跳过preview=False的凭证)
|
| 303 |
+
if next_cred_task is None and attempt < max_retries:
|
| 304 |
+
next_cred_task = asyncio.create_task(
|
| 305 |
+
credential_manager.get_valid_credential(
|
| 306 |
+
mode="geminicli", model_name=model_name
|
| 307 |
+
)
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
# 触发重试
|
| 311 |
+
if attempt < max_retries:
|
| 312 |
+
need_retry = True
|
| 313 |
+
break
|
| 314 |
+
else:
|
| 315 |
+
log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回404错误")
|
| 316 |
+
yield chunk
|
| 317 |
+
return
|
| 318 |
+
else:
|
| 319 |
+
# 错误码不在禁用码当中,直接返回,无需重试
|
| 320 |
+
log.error(f"[GEMINICLI STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
|
| 321 |
+
await record_api_call_error(
|
| 322 |
+
credential_manager, current_file, status_code,
|
| 323 |
+
None, mode="geminicli", model_name=model_name,
|
| 324 |
+
error_message=error_body
|
| 325 |
+
)
|
| 326 |
+
yield chunk
|
| 327 |
+
return
|
| 328 |
+
else:
|
| 329 |
+
# 不是Response,说明是真流,直接yield返回
|
| 330 |
+
# 只在第一个chunk时记录成功
|
| 331 |
+
if not success_recorded:
|
| 332 |
+
await record_api_call_success(
|
| 333 |
+
credential_manager, current_file, mode="geminicli", model_name=model_name
|
| 334 |
+
)
|
| 335 |
+
success_recorded = True
|
| 336 |
+
log.debug(f"[GEMINICLI STREAM] 开始接收流式响应,模型: {model_name}")
|
| 337 |
+
|
| 338 |
+
yield chunk
|
| 339 |
+
|
| 340 |
+
# 流式请求完成,检查结果
|
| 341 |
+
if success_recorded:
|
| 342 |
+
log.debug(f"[GEMINICLI STREAM] 流式响应完成,模型: {model_name}")
|
| 343 |
+
return
|
| 344 |
+
|
| 345 |
+
# 统一处理重试
|
| 346 |
+
if need_retry:
|
| 347 |
+
# 如果已经是最后一次尝试,不再重试,直接返回错误
|
| 348 |
+
if attempt >= max_retries:
|
| 349 |
+
log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回错误")
|
| 350 |
+
if last_error_response:
|
| 351 |
+
yield last_error_response
|
| 352 |
+
else:
|
| 353 |
+
yield Response(
|
| 354 |
+
content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
|
| 355 |
+
status_code=429,
|
| 356 |
+
media_type="application/json"
|
| 357 |
+
)
|
| 358 |
+
return
|
| 359 |
+
|
| 360 |
+
log.info(f"[GEMINICLI STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 361 |
+
|
| 362 |
+
switched, next_cred_task = await _switch_credential_for_retry(
|
| 363 |
+
next_cred_task=next_cred_task,
|
| 364 |
+
retry_interval=retry_interval,
|
| 365 |
+
refresh_credential_fast=refresh_credential_fast,
|
| 366 |
+
apply_cred_result=apply_cred_result,
|
| 367 |
+
log_prefix="[GEMINICLI STREAM]",
|
| 368 |
+
)
|
| 369 |
+
if not switched:
|
| 370 |
+
log.error("[GEMINICLI STREAM] 重试时无可用凭证或刷新失败")
|
| 371 |
+
yield Response(
|
| 372 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 373 |
+
status_code=500,
|
| 374 |
+
media_type="application/json"
|
| 375 |
+
)
|
| 376 |
+
return
|
| 377 |
+
continue # 重试
|
| 378 |
+
|
| 379 |
+
except Exception as e:
|
| 380 |
+
log.error(f"[GEMINICLI STREAM] 流式请求异常: {e}, 凭证: {current_file}")
|
| 381 |
+
if attempt < max_retries:
|
| 382 |
+
log.info(f"[GEMINICLI STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 383 |
+
await asyncio.sleep(retry_interval)
|
| 384 |
+
continue
|
| 385 |
+
else:
|
| 386 |
+
# 所有重试都失败,返回最后一次的错误(如果有)
|
| 387 |
+
log.error(f"[GEMINICLI STREAM] 所有重试均失败,最后异常: {e}")
|
| 388 |
+
if last_error_response:
|
| 389 |
+
yield last_error_response
|
| 390 |
+
else:
|
| 391 |
+
# 如果没有记录到错误响应,返回500错误
|
| 392 |
+
yield Response(
|
| 393 |
+
content=json.dumps({"error": f"流式请求异常: {str(e)}"}),
|
| 394 |
+
status_code=500,
|
| 395 |
+
media_type="application/json"
|
| 396 |
+
)
|
| 397 |
+
return
|
| 398 |
+
|
| 399 |
+
# 所有重试均已耗尽(for循环正常结束),返回最后记录的错误
|
| 400 |
+
log.error("[GEMINICLI STREAM] 所有重试均失败")
|
| 401 |
+
if last_error_response:
|
| 402 |
+
yield last_error_response
|
| 403 |
+
else:
|
| 404 |
+
yield Response(
|
| 405 |
+
content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
|
| 406 |
+
status_code=429,
|
| 407 |
+
media_type="application/json"
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
async def non_stream_request(
|
| 412 |
+
body: Dict[str, Any],
|
| 413 |
+
headers: Optional[Dict[str, str]] = None,
|
| 414 |
+
) -> Response:
|
| 415 |
+
"""
|
| 416 |
+
非流式请求函数
|
| 417 |
+
|
| 418 |
+
Args:
|
| 419 |
+
body: 请求体
|
| 420 |
+
native: 保留参数以保持接口一致性(实际未使用)
|
| 421 |
+
headers: 额外的请求头
|
| 422 |
+
|
| 423 |
+
Returns:
|
| 424 |
+
Response对象
|
| 425 |
+
"""
|
| 426 |
+
# 获取有效凭证
|
| 427 |
+
model_name = body.get("model", "")
|
| 428 |
+
|
| 429 |
+
# 1. 获取有效凭证
|
| 430 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 431 |
+
mode="geminicli", model_name=model_name
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
if not cred_result:
|
| 435 |
+
# 如果返回值是None,直接返回错误500
|
| 436 |
+
return Response(
|
| 437 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 438 |
+
status_code=500,
|
| 439 |
+
media_type="application/json"
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
current_file, credential_data = cred_result
|
| 443 |
+
|
| 444 |
+
# 2. 构建URL和请求头
|
| 445 |
+
try:
|
| 446 |
+
auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
|
| 447 |
+
body, credential_data,
|
| 448 |
+
f"{await get_code_assist_endpoint()}/v1internal:generateContent"
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
# 合并自定义headers
|
| 452 |
+
if headers:
|
| 453 |
+
auth_headers.update(headers)
|
| 454 |
+
|
| 455 |
+
except Exception as e:
|
| 456 |
+
log.error(f"准备请求失败: {e}")
|
| 457 |
+
return Response(
|
| 458 |
+
content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
|
| 459 |
+
status_code=500,
|
| 460 |
+
media_type="application/json"
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
# 3. 调用post_async进行请求
|
| 464 |
+
retry_config = await get_retry_config()
|
| 465 |
+
max_retries = retry_config["max_retries"]
|
| 466 |
+
retry_interval = retry_config["retry_interval"]
|
| 467 |
+
|
| 468 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 469 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 470 |
+
next_cred_task = None # 预热的下一个凭证任务
|
| 471 |
+
|
| 472 |
+
# 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
|
| 473 |
+
async def refresh_credential_fast():
|
| 474 |
+
nonlocal current_file, credential_data, auth_headers, final_payload
|
| 475 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 476 |
+
mode="geminicli", model_name=model_name
|
| 477 |
+
)
|
| 478 |
+
if not cred_result:
|
| 479 |
+
return None
|
| 480 |
+
current_file, credential_data = cred_result
|
| 481 |
+
try:
|
| 482 |
+
# 只更新token和project_id,不重建整个headers和payload
|
| 483 |
+
token = credential_data.get("token") or credential_data.get("access_token", "")
|
| 484 |
+
project_id = credential_data.get("project_id", "")
|
| 485 |
+
if not token or not project_id:
|
| 486 |
+
return None
|
| 487 |
+
|
| 488 |
+
# 直接更新现有的headers和payload
|
| 489 |
+
auth_headers["Authorization"] = f"Bearer {token}"
|
| 490 |
+
final_payload["project"] = project_id
|
| 491 |
+
return True
|
| 492 |
+
except Exception:
|
| 493 |
+
return None
|
| 494 |
+
|
| 495 |
+
def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
|
| 496 |
+
nonlocal current_file, credential_data, auth_headers, final_payload
|
| 497 |
+
current_file, credential_data = cred_result
|
| 498 |
+
token = credential_data.get("token") or credential_data.get("access_token", "")
|
| 499 |
+
project_id = credential_data.get("project_id", "")
|
| 500 |
+
if not token or not project_id:
|
| 501 |
+
return False
|
| 502 |
+
auth_headers["Authorization"] = f"Bearer {token}"
|
| 503 |
+
final_payload["project"] = project_id
|
| 504 |
+
return True
|
| 505 |
+
|
| 506 |
+
for attempt in range(max_retries + 1):
|
| 507 |
+
try:
|
| 508 |
+
response = await post_async(
|
| 509 |
+
url=target_url,
|
| 510 |
+
json=final_payload,
|
| 511 |
+
headers=auth_headers,
|
| 512 |
+
timeout=300.0
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
status_code = response.status_code
|
| 516 |
+
|
| 517 |
+
# 成功
|
| 518 |
+
if status_code == 200:
|
| 519 |
+
await record_api_call_success(
|
| 520 |
+
credential_manager, current_file, mode="geminicli", model_name=model_name
|
| 521 |
+
)
|
| 522 |
+
# 创建响应头,移除压缩相关的header避免重复解压
|
| 523 |
+
response_headers = dict(response.headers)
|
| 524 |
+
response_headers.pop('content-encoding', None)
|
| 525 |
+
response_headers.pop('content-length', None)
|
| 526 |
+
|
| 527 |
+
return Response(
|
| 528 |
+
content=response.content,
|
| 529 |
+
status_code=200,
|
| 530 |
+
headers=response_headers
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
# 失败 - 记录最后一次错误
|
| 534 |
+
# 创建响应头,移除压缩相关的header避免重复解压
|
| 535 |
+
error_headers = dict(response.headers)
|
| 536 |
+
error_headers.pop('content-encoding', None)
|
| 537 |
+
error_headers.pop('content-length', None)
|
| 538 |
+
|
| 539 |
+
last_error_response = Response(
|
| 540 |
+
content=response.content,
|
| 541 |
+
status_code=status_code,
|
| 542 |
+
headers=error_headers
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# 判断是否需要重试
|
| 546 |
+
# 缓存错误文本,避免重复解析
|
| 547 |
+
error_text = ""
|
| 548 |
+
try:
|
| 549 |
+
error_text = response.text
|
| 550 |
+
except Exception:
|
| 551 |
+
pass
|
| 552 |
+
|
| 553 |
+
# 统一处理所有需要重试的错误码(429、503、禁用码)
|
| 554 |
+
if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
|
| 555 |
+
log.warning(f"[NON-STREAM] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
|
| 556 |
+
|
| 557 |
+
# 解析冷却时间
|
| 558 |
+
cooldown_until = None
|
| 559 |
+
if (status_code == 429 or status_code == 503) and error_text:
|
| 560 |
+
try:
|
| 561 |
+
cooldown_until = await parse_and_log_cooldown(error_text, mode="geminicli")
|
| 562 |
+
except Exception:
|
| 563 |
+
pass
|
| 564 |
+
|
| 565 |
+
# 并行预热下一个凭证,不阻塞当前处理
|
| 566 |
+
if next_cred_task is None and attempt < max_retries:
|
| 567 |
+
next_cred_task = asyncio.create_task(
|
| 568 |
+
credential_manager.get_valid_credential(
|
| 569 |
+
mode="geminicli", model_name=model_name
|
| 570 |
+
)
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
# 记录错误并切换凭证
|
| 574 |
+
await record_api_call_error(
|
| 575 |
+
credential_manager, current_file, status_code,
|
| 576 |
+
cooldown_until, mode="geminicli", model_name=model_name,
|
| 577 |
+
error_message=error_text
|
| 578 |
+
)
|
| 579 |
+
|
| 580 |
+
# 检查是否应该重试(会自动处理禁用逻辑)
|
| 581 |
+
should_retry = await handle_error_with_retry(
|
| 582 |
+
credential_manager, status_code, current_file,
|
| 583 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 584 |
+
mode="geminicli"
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
if should_retry and attempt < max_retries:
|
| 588 |
+
# 重新获取凭证并重试
|
| 589 |
+
log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 590 |
+
|
| 591 |
+
switched, next_cred_task = await _switch_credential_for_retry(
|
| 592 |
+
next_cred_task=next_cred_task,
|
| 593 |
+
retry_interval=retry_interval,
|
| 594 |
+
refresh_credential_fast=refresh_credential_fast,
|
| 595 |
+
apply_cred_result=apply_cred_result,
|
| 596 |
+
log_prefix="[NON-STREAM]",
|
| 597 |
+
)
|
| 598 |
+
if not switched:
|
| 599 |
+
log.error("[NON-STREAM] 重试时无可用凭证或刷新失败")
|
| 600 |
+
return Response(
|
| 601 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 602 |
+
status_code=500,
|
| 603 |
+
media_type="application/json"
|
| 604 |
+
)
|
| 605 |
+
continue # 重试
|
| 606 |
+
else:
|
| 607 |
+
# 不重试,直接返回原始错误
|
| 608 |
+
log.error(f"[NON-STREAM] 达到最大重试次数或不应重试,返回原始错误")
|
| 609 |
+
return last_error_response
|
| 610 |
+
elif status_code == 404 and "preview" in model_name.lower():
|
| 611 |
+
# 特殊处理:preview模型返回404,说明该凭证不支持preview模型
|
| 612 |
+
log.warning(f"[NON-STREAM] Preview模型404错误,凭证不支持preview: {current_file}")
|
| 613 |
+
|
| 614 |
+
# 将该凭证的preview状态设置为False
|
| 615 |
+
try:
|
| 616 |
+
await credential_manager.update_credential_state(
|
| 617 |
+
current_file, {"preview": False}, mode="geminicli"
|
| 618 |
+
)
|
| 619 |
+
log.info(f"[NON-STREAM] 已将凭证 {current_file} 的preview状态设置为False")
|
| 620 |
+
except Exception as e:
|
| 621 |
+
log.error(f"[NON-STREAM] 更新凭证preview状态失败: {e}")
|
| 622 |
+
|
| 623 |
+
# 记录404错误
|
| 624 |
+
await record_api_call_error(
|
| 625 |
+
credential_manager, current_file, status_code,
|
| 626 |
+
None, mode="geminicli", model_name=model_name,
|
| 627 |
+
error_message=error_text
|
| 628 |
+
)
|
| 629 |
+
|
| 630 |
+
# 预热下一个凭证(会自动跳过preview=False的凭证)
|
| 631 |
+
if next_cred_task is None and attempt < max_retries:
|
| 632 |
+
next_cred_task = asyncio.create_task(
|
| 633 |
+
credential_manager.get_valid_credential(
|
| 634 |
+
mode="geminicli", model_name=model_name
|
| 635 |
+
)
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
# 触发重试
|
| 639 |
+
if attempt < max_retries:
|
| 640 |
+
log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 641 |
+
|
| 642 |
+
switched, next_cred_task = await _switch_credential_for_retry(
|
| 643 |
+
next_cred_task=next_cred_task,
|
| 644 |
+
retry_interval=retry_interval,
|
| 645 |
+
refresh_credential_fast=refresh_credential_fast,
|
| 646 |
+
apply_cred_result=apply_cred_result,
|
| 647 |
+
log_prefix="[NON-STREAM]",
|
| 648 |
+
)
|
| 649 |
+
if not switched:
|
| 650 |
+
log.error("[NON-STREAM] 重试时无可用凭证或刷新失败")
|
| 651 |
+
return Response(
|
| 652 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 653 |
+
status_code=500,
|
| 654 |
+
media_type="application/json"
|
| 655 |
+
)
|
| 656 |
+
continue # 重试
|
| 657 |
+
else:
|
| 658 |
+
log.error(f"[NON-STREAM] 达到最大重试次数,返回404错误")
|
| 659 |
+
return last_error_response
|
| 660 |
+
else:
|
| 661 |
+
# 错误码不在重试范围内,直接返回
|
| 662 |
+
log.error(f"[NON-STREAM] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
|
| 663 |
+
await record_api_call_error(
|
| 664 |
+
credential_manager, current_file, status_code,
|
| 665 |
+
None, mode="geminicli", model_name=model_name,
|
| 666 |
+
error_message=error_text
|
| 667 |
+
)
|
| 668 |
+
return last_error_response
|
| 669 |
+
|
| 670 |
+
except Exception as e:
|
| 671 |
+
log.error(f"非流式请求异常: {e}, 凭证: {current_file}")
|
| 672 |
+
if attempt < max_retries:
|
| 673 |
+
log.info(f"[NON-STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 674 |
+
await asyncio.sleep(retry_interval)
|
| 675 |
+
continue
|
| 676 |
+
else:
|
| 677 |
+
# 所有重试都失败,返回最后一次的错误(如果有)或500错误
|
| 678 |
+
log.error(f"[NON-STREAM] 所有重试均失败,最后异常: {e}")
|
| 679 |
+
if last_error_response:
|
| 680 |
+
return last_error_response
|
| 681 |
+
else:
|
| 682 |
+
return Response(
|
| 683 |
+
content=json.dumps({"error": f"请求异常: {str(e)}"}),
|
| 684 |
+
status_code=500,
|
| 685 |
+
media_type="application/json"
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
# 所有重试都失败,返回最后一次的原始错误
|
| 689 |
+
log.error("[NON-STREAM] 所有重试均失败")
|
| 690 |
+
return last_error_response
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
# ==================== 测试代码 ====================
|
| 694 |
+
|
| 695 |
+
if __name__ == "__main__":
|
| 696 |
+
"""
|
| 697 |
+
测试代码:演示API返回的流式和非流式数据格式
|
| 698 |
+
运行方式: python src/api/geminicli.py
|
| 699 |
+
"""
|
| 700 |
+
print("=" * 80)
|
| 701 |
+
print("GeminiCli API 测试")
|
| 702 |
+
print("=" * 80)
|
| 703 |
+
|
| 704 |
+
# 测试请求体
|
| 705 |
+
test_body = {
|
| 706 |
+
"model": "gemini-2.5-flash",
|
| 707 |
+
"request": {
|
| 708 |
+
"contents": [
|
| 709 |
+
{
|
| 710 |
+
"role": "user",
|
| 711 |
+
"parts": [{"text": "Hello, tell me a joke in one sentence."}]
|
| 712 |
+
}
|
| 713 |
+
]
|
| 714 |
+
}
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
async def test_stream_request():
|
| 718 |
+
"""测试流式请求"""
|
| 719 |
+
print("\n" + "=" * 80)
|
| 720 |
+
print("【测试1】流式请求 (stream_request with native=False)")
|
| 721 |
+
print("=" * 80)
|
| 722 |
+
print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
|
| 723 |
+
|
| 724 |
+
print("流式响应数据 (每个chunk):")
|
| 725 |
+
print("-" * 80)
|
| 726 |
+
|
| 727 |
+
chunk_count = 0
|
| 728 |
+
async for chunk in stream_request(body=test_body, native=False):
|
| 729 |
+
chunk_count += 1
|
| 730 |
+
if isinstance(chunk, Response):
|
| 731 |
+
# 错误响应
|
| 732 |
+
print(f"\n❌ 错误响应:")
|
| 733 |
+
print(f" 状态码: {chunk.status_code}")
|
| 734 |
+
print(f" Content-Type: {chunk.headers.get('content-type', 'N/A')}")
|
| 735 |
+
try:
|
| 736 |
+
content = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 737 |
+
print(f" 内容: {content}")
|
| 738 |
+
except Exception as e:
|
| 739 |
+
print(f" 内容解析失败: {e}")
|
| 740 |
+
else:
|
| 741 |
+
# 正常的流式数据块 (str类型)
|
| 742 |
+
print(f"\nChunk #{chunk_count}:")
|
| 743 |
+
print(f" 类型: {type(chunk).__name__}")
|
| 744 |
+
print(f" 长度: {len(chunk) if hasattr(chunk, '__len__') else 'N/A'}")
|
| 745 |
+
print(f" 内容预览: {repr(chunk[:200] if len(chunk) > 200 else chunk)}")
|
| 746 |
+
|
| 747 |
+
# 如果是SSE格式,尝试解析
|
| 748 |
+
if isinstance(chunk, str) and chunk.startswith("data: "):
|
| 749 |
+
try:
|
| 750 |
+
data_line = chunk.strip()
|
| 751 |
+
if data_line.startswith("data: "):
|
| 752 |
+
json_str = data_line[6:] # 去掉 "data: " 前缀
|
| 753 |
+
json_data = json.loads(json_str)
|
| 754 |
+
print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}")
|
| 755 |
+
except Exception as e:
|
| 756 |
+
print(f" SSE解析尝试失败: {e}")
|
| 757 |
+
|
| 758 |
+
print(f"\n总共收到 {chunk_count} 个chunk")
|
| 759 |
+
|
| 760 |
+
async def test_non_stream_request():
|
| 761 |
+
"""测试非流式请求"""
|
| 762 |
+
print("\n" + "=" * 80)
|
| 763 |
+
print("【测试2】非流式请求 (non_stream_request)")
|
| 764 |
+
print("=" * 80)
|
| 765 |
+
print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
|
| 766 |
+
|
| 767 |
+
response = await non_stream_request(body=test_body)
|
| 768 |
+
|
| 769 |
+
print("非流式响应数据:")
|
| 770 |
+
print("-" * 80)
|
| 771 |
+
print(f"状态码: {response.status_code}")
|
| 772 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}")
|
| 773 |
+
print(f"\n响应头: {dict(response.headers)}\n")
|
| 774 |
+
|
| 775 |
+
try:
|
| 776 |
+
content = response.body.decode('utf-8') if isinstance(response.body, bytes) else str(response.body)
|
| 777 |
+
print(f"响应内容 (原始):\n{content}\n")
|
| 778 |
+
|
| 779 |
+
# 尝试解析JSON
|
| 780 |
+
try:
|
| 781 |
+
json_data = json.loads(content)
|
| 782 |
+
print(f"响应内容 (格式化JSON):")
|
| 783 |
+
print(json.dumps(json_data, indent=2, ensure_ascii=False))
|
| 784 |
+
except json.JSONDecodeError:
|
| 785 |
+
print("(非JSON格式)")
|
| 786 |
+
except Exception as e:
|
| 787 |
+
print(f"内容解析失败: {e}")
|
| 788 |
+
|
| 789 |
+
async def main():
|
| 790 |
+
"""主测试函数"""
|
| 791 |
+
try:
|
| 792 |
+
# 测试流式请求
|
| 793 |
+
await test_stream_request()
|
| 794 |
+
|
| 795 |
+
# 测试非流式请求
|
| 796 |
+
await test_non_stream_request()
|
| 797 |
+
|
| 798 |
+
print("\n" + "=" * 80)
|
| 799 |
+
print("测试完成")
|
| 800 |
+
print("=" * 80)
|
| 801 |
+
|
| 802 |
+
except Exception as e:
|
| 803 |
+
print(f"\n❌ 测试过程中出现异常: {e}")
|
| 804 |
+
import traceback
|
| 805 |
+
traceback.print_exc()
|
| 806 |
+
|
| 807 |
+
# 运行测试
|
| 808 |
+
asyncio.run(main())
|
src/api/utils.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base API Client - 共用的 API 客户端基础功能
|
| 3 |
+
提供错误处理、自动封禁、重试逻辑等共同功能
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import Response
|
| 12 |
+
|
| 13 |
+
from config import (
|
| 14 |
+
get_auto_ban_enabled,
|
| 15 |
+
get_auto_ban_error_codes,
|
| 16 |
+
get_retry_429_enabled,
|
| 17 |
+
get_retry_429_interval,
|
| 18 |
+
get_retry_429_max_retries,
|
| 19 |
+
)
|
| 20 |
+
from log import log
|
| 21 |
+
from src.credential_manager import CredentialManager
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ==================== 错误检查与处理 ====================
|
| 25 |
+
|
| 26 |
+
async def check_should_auto_ban(status_code: int) -> bool:
|
| 27 |
+
"""
|
| 28 |
+
检查是否应该触发自动封禁
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
status_code: HTTP状态码
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
bool: 是否应该触发自动封禁
|
| 35 |
+
"""
|
| 36 |
+
return (
|
| 37 |
+
await get_auto_ban_enabled()
|
| 38 |
+
and status_code in await get_auto_ban_error_codes()
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def handle_auto_ban(
|
| 43 |
+
credential_manager: CredentialManager,
|
| 44 |
+
status_code: int,
|
| 45 |
+
credential_name: str,
|
| 46 |
+
mode: str = "geminicli"
|
| 47 |
+
) -> None:
|
| 48 |
+
"""
|
| 49 |
+
处理自动封禁:直接禁用凭证
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
credential_manager: 凭证管理器实例
|
| 53 |
+
status_code: HTTP状态码
|
| 54 |
+
credential_name: 凭证名称
|
| 55 |
+
mode: 模式(geminicli 或 antigravity)
|
| 56 |
+
"""
|
| 57 |
+
if credential_manager and credential_name:
|
| 58 |
+
log.warning(
|
| 59 |
+
f"[{mode.upper()} AUTO_BAN] Status {status_code} triggers auto-ban for credential: {credential_name}"
|
| 60 |
+
)
|
| 61 |
+
await credential_manager.set_cred_disabled(
|
| 62 |
+
credential_name, True, mode=mode
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def handle_error_with_retry(
|
| 67 |
+
credential_manager: CredentialManager,
|
| 68 |
+
status_code: int,
|
| 69 |
+
credential_name: str,
|
| 70 |
+
retry_enabled: bool,
|
| 71 |
+
attempt: int,
|
| 72 |
+
max_retries: int,
|
| 73 |
+
retry_interval: float,
|
| 74 |
+
mode: str = "geminicli"
|
| 75 |
+
) -> bool:
|
| 76 |
+
"""
|
| 77 |
+
统一处理错误和重试逻辑
|
| 78 |
+
|
| 79 |
+
仅在以下情况下进行自动重试:
|
| 80 |
+
1. 429错误(速率限制)
|
| 81 |
+
2. 503错误(服务不可用)
|
| 82 |
+
3. 导致凭证封禁的错误(AUTO_BAN_ERROR_CODES配置)
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
credential_manager: 凭证管理器实例
|
| 86 |
+
status_code: HTTP状态码
|
| 87 |
+
credential_name: 凭证名称
|
| 88 |
+
retry_enabled: 是否启用重试
|
| 89 |
+
attempt: 当前重试次数
|
| 90 |
+
max_retries: 最大重试次数
|
| 91 |
+
retry_interval: 重试间隔
|
| 92 |
+
mode: 模式(geminicli 或 antigravity)
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
bool: True表示需要继续重试,False表示不需要重试
|
| 96 |
+
"""
|
| 97 |
+
# 优先检查自动封禁
|
| 98 |
+
should_auto_ban = await check_should_auto_ban(status_code)
|
| 99 |
+
|
| 100 |
+
if should_auto_ban:
|
| 101 |
+
# 触发自动封禁
|
| 102 |
+
await handle_auto_ban(credential_manager, status_code, credential_name, mode)
|
| 103 |
+
|
| 104 |
+
# 自动封禁后,仍然尝试重试(会在下次循环中自动获取新凭证)
|
| 105 |
+
if retry_enabled and attempt < max_retries:
|
| 106 |
+
log.info(
|
| 107 |
+
f"[{mode.upper()} RETRY] Retrying with next credential after auto-ban "
|
| 108 |
+
f"(status {status_code}, attempt {attempt + 1}/{max_retries})"
|
| 109 |
+
)
|
| 110 |
+
await asyncio.sleep(retry_interval)
|
| 111 |
+
return True
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
# 如果不触发自动封禁,仅对429和503错误进行重试
|
| 115 |
+
if (status_code == 429 or status_code == 503) and retry_enabled and attempt < max_retries:
|
| 116 |
+
log.info(
|
| 117 |
+
f"[{mode.upper()} RETRY] {status_code} error encountered, retrying "
|
| 118 |
+
f"(attempt {attempt + 1}/{max_retries})"
|
| 119 |
+
)
|
| 120 |
+
await asyncio.sleep(retry_interval)
|
| 121 |
+
return True
|
| 122 |
+
|
| 123 |
+
# 其他错误不进行重试
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ==================== 重试配置获取 ====================
|
| 128 |
+
|
| 129 |
+
async def get_retry_config() -> Dict[str, Any]:
|
| 130 |
+
"""
|
| 131 |
+
获取重试配置
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
包含重试配置的字典
|
| 135 |
+
"""
|
| 136 |
+
return {
|
| 137 |
+
"retry_enabled": await get_retry_429_enabled(),
|
| 138 |
+
"max_retries": await get_retry_429_max_retries(),
|
| 139 |
+
"retry_interval": await get_retry_429_interval(),
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ==================== API调用结果记录 ====================
|
| 144 |
+
|
| 145 |
+
async def record_api_call_success(
|
| 146 |
+
credential_manager: CredentialManager,
|
| 147 |
+
credential_name: str,
|
| 148 |
+
mode: str = "geminicli",
|
| 149 |
+
model_name: Optional[str] = None
|
| 150 |
+
) -> None:
|
| 151 |
+
"""
|
| 152 |
+
记录API调用成功
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
credential_manager: 凭证管理器实例
|
| 156 |
+
credential_name: 凭证名称
|
| 157 |
+
mode: 模式(geminicli 或 antigravity)
|
| 158 |
+
model_name: 模型名称(用于模型级CD)
|
| 159 |
+
"""
|
| 160 |
+
if credential_manager and credential_name:
|
| 161 |
+
await credential_manager.record_api_call_result(
|
| 162 |
+
credential_name, True, mode=mode, model_name=model_name
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def record_api_call_error(
|
| 167 |
+
credential_manager: CredentialManager,
|
| 168 |
+
credential_name: str,
|
| 169 |
+
status_code: int,
|
| 170 |
+
cooldown_until: Optional[float] = None,
|
| 171 |
+
mode: str = "geminicli",
|
| 172 |
+
model_name: Optional[str] = None,
|
| 173 |
+
error_message: Optional[str] = None
|
| 174 |
+
) -> None:
|
| 175 |
+
"""
|
| 176 |
+
记录API调用错误
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
credential_manager: 凭证管理器实例
|
| 180 |
+
credential_name: 凭证名称
|
| 181 |
+
status_code: HTTP状态码
|
| 182 |
+
cooldown_until: 冷却截止时间(Unix时间戳)
|
| 183 |
+
mode: 模式(geminicli 或 antigravity)
|
| 184 |
+
model_name: 模型名称(用于模型级CD)
|
| 185 |
+
error_message: 错误信息(可选)
|
| 186 |
+
"""
|
| 187 |
+
if credential_manager and credential_name:
|
| 188 |
+
await credential_manager.record_api_call_result(
|
| 189 |
+
credential_name,
|
| 190 |
+
False,
|
| 191 |
+
status_code,
|
| 192 |
+
cooldown_until=cooldown_until,
|
| 193 |
+
mode=mode,
|
| 194 |
+
model_name=model_name,
|
| 195 |
+
error_message=error_message
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ==================== 429错误处理 ====================
|
| 200 |
+
|
| 201 |
+
async def parse_and_log_cooldown(
|
| 202 |
+
error_text: str,
|
| 203 |
+
mode: str = "geminicli"
|
| 204 |
+
) -> Optional[float]:
|
| 205 |
+
"""
|
| 206 |
+
解析并记录冷却时间
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
error_text: 错误响应文本
|
| 210 |
+
mode: 模式(geminicli 或 antigravity)
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
冷却截止时间(Unix时间戳),如果解析失败则返回None
|
| 214 |
+
"""
|
| 215 |
+
try:
|
| 216 |
+
error_data = json.loads(error_text)
|
| 217 |
+
cooldown_until = parse_quota_reset_timestamp(error_data)
|
| 218 |
+
if cooldown_until:
|
| 219 |
+
log.info(
|
| 220 |
+
f"[{mode.upper()}] 检测到quota冷却时间: "
|
| 221 |
+
f"{datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
|
| 222 |
+
)
|
| 223 |
+
return cooldown_until
|
| 224 |
+
except Exception as parse_err:
|
| 225 |
+
log.debug(f"[{mode.upper()}] Failed to parse cooldown time: {parse_err}")
|
| 226 |
+
return None
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ==================== 流式响应收集 ====================
|
| 230 |
+
|
| 231 |
+
async def collect_streaming_response(stream_generator) -> Response:
|
| 232 |
+
"""
|
| 233 |
+
将Gemini流式响应收集为一条完整的非流式响应
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
stream_generator: 流式响应生成器,产生 "data: {json}" 格式的行或Response对象
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
Response: 合并后的完整响应对象
|
| 240 |
+
|
| 241 |
+
Example:
|
| 242 |
+
>>> async for line in stream_generator:
|
| 243 |
+
... # line format: "data: {...}" or Response object
|
| 244 |
+
>>> response = await collect_streaming_response(stream_generator)
|
| 245 |
+
"""
|
| 246 |
+
# 初始化响应结构
|
| 247 |
+
merged_response = {
|
| 248 |
+
"response": {
|
| 249 |
+
"candidates": [{
|
| 250 |
+
"content": {
|
| 251 |
+
"parts": [],
|
| 252 |
+
"role": "model"
|
| 253 |
+
},
|
| 254 |
+
"finishReason": None,
|
| 255 |
+
"safetyRatings": [],
|
| 256 |
+
"citationMetadata": None
|
| 257 |
+
}],
|
| 258 |
+
"usageMetadata": {
|
| 259 |
+
"promptTokenCount": 0,
|
| 260 |
+
"candidatesTokenCount": 0,
|
| 261 |
+
"totalTokenCount": 0
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
collected_text = [] # 用于收集文本内容
|
| 267 |
+
collected_thought_text = [] # 用于收集思维链内容
|
| 268 |
+
collected_other_parts = [] # 用于收集其他类型的parts(图片、文件、工具调用等)
|
| 269 |
+
collected_tool_parts_count = 0 # 记录工具调用相关part数量
|
| 270 |
+
has_data = False
|
| 271 |
+
line_count = 0
|
| 272 |
+
|
| 273 |
+
log.debug("[STREAM COLLECTOR] Starting to collect streaming response")
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
async for line in stream_generator:
|
| 277 |
+
line_count += 1
|
| 278 |
+
|
| 279 |
+
# 如果收到的是Response对象(错误),直接返回
|
| 280 |
+
if isinstance(line, Response):
|
| 281 |
+
log.debug(f"[STREAM COLLECTOR] 收到错误Response,状态码: {line.status_code}")
|
| 282 |
+
return line
|
| 283 |
+
|
| 284 |
+
# 处理 bytes 类型
|
| 285 |
+
if isinstance(line, bytes):
|
| 286 |
+
line_str = line.decode('utf-8', errors='ignore')
|
| 287 |
+
log.debug(f"[STREAM COLLECTOR] Processing bytes line {line_count}: {line_str[:200] if line_str else 'empty'}")
|
| 288 |
+
elif isinstance(line, str):
|
| 289 |
+
line_str = line
|
| 290 |
+
log.debug(f"[STREAM COLLECTOR] Processing line {line_count}: {line_str[:200] if line_str else 'empty'}")
|
| 291 |
+
else:
|
| 292 |
+
log.debug(f"[STREAM COLLECTOR] Skipping non-string/bytes line: {type(line)}")
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
# 解析流式数据行
|
| 296 |
+
if not line_str.startswith("data: "):
|
| 297 |
+
log.debug(f"[STREAM COLLECTOR] Skipping line without 'data: ' prefix: {line_str[:100]}")
|
| 298 |
+
continue
|
| 299 |
+
|
| 300 |
+
raw = line_str[6:].strip()
|
| 301 |
+
if raw == "[DONE]":
|
| 302 |
+
log.debug("[STREAM COLLECTOR] Received [DONE] marker")
|
| 303 |
+
break
|
| 304 |
+
|
| 305 |
+
try:
|
| 306 |
+
log.debug(f"[STREAM COLLECTOR] Parsing JSON: {raw[:200]}")
|
| 307 |
+
chunk = json.loads(raw)
|
| 308 |
+
has_data = True
|
| 309 |
+
log.debug(f"[STREAM COLLECTOR] Chunk keys: {chunk.keys() if isinstance(chunk, dict) else type(chunk)}")
|
| 310 |
+
|
| 311 |
+
# 提取响应对象
|
| 312 |
+
response_obj = chunk.get("response", {})
|
| 313 |
+
if not response_obj:
|
| 314 |
+
log.debug("[STREAM COLLECTOR] No 'response' key in chunk, trying direct access")
|
| 315 |
+
response_obj = chunk # 尝试直接使用chunk
|
| 316 |
+
|
| 317 |
+
candidates = response_obj.get("candidates", [])
|
| 318 |
+
log.debug(f"[STREAM COLLECTOR] Found {len(candidates)} candidates")
|
| 319 |
+
if not candidates:
|
| 320 |
+
log.debug(f"[STREAM COLLECTOR] No candidates in chunk, chunk structure: {list(chunk.keys()) if isinstance(chunk, dict) else type(chunk)}")
|
| 321 |
+
continue
|
| 322 |
+
|
| 323 |
+
candidate = candidates[0]
|
| 324 |
+
|
| 325 |
+
# 收集文本内容
|
| 326 |
+
content = candidate.get("content", {})
|
| 327 |
+
parts = content.get("parts", [])
|
| 328 |
+
log.debug(f"[STREAM COLLECTOR] Processing {len(parts)} parts from candidate")
|
| 329 |
+
|
| 330 |
+
for part in parts:
|
| 331 |
+
if not isinstance(part, dict):
|
| 332 |
+
continue
|
| 333 |
+
|
| 334 |
+
# 优先保留工具调用相关 part(functionCall / functionResponse)
|
| 335 |
+
# 避免在 stream2nostream 模式下工具调用丢失
|
| 336 |
+
if "functionCall" in part or "functionResponse" in part or "function_call" in part:
|
| 337 |
+
collected_other_parts.append(part)
|
| 338 |
+
collected_tool_parts_count += 1
|
| 339 |
+
log.debug(f"[STREAM COLLECTOR] Collected tool part: {list(part.keys())}")
|
| 340 |
+
continue
|
| 341 |
+
|
| 342 |
+
# 处理文本内容
|
| 343 |
+
text = part.get("text", "")
|
| 344 |
+
if text:
|
| 345 |
+
# 区分普通文本和思维链
|
| 346 |
+
if part.get("thought", False):
|
| 347 |
+
collected_thought_text.append(text)
|
| 348 |
+
log.debug(f"[STREAM COLLECTOR] Collected thought text: {text[:100]}")
|
| 349 |
+
else:
|
| 350 |
+
collected_text.append(text)
|
| 351 |
+
log.debug(f"[STREAM COLLECTOR] Collected regular text: {text[:100]}")
|
| 352 |
+
# 处理非文本内容(图片、文件等)
|
| 353 |
+
elif "inlineData" in part or "fileData" in part or "executableCode" in part or "codeExecutionResult" in part:
|
| 354 |
+
collected_other_parts.append(part)
|
| 355 |
+
log.debug(f"[STREAM COLLECTOR] Collected non-text part: {list(part.keys())}")
|
| 356 |
+
|
| 357 |
+
# 收集其他信息(使用最后一个块的值)
|
| 358 |
+
if candidate.get("finishReason"):
|
| 359 |
+
merged_response["response"]["candidates"][0]["finishReason"] = candidate["finishReason"]
|
| 360 |
+
|
| 361 |
+
if candidate.get("safetyRatings"):
|
| 362 |
+
merged_response["response"]["candidates"][0]["safetyRatings"] = candidate["safetyRatings"]
|
| 363 |
+
|
| 364 |
+
if candidate.get("citationMetadata"):
|
| 365 |
+
merged_response["response"]["candidates"][0]["citationMetadata"] = candidate["citationMetadata"]
|
| 366 |
+
|
| 367 |
+
# 更新使用元数据
|
| 368 |
+
usage = response_obj.get("usageMetadata", {})
|
| 369 |
+
if usage:
|
| 370 |
+
merged_response["response"]["usageMetadata"].update(usage)
|
| 371 |
+
|
| 372 |
+
except json.JSONDecodeError as e:
|
| 373 |
+
log.debug(f"[STREAM COLLECTOR] Failed to parse JSON chunk: {e}")
|
| 374 |
+
continue
|
| 375 |
+
except Exception as e:
|
| 376 |
+
log.debug(f"[STREAM COLLECTOR] Error processing chunk: {e}")
|
| 377 |
+
continue
|
| 378 |
+
|
| 379 |
+
except Exception as e:
|
| 380 |
+
log.error(f"[STREAM COLLECTOR] Error collecting stream after {line_count} lines: {e}")
|
| 381 |
+
return Response(
|
| 382 |
+
content=json.dumps({"error": f"收集流式响应失败: {str(e)}"}),
|
| 383 |
+
status_code=500,
|
| 384 |
+
media_type="application/json"
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
log.debug(f"[STREAM COLLECTOR] Finished iteration, has_data={has_data}, line_count={line_count}")
|
| 388 |
+
|
| 389 |
+
# 如果没有收集到任何数据,返回错误
|
| 390 |
+
if not has_data:
|
| 391 |
+
log.error(f"[STREAM COLLECTOR] No data collected from stream after {line_count} lines")
|
| 392 |
+
return Response(
|
| 393 |
+
content=json.dumps({"error": "No data collected from stream"}),
|
| 394 |
+
status_code=500,
|
| 395 |
+
media_type="application/json"
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
# 组装最终的parts
|
| 399 |
+
final_parts = []
|
| 400 |
+
|
| 401 |
+
# 先添加思维链内容(如果有)
|
| 402 |
+
if collected_thought_text:
|
| 403 |
+
final_parts.append({
|
| 404 |
+
"text": "".join(collected_thought_text),
|
| 405 |
+
"thought": True
|
| 406 |
+
})
|
| 407 |
+
|
| 408 |
+
# 再添加普通文本内容
|
| 409 |
+
if collected_text:
|
| 410 |
+
final_parts.append({
|
| 411 |
+
"text": "".join(collected_text)
|
| 412 |
+
})
|
| 413 |
+
|
| 414 |
+
# 添加其他类型的parts(图片、文件等)
|
| 415 |
+
final_parts.extend(collected_other_parts)
|
| 416 |
+
|
| 417 |
+
# 如果没有任何内容,添加空文本
|
| 418 |
+
if not final_parts:
|
| 419 |
+
final_parts.append({"text": ""})
|
| 420 |
+
|
| 421 |
+
merged_response["response"]["candidates"][0]["content"]["parts"] = final_parts
|
| 422 |
+
|
| 423 |
+
log.info(
|
| 424 |
+
f"[STREAM COLLECTOR] Collected {len(collected_text)} text chunks, "
|
| 425 |
+
f"{len(collected_thought_text)} thought chunks, {len(collected_other_parts)} other parts "
|
| 426 |
+
f"(tool parts: {collected_tool_parts_count})"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
# 去掉嵌套的 "response" 包装(Antigravity格式 -> 标准Gemini格式)
|
| 430 |
+
if "response" in merged_response and "candidates" not in merged_response:
|
| 431 |
+
log.debug(f"[STREAM COLLECTOR] 展开response包装")
|
| 432 |
+
merged_response = merged_response["response"]
|
| 433 |
+
|
| 434 |
+
# 返回纯JSON格式
|
| 435 |
+
return Response(
|
| 436 |
+
content=json.dumps(merged_response, ensure_ascii=False).encode('utf-8'),
|
| 437 |
+
status_code=200,
|
| 438 |
+
headers={},
|
| 439 |
+
media_type="application/json"
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
RESOURCE_EXHAUSTED_COOLDOWN_HOURS = 4 # RESOURCE_EXHAUSTED 错误的默认冷却时间(小时)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def parse_quota_reset_timestamp(error_response: dict) -> Optional[float]:
|
| 447 |
+
"""
|
| 448 |
+
从Google API错误响应中提取quota重置时间戳
|
| 449 |
+
|
| 450 |
+
Args:
|
| 451 |
+
error_response: Google API返回的错误响应字典
|
| 452 |
+
|
| 453 |
+
Returns:
|
| 454 |
+
Unix时间戳(秒),如果无法解析则返回None
|
| 455 |
+
|
| 456 |
+
示例错误响应:
|
| 457 |
+
{
|
| 458 |
+
"error": {
|
| 459 |
+
"code": 429,
|
| 460 |
+
"message": "You have exhausted your capacity...",
|
| 461 |
+
"status": "RESOURCE_EXHAUSTED",
|
| 462 |
+
"details": [
|
| 463 |
+
{
|
| 464 |
+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
| 465 |
+
"reason": "QUOTA_EXHAUSTED",
|
| 466 |
+
"metadata": {
|
| 467 |
+
"quotaResetTimeStamp": "2025-11-30T14:57:24Z",
|
| 468 |
+
"quotaResetDelay": "13h19m1.20964964s"
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
]
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
"""
|
| 475 |
+
try:
|
| 476 |
+
error_obj = error_response.get("error", {})
|
| 477 |
+
details = error_obj.get("details", [])
|
| 478 |
+
|
| 479 |
+
for detail in details:
|
| 480 |
+
if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo":
|
| 481 |
+
reset_timestamp_str = detail.get("metadata", {}).get("quotaResetTimeStamp")
|
| 482 |
+
|
| 483 |
+
if reset_timestamp_str:
|
| 484 |
+
if reset_timestamp_str.endswith("Z"):
|
| 485 |
+
reset_timestamp_str = reset_timestamp_str.replace("Z", "+00:00")
|
| 486 |
+
|
| 487 |
+
reset_dt = datetime.fromisoformat(reset_timestamp_str)
|
| 488 |
+
if reset_dt.tzinfo is None:
|
| 489 |
+
reset_dt = reset_dt.replace(tzinfo=timezone.utc)
|
| 490 |
+
|
| 491 |
+
return reset_dt.astimezone(timezone.utc).timestamp()
|
| 492 |
+
|
| 493 |
+
# 如果是 RESOURCE_EXHAUSTED 错误且消息完全匹配,设置默认4小时冷却时间
|
| 494 |
+
if (
|
| 495 |
+
error_obj.get("status") == "RESOURCE_EXHAUSTED"
|
| 496 |
+
and error_obj.get("message") == "Resource has been exhausted (e.g. check quota)."
|
| 497 |
+
):
|
| 498 |
+
import time
|
| 499 |
+
cooldown_until = time.time() + RESOURCE_EXHAUSTED_COOLDOWN_HOURS * 3600
|
| 500 |
+
return cooldown_until
|
| 501 |
+
|
| 502 |
+
return None
|
| 503 |
+
|
| 504 |
+
except Exception:
|
| 505 |
+
return None
|
src/auth.py
ADDED
|
@@ -0,0 +1,1089 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
认证API模块
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import socket
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import timezone
|
| 11 |
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
| 12 |
+
from typing import Any, Dict, Optional
|
| 13 |
+
from urllib.parse import parse_qs, urlparse
|
| 14 |
+
|
| 15 |
+
from config import get_config_value, get_code_assist_endpoint
|
| 16 |
+
from log import log
|
| 17 |
+
|
| 18 |
+
from .google_oauth_api import (
|
| 19 |
+
Credentials,
|
| 20 |
+
Flow,
|
| 21 |
+
enable_required_apis,
|
| 22 |
+
fetch_project_id_and_tier,
|
| 23 |
+
get_user_projects,
|
| 24 |
+
select_default_project,
|
| 25 |
+
)
|
| 26 |
+
from .storage_adapter import get_storage_adapter
|
| 27 |
+
from .utils import (
|
| 28 |
+
ANTIGRAVITY_CLIENT_ID,
|
| 29 |
+
ANTIGRAVITY_CLIENT_SECRET,
|
| 30 |
+
ANTIGRAVITY_SCOPES,
|
| 31 |
+
ANTIGRAVITY_USER_AGENT,
|
| 32 |
+
CALLBACK_HOST,
|
| 33 |
+
CLIENT_ID,
|
| 34 |
+
CLIENT_SECRET,
|
| 35 |
+
SCOPES,
|
| 36 |
+
GEMINICLI_USER_AGENT,
|
| 37 |
+
TOKEN_URL,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def get_callback_port():
|
| 42 |
+
"""获取OAuth回调端口"""
|
| 43 |
+
return int(await get_config_value("oauth_callback_port", "11451", "OAUTH_CALLBACK_PORT"))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _prepare_credentials_data(credentials: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> Dict[str, Any]:
|
| 47 |
+
"""准备凭证数据字典(统一函数)"""
|
| 48 |
+
if mode == "antigravity":
|
| 49 |
+
creds_data = {
|
| 50 |
+
"client_id": ANTIGRAVITY_CLIENT_ID,
|
| 51 |
+
"client_secret": ANTIGRAVITY_CLIENT_SECRET,
|
| 52 |
+
"token": credentials.access_token,
|
| 53 |
+
"refresh_token": credentials.refresh_token,
|
| 54 |
+
"scopes": ANTIGRAVITY_SCOPES,
|
| 55 |
+
"token_uri": TOKEN_URL,
|
| 56 |
+
"project_id": project_id,
|
| 57 |
+
}
|
| 58 |
+
else:
|
| 59 |
+
creds_data = {
|
| 60 |
+
"client_id": CLIENT_ID,
|
| 61 |
+
"client_secret": CLIENT_SECRET,
|
| 62 |
+
"token": credentials.access_token,
|
| 63 |
+
"refresh_token": credentials.refresh_token,
|
| 64 |
+
"scopes": SCOPES,
|
| 65 |
+
"token_uri": TOKEN_URL,
|
| 66 |
+
"project_id": project_id,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if credentials.expires_at:
|
| 70 |
+
if credentials.expires_at.tzinfo is None:
|
| 71 |
+
expiry_utc = credentials.expires_at.replace(tzinfo=timezone.utc)
|
| 72 |
+
else:
|
| 73 |
+
expiry_utc = credentials.expires_at
|
| 74 |
+
creds_data["expiry"] = expiry_utc.isoformat()
|
| 75 |
+
|
| 76 |
+
return creds_data
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _cleanup_auth_flow_server(state: str):
|
| 80 |
+
"""清理认证流程的服务器资源"""
|
| 81 |
+
if state in auth_flows:
|
| 82 |
+
flow_data_to_clean = auth_flows[state]
|
| 83 |
+
try:
|
| 84 |
+
if flow_data_to_clean.get("server"):
|
| 85 |
+
server = flow_data_to_clean["server"]
|
| 86 |
+
port = flow_data_to_clean.get("callback_port")
|
| 87 |
+
async_shutdown_server(server, port)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
log.debug(f"关闭服务器时出错: {e}")
|
| 90 |
+
del auth_flows[state]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class _OAuthLibPatcher:
|
| 94 |
+
"""oauthlib参数验证补丁的上下文管理器"""
|
| 95 |
+
def __init__(self):
|
| 96 |
+
import oauthlib.oauth2.rfc6749.parameters
|
| 97 |
+
self.module = oauthlib.oauth2.rfc6749.parameters
|
| 98 |
+
self.original_validate = None
|
| 99 |
+
|
| 100 |
+
def __enter__(self):
|
| 101 |
+
self.original_validate = self.module.validate_token_parameters
|
| 102 |
+
|
| 103 |
+
def patched_validate(params):
|
| 104 |
+
try:
|
| 105 |
+
return self.original_validate(params)
|
| 106 |
+
except Warning:
|
| 107 |
+
pass
|
| 108 |
+
|
| 109 |
+
self.module.validate_token_parameters = patched_validate
|
| 110 |
+
return self
|
| 111 |
+
|
| 112 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 113 |
+
if self.original_validate:
|
| 114 |
+
self.module.validate_token_parameters = self.original_validate
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# 全局状态管理 - 严格限制大小
|
| 118 |
+
auth_flows = {} # 存储进行中的认证流程
|
| 119 |
+
MAX_AUTH_FLOWS = 20 # 严格限制最大认证流程数
|
| 120 |
+
DEFAULT_PROJECT_ID = "gemini-pro-1751713012-07fc4dfd"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def cleanup_auth_flows_for_memory():
|
| 124 |
+
"""清理认证流程以释放内存"""
|
| 125 |
+
global auth_flows
|
| 126 |
+
cleanup_expired_flows()
|
| 127 |
+
# 如果还是太多,强制清理一些旧的流程
|
| 128 |
+
if len(auth_flows) > 10:
|
| 129 |
+
# 按创建时间排序,保留最新的10个
|
| 130 |
+
sorted_flows = sorted(
|
| 131 |
+
auth_flows.items(), key=lambda x: x[1].get("created_at", 0), reverse=True
|
| 132 |
+
)
|
| 133 |
+
new_auth_flows = dict(sorted_flows[:10])
|
| 134 |
+
|
| 135 |
+
# 清理被移除的流程
|
| 136 |
+
for state, flow_data in auth_flows.items():
|
| 137 |
+
if state not in new_auth_flows:
|
| 138 |
+
try:
|
| 139 |
+
if flow_data.get("server"):
|
| 140 |
+
server = flow_data["server"]
|
| 141 |
+
port = flow_data.get("callback_port")
|
| 142 |
+
async_shutdown_server(server, port)
|
| 143 |
+
except Exception:
|
| 144 |
+
pass
|
| 145 |
+
flow_data.clear()
|
| 146 |
+
|
| 147 |
+
auth_flows = new_auth_flows
|
| 148 |
+
log.info(f"强制清理认证流程,保留 {len(auth_flows)} 个最新流程")
|
| 149 |
+
|
| 150 |
+
return len(auth_flows)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
async def find_available_port(start_port: int = None) -> int:
|
| 154 |
+
"""动态查找可用端口"""
|
| 155 |
+
if start_port is None:
|
| 156 |
+
start_port = await get_callback_port()
|
| 157 |
+
|
| 158 |
+
# 首先尝试默认端口
|
| 159 |
+
for port in range(start_port, start_port + 100): # 尝试100个端口
|
| 160 |
+
try:
|
| 161 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 162 |
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 163 |
+
s.bind(("0.0.0.0", port))
|
| 164 |
+
log.info(f"找到可用端口: {port}")
|
| 165 |
+
return port
|
| 166 |
+
except OSError:
|
| 167 |
+
continue
|
| 168 |
+
|
| 169 |
+
# 如果都不可用,让系统自动分配端口
|
| 170 |
+
try:
|
| 171 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 172 |
+
s.bind(("0.0.0.0", 0))
|
| 173 |
+
port = s.getsockname()[1]
|
| 174 |
+
log.info(f"系统分配可用端口: {port}")
|
| 175 |
+
return port
|
| 176 |
+
except OSError as e:
|
| 177 |
+
log.error(f"无法找到可用端口: {e}")
|
| 178 |
+
raise RuntimeError("无法找到可用端口")
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def create_callback_server(port: int) -> HTTPServer:
|
| 182 |
+
"""创建指定端口的回调服务器,优化快速关闭"""
|
| 183 |
+
try:
|
| 184 |
+
# 服务器监听0.0.0.0
|
| 185 |
+
server = HTTPServer(("0.0.0.0", port), AuthCallbackHandler)
|
| 186 |
+
|
| 187 |
+
# 设置socket选项以支持快速关闭
|
| 188 |
+
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 189 |
+
# 设置较短的超时时间
|
| 190 |
+
server.timeout = 1.0
|
| 191 |
+
|
| 192 |
+
log.info(f"创建OAuth回调服务器,监听端口: {port}")
|
| 193 |
+
return server
|
| 194 |
+
except OSError as e:
|
| 195 |
+
log.error(f"创建端口{port}的服务器失败: {e}")
|
| 196 |
+
raise
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class AuthCallbackHandler(BaseHTTPRequestHandler):
|
| 200 |
+
"""OAuth回调处理器"""
|
| 201 |
+
|
| 202 |
+
def do_GET(self):
|
| 203 |
+
query_components = parse_qs(urlparse(self.path).query)
|
| 204 |
+
code = query_components.get("code", [None])[0]
|
| 205 |
+
state = query_components.get("state", [None])[0]
|
| 206 |
+
|
| 207 |
+
log.info(f"收到OAuth回调: code={'已获取' if code else '未获取'}, state={state}")
|
| 208 |
+
|
| 209 |
+
if code and state and state in auth_flows:
|
| 210 |
+
# 更新流程状态
|
| 211 |
+
auth_flows[state]["code"] = code
|
| 212 |
+
auth_flows[state]["completed"] = True
|
| 213 |
+
|
| 214 |
+
log.info(f"OAuth回调成功处理: state={state}")
|
| 215 |
+
|
| 216 |
+
self.send_response(200)
|
| 217 |
+
self.send_header("Content-type", "text/html")
|
| 218 |
+
self.end_headers()
|
| 219 |
+
# 成功页面
|
| 220 |
+
self.wfile.write(
|
| 221 |
+
b"<h1>OAuth authentication successful!</h1><p>You can close this window. Please return to the original page and click 'Get Credentials' button.</p>"
|
| 222 |
+
)
|
| 223 |
+
else:
|
| 224 |
+
self.send_response(400)
|
| 225 |
+
self.send_header("Content-type", "text/html")
|
| 226 |
+
self.end_headers()
|
| 227 |
+
self.wfile.write(b"<h1>Authentication failed.</h1><p>Please try again.</p>")
|
| 228 |
+
|
| 229 |
+
def log_message(self, format, *args):
|
| 230 |
+
# 减少日志噪音
|
| 231 |
+
pass
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
async def create_auth_url(
|
| 235 |
+
project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
|
| 236 |
+
) -> Dict[str, Any]:
|
| 237 |
+
"""创建认证URL,支持动态端口分配"""
|
| 238 |
+
try:
|
| 239 |
+
# 动态分配端口
|
| 240 |
+
callback_port = await find_available_port()
|
| 241 |
+
callback_url = f"http://{CALLBACK_HOST}:{callback_port}"
|
| 242 |
+
|
| 243 |
+
# 立即启动回调服务器
|
| 244 |
+
try:
|
| 245 |
+
callback_server = create_callback_server(callback_port)
|
| 246 |
+
# 在后台线程中运行服务器
|
| 247 |
+
server_thread = threading.Thread(
|
| 248 |
+
target=callback_server.serve_forever,
|
| 249 |
+
daemon=True,
|
| 250 |
+
name=f"OAuth-Server-{callback_port}",
|
| 251 |
+
)
|
| 252 |
+
server_thread.start()
|
| 253 |
+
log.info(f"OAuth回调服务器已启动,端口: {callback_port}")
|
| 254 |
+
except Exception as e:
|
| 255 |
+
log.error(f"启动回调服务器失败: {e}")
|
| 256 |
+
return {
|
| 257 |
+
"success": False,
|
| 258 |
+
"error": f"无法启动OAuth回调服务器,端口{callback_port}: {str(e)}",
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
# 创建OAuth流程
|
| 262 |
+
# 根据模式选择配置
|
| 263 |
+
if mode == "antigravity":
|
| 264 |
+
client_id = ANTIGRAVITY_CLIENT_ID
|
| 265 |
+
client_secret = ANTIGRAVITY_CLIENT_SECRET
|
| 266 |
+
scopes = ANTIGRAVITY_SCOPES
|
| 267 |
+
else:
|
| 268 |
+
client_id = CLIENT_ID
|
| 269 |
+
client_secret = CLIENT_SECRET
|
| 270 |
+
scopes = SCOPES
|
| 271 |
+
|
| 272 |
+
flow = Flow(
|
| 273 |
+
client_id=client_id,
|
| 274 |
+
client_secret=client_secret,
|
| 275 |
+
scopes=scopes,
|
| 276 |
+
redirect_uri=callback_url,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# 生成状态标识符,包含用户会话信息
|
| 280 |
+
if user_session:
|
| 281 |
+
state = f"{user_session}_{str(uuid.uuid4())}"
|
| 282 |
+
else:
|
| 283 |
+
state = str(uuid.uuid4())
|
| 284 |
+
|
| 285 |
+
# 生成认证URL
|
| 286 |
+
auth_url = flow.get_auth_url(state=state)
|
| 287 |
+
|
| 288 |
+
# 严格控制认证流程数量 - 超过限制时立即清理最旧的
|
| 289 |
+
if len(auth_flows) >= MAX_AUTH_FLOWS:
|
| 290 |
+
# 清理最旧的认证流程
|
| 291 |
+
oldest_state = min(auth_flows.keys(), key=lambda k: auth_flows[k].get("created_at", 0))
|
| 292 |
+
try:
|
| 293 |
+
# 清理服务器资源
|
| 294 |
+
old_flow = auth_flows[oldest_state]
|
| 295 |
+
if old_flow.get("server"):
|
| 296 |
+
server = old_flow["server"]
|
| 297 |
+
port = old_flow.get("callback_port")
|
| 298 |
+
async_shutdown_server(server, port)
|
| 299 |
+
except Exception as e:
|
| 300 |
+
log.warning(f"Failed to cleanup old auth flow {oldest_state}: {e}")
|
| 301 |
+
|
| 302 |
+
del auth_flows[oldest_state]
|
| 303 |
+
log.debug(f"Removed oldest auth flow: {oldest_state}")
|
| 304 |
+
|
| 305 |
+
# 保存流程状态
|
| 306 |
+
auth_flows[state] = {
|
| 307 |
+
"flow": flow,
|
| 308 |
+
"project_id": project_id, # 可能为None,稍后在回调时确定
|
| 309 |
+
"user_session": user_session,
|
| 310 |
+
"callback_port": callback_port, # 存储分配的端口
|
| 311 |
+
"callback_url": callback_url, # 存储完整回调URL
|
| 312 |
+
"server": callback_server, # 存储服务器实例
|
| 313 |
+
"server_thread": server_thread, # 存储服务器线程
|
| 314 |
+
"code": None,
|
| 315 |
+
"completed": False,
|
| 316 |
+
"created_at": time.time(),
|
| 317 |
+
"auto_project_detection": project_id is None, # 标记是否需要自动检测项目ID
|
| 318 |
+
"mode": mode, # 凭证模式
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
# 清理过期的流程(30分钟)
|
| 322 |
+
cleanup_expired_flows()
|
| 323 |
+
|
| 324 |
+
log.info(f"OAuth流程已创建: state={state}, project_id={project_id}")
|
| 325 |
+
log.info(f"用户需要访问认证URL,然后OAuth会回调到 {callback_url}")
|
| 326 |
+
log.info(f"为此认证流程分配的端口: {callback_port}")
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
"auth_url": auth_url,
|
| 330 |
+
"state": state,
|
| 331 |
+
"callback_port": callback_port,
|
| 332 |
+
"success": True,
|
| 333 |
+
"auto_project_detection": project_id is None,
|
| 334 |
+
"detected_project_id": project_id,
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
except Exception as e:
|
| 338 |
+
log.error(f"创建认证URL失败: {e}")
|
| 339 |
+
return {"success": False, "error": str(e)}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def wait_for_callback_sync(state: str, timeout: int = 300) -> Optional[str]:
|
| 343 |
+
"""同步等待OAuth回调完成,使用对应流程的专用服务器"""
|
| 344 |
+
if state not in auth_flows:
|
| 345 |
+
log.error(f"未找到状态为 {state} 的认证流程")
|
| 346 |
+
return None
|
| 347 |
+
|
| 348 |
+
flow_data = auth_flows[state]
|
| 349 |
+
callback_port = flow_data["callback_port"]
|
| 350 |
+
|
| 351 |
+
# 服务器已经在create_auth_url时启动了,这里只需要等待
|
| 352 |
+
log.info(f"等待OAuth回调完成,端口: {callback_port}")
|
| 353 |
+
|
| 354 |
+
# 等待回调完成
|
| 355 |
+
start_time = time.time()
|
| 356 |
+
while time.time() - start_time < timeout:
|
| 357 |
+
if flow_data.get("code"):
|
| 358 |
+
log.info("OAuth回调成功完成")
|
| 359 |
+
return flow_data["code"]
|
| 360 |
+
time.sleep(0.5) # 每0.5秒检查一次
|
| 361 |
+
|
| 362 |
+
# 刷新flow_data引用
|
| 363 |
+
if state in auth_flows:
|
| 364 |
+
flow_data = auth_flows[state]
|
| 365 |
+
|
| 366 |
+
log.warning(f"等待OAuth回调超时 ({timeout}秒)")
|
| 367 |
+
return None
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
async def complete_auth_flow(
|
| 371 |
+
project_id: Optional[str] = None, user_session: str = None
|
| 372 |
+
) -> Dict[str, Any]:
|
| 373 |
+
"""完成认证流程并保存凭证,支持自动检测项目ID"""
|
| 374 |
+
try:
|
| 375 |
+
# 查找对应的认证流程
|
| 376 |
+
state = None
|
| 377 |
+
flow_data = None
|
| 378 |
+
|
| 379 |
+
# 如果指定了project_id,先尝试匹配指定的项目
|
| 380 |
+
if project_id:
|
| 381 |
+
for s, data in auth_flows.items():
|
| 382 |
+
if data["project_id"] == project_id:
|
| 383 |
+
# 如果指定了用户会话,优先匹配相同会话的流程
|
| 384 |
+
if user_session and data.get("user_session") == user_session:
|
| 385 |
+
state = s
|
| 386 |
+
flow_data = data
|
| 387 |
+
break
|
| 388 |
+
# 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
|
| 389 |
+
elif not state:
|
| 390 |
+
state = s
|
| 391 |
+
flow_data = data
|
| 392 |
+
|
| 393 |
+
# 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
|
| 394 |
+
if not state:
|
| 395 |
+
for s, data in auth_flows.items():
|
| 396 |
+
if data.get("auto_project_detection", False):
|
| 397 |
+
# 如果指定了用户会话,优先匹配相同会话的流程
|
| 398 |
+
if user_session and data.get("user_session") == user_session:
|
| 399 |
+
state = s
|
| 400 |
+
flow_data = data
|
| 401 |
+
break
|
| 402 |
+
# 使用第一个找到的需要自动检测的流程
|
| 403 |
+
elif not state:
|
| 404 |
+
state = s
|
| 405 |
+
flow_data = data
|
| 406 |
+
|
| 407 |
+
if not state or not flow_data:
|
| 408 |
+
return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
|
| 409 |
+
|
| 410 |
+
if not project_id:
|
| 411 |
+
project_id = flow_data.get("project_id")
|
| 412 |
+
if not project_id:
|
| 413 |
+
project_id = DEFAULT_PROJECT_ID
|
| 414 |
+
log.warning(f"未获取到project_id,使用默认project_id: {project_id}")
|
| 415 |
+
|
| 416 |
+
flow = flow_data["flow"]
|
| 417 |
+
|
| 418 |
+
# 如果还没有���权码,需要等待回调
|
| 419 |
+
if not flow_data.get("code"):
|
| 420 |
+
log.info(f"等待用户完成OAuth授权 (state: {state})")
|
| 421 |
+
auth_code = wait_for_callback_sync(state)
|
| 422 |
+
|
| 423 |
+
if not auth_code:
|
| 424 |
+
return {
|
| 425 |
+
"success": False,
|
| 426 |
+
"error": "未接收到授权回调,请确保完成了浏览器中的OAuth认证",
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
# 更新流程数据
|
| 430 |
+
auth_flows[state]["code"] = auth_code
|
| 431 |
+
auth_flows[state]["completed"] = True
|
| 432 |
+
else:
|
| 433 |
+
auth_code = flow_data["code"]
|
| 434 |
+
|
| 435 |
+
# 使用认证代码获取凭证
|
| 436 |
+
with _OAuthLibPatcher():
|
| 437 |
+
try:
|
| 438 |
+
credentials = await flow.exchange_code(auth_code)
|
| 439 |
+
# credentials 已经在 exchange_code 中获得
|
| 440 |
+
|
| 441 |
+
# 如果需要自动检测项目ID且没有提供项目ID
|
| 442 |
+
if flow_data.get("auto_project_detection", False) and not project_id:
|
| 443 |
+
log.info("尝试通过API获取用户项目列表...")
|
| 444 |
+
log.info(f"使用的token: {credentials.access_token[:20]}...")
|
| 445 |
+
log.info(f"Token过期时间: {credentials.expires_at}")
|
| 446 |
+
user_projects = await get_user_projects(credentials)
|
| 447 |
+
|
| 448 |
+
if user_projects:
|
| 449 |
+
# 如果只有一个项目,自动使用
|
| 450 |
+
if len(user_projects) == 1:
|
| 451 |
+
# Google API returns projectId in camelCase
|
| 452 |
+
project_id = user_projects[0].get("projectId")
|
| 453 |
+
if project_id:
|
| 454 |
+
flow_data["project_id"] = project_id
|
| 455 |
+
log.info(f"自动选择唯一项目: {project_id}")
|
| 456 |
+
# 如果有多个项目,尝试选择默认项目
|
| 457 |
+
else:
|
| 458 |
+
project_id = await select_default_project(user_projects)
|
| 459 |
+
if project_id:
|
| 460 |
+
flow_data["project_id"] = project_id
|
| 461 |
+
log.info(f"自动选择默认项目: {project_id}")
|
| 462 |
+
else:
|
| 463 |
+
# 返回项目列表让用户选择
|
| 464 |
+
return {
|
| 465 |
+
"success": False,
|
| 466 |
+
"error": "请从以下项目中选择一个",
|
| 467 |
+
"requires_project_selection": True,
|
| 468 |
+
"available_projects": [
|
| 469 |
+
{
|
| 470 |
+
# Google API returns projectId in camelCase
|
| 471 |
+
"project_id": p.get("projectId"),
|
| 472 |
+
"name": p.get("displayName") or p.get("projectId"),
|
| 473 |
+
"projectNumber": p.get("projectNumber"),
|
| 474 |
+
}
|
| 475 |
+
for p in user_projects
|
| 476 |
+
],
|
| 477 |
+
}
|
| 478 |
+
else:
|
| 479 |
+
# 如果无法获取项目列表,使用默认project_id
|
| 480 |
+
project_id = DEFAULT_PROJECT_ID
|
| 481 |
+
flow_data["project_id"] = project_id
|
| 482 |
+
log.warning(f"无法获取项目列表,使用默认project_id: {project_id}")
|
| 483 |
+
|
| 484 |
+
# 如果仍然没有项目ID,返回错误
|
| 485 |
+
if not project_id:
|
| 486 |
+
project_id = DEFAULT_PROJECT_ID
|
| 487 |
+
flow_data["project_id"] = project_id
|
| 488 |
+
log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}")
|
| 489 |
+
|
| 490 |
+
# 保存凭证
|
| 491 |
+
saved_filename = await save_credentials(credentials, project_id)
|
| 492 |
+
|
| 493 |
+
# 准备返回的凭证数据
|
| 494 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
|
| 495 |
+
|
| 496 |
+
# 清理使用过的流程
|
| 497 |
+
_cleanup_auth_flow_server(state)
|
| 498 |
+
|
| 499 |
+
log.info("OAuth认证成功,凭证已保存")
|
| 500 |
+
return {
|
| 501 |
+
"success": True,
|
| 502 |
+
"credentials": creds_data,
|
| 503 |
+
"file_path": saved_filename,
|
| 504 |
+
"auto_detected_project": flow_data.get("auto_project_detection", False),
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
except Exception as e:
|
| 508 |
+
log.error(f"获取凭证失败: {e}")
|
| 509 |
+
return {"success": False, "error": f"获取凭证失败: {str(e)}"}
|
| 510 |
+
|
| 511 |
+
except Exception as e:
|
| 512 |
+
log.error(f"完成认证流程失败: {e}")
|
| 513 |
+
return {"success": False, "error": str(e)}
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
async def asyncio_complete_auth_flow(
|
| 517 |
+
project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
|
| 518 |
+
) -> Dict[str, Any]:
|
| 519 |
+
"""异步完成认证流程,支持自动检测项目ID"""
|
| 520 |
+
try:
|
| 521 |
+
log.info(
|
| 522 |
+
f"asyncio_complete_auth_flow开始执行: project_id={project_id}, user_session={user_session}"
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
# 查找对应的认证流程
|
| 526 |
+
state = None
|
| 527 |
+
flow_data = None
|
| 528 |
+
|
| 529 |
+
log.debug(f"当前所有auth_flows: {list(auth_flows.keys())}")
|
| 530 |
+
|
| 531 |
+
# 如果指定了project_id,先尝试匹配指定的项目
|
| 532 |
+
if project_id:
|
| 533 |
+
log.info(f"尝试匹配指定的项目ID: {project_id}")
|
| 534 |
+
for s, data in auth_flows.items():
|
| 535 |
+
if data["project_id"] == project_id:
|
| 536 |
+
# 如果指定了用户会话,优先匹配相同会话的流程
|
| 537 |
+
if user_session and data.get("user_session") == user_session:
|
| 538 |
+
state = s
|
| 539 |
+
flow_data = data
|
| 540 |
+
log.info(f"找到匹配的用户会话: {s}")
|
| 541 |
+
break
|
| 542 |
+
# 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
|
| 543 |
+
elif not state:
|
| 544 |
+
state = s
|
| 545 |
+
flow_data = data
|
| 546 |
+
log.info(f"找到匹配的项目ID: {s}")
|
| 547 |
+
|
| 548 |
+
# 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
|
| 549 |
+
if not state:
|
| 550 |
+
log.info("没有找到指定项目的流程,查找自动检测流程")
|
| 551 |
+
# 首先尝试找到已完成的流程(有授权码的)
|
| 552 |
+
completed_flows = []
|
| 553 |
+
for s, data in auth_flows.items():
|
| 554 |
+
if data.get("auto_project_detection", False):
|
| 555 |
+
if user_session and data.get("user_session") == user_session:
|
| 556 |
+
if data.get("code"): # 优先选择已完成的
|
| 557 |
+
completed_flows.append((s, data, data.get("created_at", 0)))
|
| 558 |
+
|
| 559 |
+
# 如果有已完成的流程,选择最新的
|
| 560 |
+
if completed_flows:
|
| 561 |
+
completed_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
|
| 562 |
+
state, flow_data, _ = completed_flows[0]
|
| 563 |
+
log.info(f"找到已完成的最新认证流程: {state}")
|
| 564 |
+
else:
|
| 565 |
+
# 如果没有已完成的,找最新的未完成流程
|
| 566 |
+
pending_flows = []
|
| 567 |
+
for s, data in auth_flows.items():
|
| 568 |
+
if data.get("auto_project_detection", False):
|
| 569 |
+
if user_session and data.get("user_session") == user_session:
|
| 570 |
+
pending_flows.append((s, data, data.get("created_at", 0)))
|
| 571 |
+
elif not user_session:
|
| 572 |
+
pending_flows.append((s, data, data.get("created_at", 0)))
|
| 573 |
+
|
| 574 |
+
if pending_flows:
|
| 575 |
+
pending_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
|
| 576 |
+
state, flow_data, _ = pending_flows[0]
|
| 577 |
+
log.info(f"找到最新的待完成认证流程: {state}")
|
| 578 |
+
|
| 579 |
+
if not state or not flow_data:
|
| 580 |
+
log.error(f"未找到认证流程: state={state}, flow_data存在={bool(flow_data)}")
|
| 581 |
+
log.debug(f"当前所有flow_data: {list(auth_flows.keys())}")
|
| 582 |
+
return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
|
| 583 |
+
|
| 584 |
+
log.info(f"找到认证流程: state={state}")
|
| 585 |
+
log.info(
|
| 586 |
+
f"flow_data内容: project_id={flow_data.get('project_id')}, auto_project_detection={flow_data.get('auto_project_detection')}"
|
| 587 |
+
)
|
| 588 |
+
log.info(f"传入的project_id参数: {project_id}")
|
| 589 |
+
|
| 590 |
+
# 如果需要自动检测项目ID且没有提供项目ID
|
| 591 |
+
log.info(
|
| 592 |
+
f"检查auto_project_detection条件: auto_project_detection={flow_data.get('auto_project_detection', False)}, not project_id={not project_id}"
|
| 593 |
+
)
|
| 594 |
+
if flow_data.get("auto_project_detection", False) and not project_id:
|
| 595 |
+
log.info("跳过自动检测项目ID,进入等待阶段")
|
| 596 |
+
elif not project_id:
|
| 597 |
+
log.info("进入project_id检查分支")
|
| 598 |
+
project_id = flow_data.get("project_id")
|
| 599 |
+
if not project_id:
|
| 600 |
+
project_id = DEFAULT_PROJECT_ID
|
| 601 |
+
flow_data["project_id"] = project_id
|
| 602 |
+
log.warning(f"缺少项目ID,使用默认project_id: {project_id}")
|
| 603 |
+
else:
|
| 604 |
+
log.info(f"使用提供的项目ID: {project_id}")
|
| 605 |
+
|
| 606 |
+
# 检查是否已经有授权码
|
| 607 |
+
log.info("开始检查OAuth授权码...")
|
| 608 |
+
log.info(f"等待state={state}的授权回调,回调端口: {flow_data.get('callback_port')}")
|
| 609 |
+
log.info(f"当前flow_data状态: completed={flow_data.get('completed')}, code存在={bool(flow_data.get('code'))}")
|
| 610 |
+
max_wait_time = 60 # 最多等待60秒
|
| 611 |
+
wait_interval = 1 # 每秒检查一次
|
| 612 |
+
waited = 0
|
| 613 |
+
|
| 614 |
+
while waited < max_wait_time:
|
| 615 |
+
if flow_data.get("code"):
|
| 616 |
+
log.info(f"检测到OAuth授权码,开始处理凭证 (等待时间: {waited}秒)")
|
| 617 |
+
break
|
| 618 |
+
|
| 619 |
+
# 每5秒输出一次提示
|
| 620 |
+
if waited % 5 == 0 and waited > 0:
|
| 621 |
+
log.info(f"仍在等待OAuth授权... ({waited}/{max_wait_time}秒)")
|
| 622 |
+
log.debug(f"当前state: {state}, flow_data keys: {list(flow_data.keys())}")
|
| 623 |
+
|
| 624 |
+
# 异步等待
|
| 625 |
+
await asyncio.sleep(wait_interval)
|
| 626 |
+
waited += wait_interval
|
| 627 |
+
|
| 628 |
+
# 刷新flow_data引用,因为可能被回调更新了
|
| 629 |
+
if state in auth_flows:
|
| 630 |
+
flow_data = auth_flows[state]
|
| 631 |
+
|
| 632 |
+
if not flow_data.get("code"):
|
| 633 |
+
log.error(f"等待OAuth回调超时,等待了{waited}秒")
|
| 634 |
+
return {
|
| 635 |
+
"success": False,
|
| 636 |
+
"error": "等待OAuth回调超时,请确保完成了浏览器中的认证并看到成功页面",
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
flow = flow_data["flow"]
|
| 640 |
+
auth_code = flow_data["code"]
|
| 641 |
+
|
| 642 |
+
log.info(f"开始使用授权码获取凭证: code={'***' + auth_code[-4:] if auth_code else 'None'}")
|
| 643 |
+
|
| 644 |
+
# 使用认证代码获取凭证
|
| 645 |
+
with _OAuthLibPatcher():
|
| 646 |
+
try:
|
| 647 |
+
log.info("调用flow.exchange_code...")
|
| 648 |
+
credentials = await flow.exchange_code(auth_code)
|
| 649 |
+
log.info(
|
| 650 |
+
f"成功获取凭证,token前缀: {credentials.access_token[:20] if credentials.access_token else 'None'}..."
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
log.info(
|
| 654 |
+
f"检查是否需要项目检测: auto_project_detection={flow_data.get('auto_project_detection')}, project_id={project_id}"
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
# 检查凭证模式
|
| 658 |
+
cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
|
| 659 |
+
if cred_mode == "antigravity":
|
| 660 |
+
log.info("Antigravity模式:从API获取project_id...")
|
| 661 |
+
# 使用API获取project_id
|
| 662 |
+
antigravity_url = await get_code_assist_endpoint()
|
| 663 |
+
project_id, subscription_tier = await fetch_project_id_and_tier(
|
| 664 |
+
credentials.access_token,
|
| 665 |
+
ANTIGRAVITY_USER_AGENT,
|
| 666 |
+
antigravity_url
|
| 667 |
+
)
|
| 668 |
+
if project_id:
|
| 669 |
+
log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}")
|
| 670 |
+
else:
|
| 671 |
+
project_id = DEFAULT_PROJECT_ID
|
| 672 |
+
log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}")
|
| 673 |
+
|
| 674 |
+
# 保存antigravity凭证
|
| 675 |
+
saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
|
| 676 |
+
|
| 677 |
+
# 准备返回的凭证数据
|
| 678 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
|
| 679 |
+
|
| 680 |
+
# 清理使用过的流程
|
| 681 |
+
_cleanup_auth_flow_server(state)
|
| 682 |
+
|
| 683 |
+
log.info("Antigravity OAuth认证成功,凭证已保存")
|
| 684 |
+
return {
|
| 685 |
+
"success": True,
|
| 686 |
+
"credentials": creds_data,
|
| 687 |
+
"file_path": saved_filename,
|
| 688 |
+
"auto_detected_project": False,
|
| 689 |
+
"mode": "antigravity",
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
# 如果需要自动检测项目ID且没有提供项目ID(标准模式)
|
| 693 |
+
if flow_data.get("auto_project_detection", False) and not project_id:
|
| 694 |
+
log.info("标准模式:从API获取project_id...")
|
| 695 |
+
# 使用API获取project_id(使用标准模式的User-Agent)
|
| 696 |
+
code_assist_url = await get_code_assist_endpoint()
|
| 697 |
+
project_id, subscription_tier = await fetch_project_id_and_tier(
|
| 698 |
+
credentials.access_token,
|
| 699 |
+
GEMINICLI_USER_AGENT,
|
| 700 |
+
code_assist_url
|
| 701 |
+
)
|
| 702 |
+
if project_id:
|
| 703 |
+
flow_data["project_id"] = project_id
|
| 704 |
+
log.info(f"成功从API获取project_id: {project_id}")
|
| 705 |
+
# 自动启用必需的API服务
|
| 706 |
+
log.info("正在自动启用必需的API服务...")
|
| 707 |
+
await enable_required_apis(credentials, project_id)
|
| 708 |
+
else:
|
| 709 |
+
log.warning("无法从API获取project_id,回退到项目列表获取方式")
|
| 710 |
+
# 回退到原来的项目列表获取方式
|
| 711 |
+
user_projects = await get_user_projects(credentials)
|
| 712 |
+
|
| 713 |
+
if user_projects:
|
| 714 |
+
# 如果只有一个项目,自动使用
|
| 715 |
+
if len(user_projects) == 1:
|
| 716 |
+
# Google API returns projectId in camelCase
|
| 717 |
+
project_id = user_projects[0].get("projectId")
|
| 718 |
+
if project_id:
|
| 719 |
+
flow_data["project_id"] = project_id
|
| 720 |
+
log.info(f"自动选择唯一项目: {project_id}")
|
| 721 |
+
# 自动启用必需的API服务
|
| 722 |
+
log.info("正在自动启用必需的API服务...")
|
| 723 |
+
await enable_required_apis(credentials, project_id)
|
| 724 |
+
# 如果有多个项目,尝试选择默认项目
|
| 725 |
+
else:
|
| 726 |
+
project_id = await select_default_project(user_projects)
|
| 727 |
+
if project_id:
|
| 728 |
+
flow_data["project_id"] = project_id
|
| 729 |
+
log.info(f"自动选择默认项目: {project_id}")
|
| 730 |
+
# 自动启用必需的API服务
|
| 731 |
+
log.info("正在自动启用必需的API服务...")
|
| 732 |
+
await enable_required_apis(credentials, project_id)
|
| 733 |
+
else:
|
| 734 |
+
# 返回项目列表让用户选择
|
| 735 |
+
return {
|
| 736 |
+
"success": False,
|
| 737 |
+
"error": "请从以下项目中选择一个",
|
| 738 |
+
"requires_project_selection": True,
|
| 739 |
+
"available_projects": [
|
| 740 |
+
{
|
| 741 |
+
# Google API returns projectId in camelCase
|
| 742 |
+
"project_id": p.get("projectId"),
|
| 743 |
+
"name": p.get("displayName") or p.get("projectId"),
|
| 744 |
+
"projectNumber": p.get("projectNumber"),
|
| 745 |
+
}
|
| 746 |
+
for p in user_projects
|
| 747 |
+
],
|
| 748 |
+
}
|
| 749 |
+
else:
|
| 750 |
+
# 如果无法获取项目列表,使用默认project_id
|
| 751 |
+
project_id = DEFAULT_PROJECT_ID
|
| 752 |
+
flow_data["project_id"] = project_id
|
| 753 |
+
log.warning(f"无法获取项目列表,使用默认project_id: {project_id}")
|
| 754 |
+
elif project_id:
|
| 755 |
+
# 如果已经有项目ID(手动提供或环境检测),也尝试启用API服务
|
| 756 |
+
log.info("正在为已提供的项目ID自动启用必需的API服务...")
|
| 757 |
+
await enable_required_apis(credentials, project_id)
|
| 758 |
+
|
| 759 |
+
# 如果仍然没有项目ID,返回错误
|
| 760 |
+
if not project_id:
|
| 761 |
+
project_id = DEFAULT_PROJECT_ID
|
| 762 |
+
flow_data["project_id"] = project_id
|
| 763 |
+
log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}")
|
| 764 |
+
|
| 765 |
+
# 保存凭证
|
| 766 |
+
saved_filename = await save_credentials(credentials, project_id)
|
| 767 |
+
|
| 768 |
+
# 准备返回的凭证数据
|
| 769 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
|
| 770 |
+
|
| 771 |
+
# 清理使用过的流程
|
| 772 |
+
_cleanup_auth_flow_server(state)
|
| 773 |
+
|
| 774 |
+
log.info("OAuth认证成功,凭证已保存")
|
| 775 |
+
return {
|
| 776 |
+
"success": True,
|
| 777 |
+
"credentials": creds_data,
|
| 778 |
+
"file_path": saved_filename,
|
| 779 |
+
"auto_detected_project": flow_data.get("auto_project_detection", False),
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
except Exception as e:
|
| 783 |
+
log.error(f"获取凭证失败: {e}")
|
| 784 |
+
return {"success": False, "error": f"获取凭证失败: {str(e)}"}
|
| 785 |
+
|
| 786 |
+
except Exception as e:
|
| 787 |
+
log.error(f"异步完成认证流程失败: {e}")
|
| 788 |
+
return {"success": False, "error": str(e)}
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
async def complete_auth_flow_from_callback_url(
|
| 792 |
+
callback_url: str, project_id: Optional[str] = None, mode: str = "geminicli"
|
| 793 |
+
) -> Dict[str, Any]:
|
| 794 |
+
"""从回调URL直接完成认证流程,无需启动本地服务器"""
|
| 795 |
+
try:
|
| 796 |
+
log.info(f"开始从回调URL完成认证: {callback_url}")
|
| 797 |
+
|
| 798 |
+
# 解析回调URL
|
| 799 |
+
parsed_url = urlparse(callback_url)
|
| 800 |
+
query_params = parse_qs(parsed_url.query)
|
| 801 |
+
|
| 802 |
+
# 验证必要参数
|
| 803 |
+
if "state" not in query_params or "code" not in query_params:
|
| 804 |
+
return {"success": False, "error": "回调URL缺少必要参数 (state 或 code)"}
|
| 805 |
+
|
| 806 |
+
state = query_params["state"][0]
|
| 807 |
+
code = query_params["code"][0]
|
| 808 |
+
|
| 809 |
+
log.info(f"从URL解析到: state={state}, code=xxx...")
|
| 810 |
+
|
| 811 |
+
# 检查是否有对应的认证流程
|
| 812 |
+
if state not in auth_flows:
|
| 813 |
+
return {
|
| 814 |
+
"success": False,
|
| 815 |
+
"error": f"未找到对应的认证流程,请先启动认证 (state: {state})",
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
flow_data = auth_flows[state]
|
| 819 |
+
flow = flow_data["flow"]
|
| 820 |
+
|
| 821 |
+
# 构造回调URL(使用flow中存储的redirect_uri)
|
| 822 |
+
redirect_uri = flow.redirect_uri
|
| 823 |
+
log.info(f"使用redirect_uri: {redirect_uri}")
|
| 824 |
+
|
| 825 |
+
try:
|
| 826 |
+
# 使用authorization code获取token
|
| 827 |
+
credentials = await flow.exchange_code(code)
|
| 828 |
+
log.info("成功获取访问令牌")
|
| 829 |
+
|
| 830 |
+
# 检查凭证模式
|
| 831 |
+
cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
|
| 832 |
+
if cred_mode == "antigravity":
|
| 833 |
+
log.info("Antigravity模式(从回调URL):从API获取project_id...")
|
| 834 |
+
# 使用API获取project_id
|
| 835 |
+
antigravity_url = await get_code_assist_endpoint()
|
| 836 |
+
project_id, subscription_tier = await fetch_project_id_and_tier(
|
| 837 |
+
credentials.access_token,
|
| 838 |
+
ANTIGRAVITY_USER_AGENT,
|
| 839 |
+
antigravity_url
|
| 840 |
+
)
|
| 841 |
+
if project_id:
|
| 842 |
+
log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}")
|
| 843 |
+
else:
|
| 844 |
+
project_id = DEFAULT_PROJECT_ID
|
| 845 |
+
log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}")
|
| 846 |
+
|
| 847 |
+
# 保存antigravity凭证
|
| 848 |
+
saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
|
| 849 |
+
|
| 850 |
+
# 准备返回的凭证数据
|
| 851 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
|
| 852 |
+
|
| 853 |
+
# 清理使用过的流程
|
| 854 |
+
_cleanup_auth_flow_server(state)
|
| 855 |
+
|
| 856 |
+
log.info("从回调URL完成Antigravity OAuth认证成功,凭证已保存")
|
| 857 |
+
return {
|
| 858 |
+
"success": True,
|
| 859 |
+
"credentials": creds_data,
|
| 860 |
+
"file_path": saved_filename,
|
| 861 |
+
"auto_detected_project": False,
|
| 862 |
+
"mode": "antigravity",
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
# 标准模式的项目ID处理逻辑
|
| 866 |
+
detected_project_id = None
|
| 867 |
+
auto_detected = False
|
| 868 |
+
subscription_tier = None
|
| 869 |
+
|
| 870 |
+
if not project_id:
|
| 871 |
+
# 尝试使用fetch_project_id_and_tier自动获取项目ID
|
| 872 |
+
try:
|
| 873 |
+
log.info("标准模式:从API获取project_id...")
|
| 874 |
+
code_assist_url = await get_code_assist_endpoint()
|
| 875 |
+
detected_project_id, subscription_tier = await fetch_project_id_and_tier(
|
| 876 |
+
credentials.access_token,
|
| 877 |
+
GEMINICLI_USER_AGENT,
|
| 878 |
+
code_assist_url
|
| 879 |
+
)
|
| 880 |
+
if detected_project_id:
|
| 881 |
+
auto_detected = True
|
| 882 |
+
log.info(f"成功从API获取project_id: {detected_project_id}, tier: {subscription_tier}")
|
| 883 |
+
else:
|
| 884 |
+
log.warning("无法从API获取project_id,回退到项目列表获取方式")
|
| 885 |
+
# 回退到原来的项目列表获取方式
|
| 886 |
+
projects = await get_user_projects(credentials)
|
| 887 |
+
if projects:
|
| 888 |
+
if len(projects) == 1:
|
| 889 |
+
# 只有一个项目,自动使用
|
| 890 |
+
# Google API returns projectId in camelCase
|
| 891 |
+
detected_project_id = projects[0]["projectId"]
|
| 892 |
+
auto_detected = True
|
| 893 |
+
log.info(f"自动检测到唯一项目ID: {detected_project_id}")
|
| 894 |
+
else:
|
| 895 |
+
# 多个项目,自动选择第一个
|
| 896 |
+
# Google API returns projectId in camelCase
|
| 897 |
+
detected_project_id = projects[0]["projectId"]
|
| 898 |
+
auto_detected = True
|
| 899 |
+
log.info(
|
| 900 |
+
f"检测到{len(projects)}个项目,自动选择第一个: {detected_project_id}"
|
| 901 |
+
)
|
| 902 |
+
log.debug(f"其他可用项目: {[p['projectId'] for p in projects[1:]]}")
|
| 903 |
+
else:
|
| 904 |
+
# 没有项目访问权限,使用默认project_id
|
| 905 |
+
detected_project_id = DEFAULT_PROJECT_ID
|
| 906 |
+
auto_detected = False
|
| 907 |
+
log.warning(f"未检测到可访问项目,使用默认project_id: {detected_project_id}")
|
| 908 |
+
except Exception as e:
|
| 909 |
+
log.warning(f"自动检测项目ID失败: {e},使用默认project_id")
|
| 910 |
+
detected_project_id = DEFAULT_PROJECT_ID
|
| 911 |
+
auto_detected = False
|
| 912 |
+
else:
|
| 913 |
+
detected_project_id = project_id
|
| 914 |
+
|
| 915 |
+
# 启用必需的API服务
|
| 916 |
+
if detected_project_id:
|
| 917 |
+
try:
|
| 918 |
+
log.info(f"正在为项目 {detected_project_id} 启用必需的API服务...")
|
| 919 |
+
await enable_required_apis(credentials, detected_project_id)
|
| 920 |
+
except Exception as e:
|
| 921 |
+
log.warning(f"启用API服务失败: {e}")
|
| 922 |
+
|
| 923 |
+
# 保存凭证
|
| 924 |
+
saved_filename = await save_credentials(credentials, detected_project_id, subscription_tier=subscription_tier)
|
| 925 |
+
|
| 926 |
+
# 准备返回的凭证数据
|
| 927 |
+
creds_data = _prepare_credentials_data(credentials, detected_project_id, mode="geminicli", subscription_tier=subscription_tier)
|
| 928 |
+
|
| 929 |
+
# 清理使用过的流程
|
| 930 |
+
_cleanup_auth_flow_server(state)
|
| 931 |
+
|
| 932 |
+
log.info("从回调URL完成OAuth认证成功,凭证已保存")
|
| 933 |
+
return {
|
| 934 |
+
"success": True,
|
| 935 |
+
"credentials": creds_data,
|
| 936 |
+
"file_path": saved_filename,
|
| 937 |
+
"auto_detected_project": auto_detected,
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
except Exception as e:
|
| 941 |
+
log.error(f"从回调URL获取凭证失败: {e}")
|
| 942 |
+
return {"success": False, "error": f"获取凭证失败: {str(e)}"}
|
| 943 |
+
|
| 944 |
+
except Exception as e:
|
| 945 |
+
log.error(f"从回调URL完成认证流程失败: {e}")
|
| 946 |
+
return {"success": False, "error": str(e)}
|
| 947 |
+
|
| 948 |
+
|
| 949 |
+
async def save_credentials(creds: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> str:
|
| 950 |
+
"""通过统一存储系统保存凭证"""
|
| 951 |
+
# 生成文件名(使用project_id和时间戳)
|
| 952 |
+
timestamp = int(time.time())
|
| 953 |
+
|
| 954 |
+
# antigravity模式使用特殊前缀
|
| 955 |
+
if mode == "antigravity":
|
| 956 |
+
filename = f"ag_{project_id}-{timestamp}.json"
|
| 957 |
+
else:
|
| 958 |
+
filename = f"{project_id}-{timestamp}.json"
|
| 959 |
+
|
| 960 |
+
# 准备凭证数据
|
| 961 |
+
creds_data = _prepare_credentials_data(creds, project_id, mode, subscription_tier)
|
| 962 |
+
|
| 963 |
+
# 通过存储适配器保存
|
| 964 |
+
storage_adapter = await get_storage_adapter()
|
| 965 |
+
success = await storage_adapter.store_credential(filename, creds_data, mode=mode)
|
| 966 |
+
|
| 967 |
+
if success:
|
| 968 |
+
# 创建默认状态记录
|
| 969 |
+
try:
|
| 970 |
+
default_state = {
|
| 971 |
+
"error_codes": [],
|
| 972 |
+
"disabled": False,
|
| 973 |
+
"last_success": time.time(),
|
| 974 |
+
"user_email": None,
|
| 975 |
+
"tier": subscription_tier,
|
| 976 |
+
}
|
| 977 |
+
await storage_adapter.update_credential_state(filename, default_state, mode=mode)
|
| 978 |
+
log.info(f"凭证和状态已保存到: {filename} (mode={mode})")
|
| 979 |
+
except Exception as e:
|
| 980 |
+
log.warning(f"创建默认状态记录失败 {filename}: {e}")
|
| 981 |
+
|
| 982 |
+
return filename
|
| 983 |
+
else:
|
| 984 |
+
raise Exception(f"保存凭证失败: {filename}")
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
def async_shutdown_server(server, port):
|
| 988 |
+
"""异步关闭OAuth回调服务器,避免阻塞主流程"""
|
| 989 |
+
|
| 990 |
+
def shutdown_server_async():
|
| 991 |
+
try:
|
| 992 |
+
# 设置一个标志来跟踪关闭状态
|
| 993 |
+
shutdown_completed = threading.Event()
|
| 994 |
+
|
| 995 |
+
def do_shutdown():
|
| 996 |
+
try:
|
| 997 |
+
server.shutdown()
|
| 998 |
+
server.server_close()
|
| 999 |
+
shutdown_completed.set()
|
| 1000 |
+
log.info(f"已关闭端口 {port} 的OAuth回调服务器")
|
| 1001 |
+
except Exception as e:
|
| 1002 |
+
shutdown_completed.set()
|
| 1003 |
+
log.debug(f"关闭服务器时出错: {e}")
|
| 1004 |
+
|
| 1005 |
+
# 在单独线程中执行关闭操作
|
| 1006 |
+
shutdown_worker = threading.Thread(target=do_shutdown, daemon=True)
|
| 1007 |
+
shutdown_worker.start()
|
| 1008 |
+
|
| 1009 |
+
# 等待最多5秒,如果超时就放弃等待
|
| 1010 |
+
if shutdown_completed.wait(timeout=5):
|
| 1011 |
+
log.debug(f"端口 {port} 服务器关闭完成")
|
| 1012 |
+
else:
|
| 1013 |
+
log.warning(f"端口 {port} 服务器关闭超时,但不阻塞主流程")
|
| 1014 |
+
|
| 1015 |
+
except Exception as e:
|
| 1016 |
+
log.debug(f"异步关闭服务器时出错: {e}")
|
| 1017 |
+
|
| 1018 |
+
# 在后台线程中关闭服务器,不阻塞主流程
|
| 1019 |
+
shutdown_thread = threading.Thread(target=shutdown_server_async, daemon=True)
|
| 1020 |
+
shutdown_thread.start()
|
| 1021 |
+
log.debug(f"开始异步关闭端口 {port} 的OAuth回调服务器")
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
def cleanup_expired_flows():
|
| 1025 |
+
"""清理过期的认证流程"""
|
| 1026 |
+
current_time = time.time()
|
| 1027 |
+
EXPIRY_TIME = 600 # 10分钟过期
|
| 1028 |
+
|
| 1029 |
+
# 直接遍历删除,避免创建额外列表
|
| 1030 |
+
states_to_remove = [
|
| 1031 |
+
state
|
| 1032 |
+
for state, flow_data in auth_flows.items()
|
| 1033 |
+
if current_time - flow_data["created_at"] > EXPIRY_TIME
|
| 1034 |
+
]
|
| 1035 |
+
|
| 1036 |
+
# 批量清理,提高效率
|
| 1037 |
+
cleaned_count = 0
|
| 1038 |
+
for state in states_to_remove:
|
| 1039 |
+
flow_data = auth_flows.get(state)
|
| 1040 |
+
if flow_data:
|
| 1041 |
+
# 快速��闭可能存在的服务器
|
| 1042 |
+
try:
|
| 1043 |
+
if flow_data.get("server"):
|
| 1044 |
+
server = flow_data["server"]
|
| 1045 |
+
port = flow_data.get("callback_port")
|
| 1046 |
+
async_shutdown_server(server, port)
|
| 1047 |
+
except Exception as e:
|
| 1048 |
+
log.debug(f"清理过期流程时启动异步关闭服务器失败: {e}")
|
| 1049 |
+
|
| 1050 |
+
# 显式清理流程数据,释放内存
|
| 1051 |
+
flow_data.clear()
|
| 1052 |
+
del auth_flows[state]
|
| 1053 |
+
cleaned_count += 1
|
| 1054 |
+
|
| 1055 |
+
if cleaned_count > 0:
|
| 1056 |
+
log.info(f"清理了 {cleaned_count} 个过期的认证流程")
|
| 1057 |
+
|
| 1058 |
+
# 更积极的垃圾回收触发条件
|
| 1059 |
+
if len(auth_flows) > 20: # 降低阈值
|
| 1060 |
+
import gc
|
| 1061 |
+
|
| 1062 |
+
gc.collect()
|
| 1063 |
+
log.debug(f"触发垃圾回收,当前活跃认证流程数: {len(auth_flows)}")
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
def get_auth_status(project_id: str) -> Dict[str, Any]:
|
| 1067 |
+
"""获取认证状态"""
|
| 1068 |
+
for state, flow_data in auth_flows.items():
|
| 1069 |
+
if flow_data["project_id"] == project_id:
|
| 1070 |
+
return {
|
| 1071 |
+
"status": "completed" if flow_data["completed"] else "pending",
|
| 1072 |
+
"state": state,
|
| 1073 |
+
"created_at": flow_data["created_at"],
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
return {"status": "not_found"}
|
| 1077 |
+
|
| 1078 |
+
|
| 1079 |
+
# 鉴权功能 - 使用更小的数据结构
|
| 1080 |
+
auth_tokens = {} # 存储有效的认证令牌
|
| 1081 |
+
TOKEN_EXPIRY = 3600 # 1小时令牌过期时间
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
async def verify_password(password: str) -> bool:
|
| 1085 |
+
"""验证密码(面板登录使用)"""
|
| 1086 |
+
from config import get_panel_password
|
| 1087 |
+
|
| 1088 |
+
correct_password = await get_panel_password()
|
| 1089 |
+
return password == correct_password
|
src/converter/anthropic2gemini.py
ADDED
|
@@ -0,0 +1,1260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Anthropic 到 Gemini 格式转换器
|
| 3 |
+
|
| 4 |
+
提供请求体、响应和流式转换的完整功能。
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import uuid
|
| 11 |
+
from typing import Any, AsyncIterator, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from fastapi import Response
|
| 14 |
+
from log import log
|
| 15 |
+
from src.converter.utils import merge_system_messages
|
| 16 |
+
|
| 17 |
+
from src.converter.thoughtSignature_fix import (
|
| 18 |
+
encode_tool_id_with_signature,
|
| 19 |
+
decode_tool_id_and_signature
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
DEFAULT_TEMPERATURE = 0.4
|
| 23 |
+
_DEBUG_TRUE = {"1", "true", "yes", "on"}
|
| 24 |
+
|
| 25 |
+
# ============================================================================
|
| 26 |
+
# Thinking 块验证和清理
|
| 27 |
+
# ============================================================================
|
| 28 |
+
|
| 29 |
+
# 最小有效签名长度
|
| 30 |
+
MIN_SIGNATURE_LENGTH = 10
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def has_valid_thoughtsignature(block: Dict[str, Any]) -> bool:
|
| 34 |
+
"""
|
| 35 |
+
检查 thinking 块是否有有效签名
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
block: content block 字典
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
bool: 是否有有效签名
|
| 42 |
+
"""
|
| 43 |
+
if not isinstance(block, dict):
|
| 44 |
+
return True
|
| 45 |
+
|
| 46 |
+
block_type = block.get("type")
|
| 47 |
+
if block_type not in ("thinking", "redacted_thinking"):
|
| 48 |
+
return True # 非 thinking 块默认有效
|
| 49 |
+
|
| 50 |
+
thinking = block.get("thinking", "")
|
| 51 |
+
thoughtsignature = block.get("thoughtSignature")
|
| 52 |
+
|
| 53 |
+
# 空 thinking + 任意 thoughtsignature = 有效 (trailing signature case)
|
| 54 |
+
if not thinking and thoughtsignature is not None:
|
| 55 |
+
return True
|
| 56 |
+
|
| 57 |
+
# 有内容 + 足够长度的 thoughtsignature = 有效
|
| 58 |
+
if thoughtsignature and isinstance(thoughtsignature, str) and len(thoughtsignature) >= MIN_SIGNATURE_LENGTH:
|
| 59 |
+
return True
|
| 60 |
+
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def sanitize_thinking_block(block: Dict[str, Any]) -> Dict[str, Any]:
|
| 65 |
+
"""
|
| 66 |
+
清理 thinking 块,只保留必要字段(移除 cache_control 等)
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
block: content block 字典
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
清理后的 block 字典
|
| 73 |
+
"""
|
| 74 |
+
if not isinstance(block, dict):
|
| 75 |
+
return block
|
| 76 |
+
|
| 77 |
+
block_type = block.get("type")
|
| 78 |
+
if block_type not in ("thinking", "redacted_thinking"):
|
| 79 |
+
return block
|
| 80 |
+
|
| 81 |
+
# 重建块,移除额外字段
|
| 82 |
+
sanitized: Dict[str, Any] = {
|
| 83 |
+
"type": block_type,
|
| 84 |
+
"thinking": block.get("thinking", "")
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
thoughtsignature = block.get("thoughtSignature")
|
| 88 |
+
if thoughtsignature:
|
| 89 |
+
sanitized["thoughtSignature"] = thoughtsignature
|
| 90 |
+
|
| 91 |
+
return sanitized
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def remove_trailing_unsigned_thinking(blocks: List[Dict[str, Any]]) -> None:
|
| 95 |
+
"""
|
| 96 |
+
移除尾部的无签名 thinking 块
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
blocks: content blocks 列表 (会被修改)
|
| 100 |
+
"""
|
| 101 |
+
if not blocks:
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
# 从后向前扫描
|
| 105 |
+
end_index = len(blocks)
|
| 106 |
+
for i in range(len(blocks) - 1, -1, -1):
|
| 107 |
+
block = blocks[i]
|
| 108 |
+
if not isinstance(block, dict):
|
| 109 |
+
break
|
| 110 |
+
|
| 111 |
+
block_type = block.get("type")
|
| 112 |
+
if block_type in ("thinking", "redacted_thinking"):
|
| 113 |
+
if not has_valid_thoughtsignature(block):
|
| 114 |
+
end_index = i
|
| 115 |
+
else:
|
| 116 |
+
break # 遇到有效签名的 thinking 块,停止
|
| 117 |
+
else:
|
| 118 |
+
break # 遇到非 thinking 块,停止
|
| 119 |
+
|
| 120 |
+
if end_index < len(blocks):
|
| 121 |
+
removed = len(blocks) - end_index
|
| 122 |
+
del blocks[end_index:]
|
| 123 |
+
log.debug(f"Removed {removed} trailing unsigned thinking block(s)")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def filter_invalid_thinking_blocks(messages: List[Dict[str, Any]]) -> None:
|
| 127 |
+
"""
|
| 128 |
+
过滤消息中的无效 thinking 块,并清理所有 thinking 块的额外字段(如 cache_control)
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
messages: Anthropic messages 列表 (会被修改)
|
| 132 |
+
"""
|
| 133 |
+
total_filtered = 0
|
| 134 |
+
|
| 135 |
+
for msg in messages:
|
| 136 |
+
# 只处理 assistant 和 model 消息
|
| 137 |
+
role = msg.get("role", "")
|
| 138 |
+
if role not in ("assistant", "model"):
|
| 139 |
+
continue
|
| 140 |
+
|
| 141 |
+
content = msg.get("content")
|
| 142 |
+
if not isinstance(content, list):
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
original_len = len(content)
|
| 146 |
+
new_blocks: List[Dict[str, Any]] = []
|
| 147 |
+
|
| 148 |
+
for block in content:
|
| 149 |
+
if not isinstance(block, dict):
|
| 150 |
+
new_blocks.append(block)
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
block_type = block.get("type")
|
| 154 |
+
if block_type not in ("thinking", "redacted_thinking"):
|
| 155 |
+
new_blocks.append(block)
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
# 所有 thinking 块都需要清理(移除 cache_control 等额外字段)
|
| 159 |
+
# 检查 thinking 块的有效性
|
| 160 |
+
if has_valid_thoughtsignature(block):
|
| 161 |
+
# 有效签名,清理后保留
|
| 162 |
+
new_blocks.append(sanitize_thinking_block(block))
|
| 163 |
+
else:
|
| 164 |
+
# 无效签名,将内容转换为 text 块
|
| 165 |
+
thinking_text = block.get("thinking", "")
|
| 166 |
+
if thinking_text and str(thinking_text).strip():
|
| 167 |
+
log.info(
|
| 168 |
+
f"[Claude-Handler] Converting thinking block with invalid thoughtSignature to text. "
|
| 169 |
+
f"Content length: {len(thinking_text)} chars"
|
| 170 |
+
)
|
| 171 |
+
new_blocks.append({"type": "text", "text": thinking_text})
|
| 172 |
+
else:
|
| 173 |
+
log.debug("[Claude-Handler] Dropping empty thinking block with invalid thoughtSignature")
|
| 174 |
+
|
| 175 |
+
msg["content"] = new_blocks
|
| 176 |
+
filtered_count = original_len - len(new_blocks)
|
| 177 |
+
total_filtered += filtered_count
|
| 178 |
+
|
| 179 |
+
# 如果过滤后为空,添加一个空文本块以保持消息有效
|
| 180 |
+
if not new_blocks:
|
| 181 |
+
msg["content"] = [{"type": "text", "text": ""}]
|
| 182 |
+
|
| 183 |
+
if total_filtered > 0:
|
| 184 |
+
log.debug(f"Filtered {total_filtered} invalid thinking block(s) from history")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ============================================================================
|
| 188 |
+
# 请求验证和提取
|
| 189 |
+
# ============================================================================
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _anthropic_debug_enabled() -> bool:
|
| 193 |
+
"""检查是否启用 Anthropic 调试模式"""
|
| 194 |
+
return str(os.getenv("ANTHROPIC_DEBUG", "true")).strip().lower() in _DEBUG_TRUE
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _is_non_whitespace_text(value: Any) -> bool:
|
| 198 |
+
"""
|
| 199 |
+
判断文本是否包含"非空白"内容。
|
| 200 |
+
|
| 201 |
+
说明:下游(Antigravity/Claude 兼容层)会对纯 text 内容块做校验:
|
| 202 |
+
- text 不能为空字符串
|
| 203 |
+
- text 不能仅由空白字符(空格/换行/制表等)组成
|
| 204 |
+
"""
|
| 205 |
+
if value is None:
|
| 206 |
+
return False
|
| 207 |
+
try:
|
| 208 |
+
return bool(str(value).strip())
|
| 209 |
+
except Exception:
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _remove_nulls_for_tool_input(value: Any) -> Any:
|
| 214 |
+
"""
|
| 215 |
+
递归移除 dict/list 中值为 null/None 的字段/元素。
|
| 216 |
+
|
| 217 |
+
背景:Roo/Kilo 在 Anthropic native tool 路径下,若收到 tool_use.input 中包含 null,
|
| 218 |
+
可能会把 null 当作真实入参执行(例如"在 null 中搜索")。
|
| 219 |
+
"""
|
| 220 |
+
if isinstance(value, dict):
|
| 221 |
+
cleaned: Dict[str, Any] = {}
|
| 222 |
+
for k, v in value.items():
|
| 223 |
+
if v is None:
|
| 224 |
+
continue
|
| 225 |
+
cleaned[k] = _remove_nulls_for_tool_input(v)
|
| 226 |
+
return cleaned
|
| 227 |
+
|
| 228 |
+
if isinstance(value, list):
|
| 229 |
+
cleaned_list = []
|
| 230 |
+
for item in value:
|
| 231 |
+
if item is None:
|
| 232 |
+
continue
|
| 233 |
+
cleaned_list.append(_remove_nulls_for_tool_input(item))
|
| 234 |
+
return cleaned_list
|
| 235 |
+
|
| 236 |
+
return value
|
| 237 |
+
|
| 238 |
+
# ============================================================================
|
| 239 |
+
# 2. JSON Schema 清理
|
| 240 |
+
# ============================================================================
|
| 241 |
+
|
| 242 |
+
def clean_json_schema(schema: Any) -> Any:
|
| 243 |
+
"""
|
| 244 |
+
清理 JSON Schema,移除下游不支持的字段,并把验证要求追加到 description。
|
| 245 |
+
"""
|
| 246 |
+
if not isinstance(schema, dict):
|
| 247 |
+
return schema
|
| 248 |
+
|
| 249 |
+
# 下游不支持的字段
|
| 250 |
+
unsupported_keys = {
|
| 251 |
+
"$schema", "$id", "$ref", "$defs", "definitions", "title",
|
| 252 |
+
"example", "examples", "readOnly", "writeOnly", "default",
|
| 253 |
+
"exclusiveMaximum", "exclusiveMinimum", "oneOf", "anyOf", "allOf",
|
| 254 |
+
"const", "additionalItems", "contains", "patternProperties",
|
| 255 |
+
"dependencies", "propertyNames", "if", "then", "else",
|
| 256 |
+
"contentEncoding", "contentMediaType",
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
validation_fields = {
|
| 260 |
+
"minLength": "minLength",
|
| 261 |
+
"maxLength": "maxLength",
|
| 262 |
+
"minimum": "minimum",
|
| 263 |
+
"maximum": "maximum",
|
| 264 |
+
"minItems": "minItems",
|
| 265 |
+
"maxItems": "maxItems",
|
| 266 |
+
}
|
| 267 |
+
fields_to_remove = {"additionalProperties"}
|
| 268 |
+
|
| 269 |
+
validations: List[str] = []
|
| 270 |
+
for field, label in validation_fields.items():
|
| 271 |
+
if field in schema:
|
| 272 |
+
validations.append(f"{label}: {schema[field]}")
|
| 273 |
+
|
| 274 |
+
cleaned: Dict[str, Any] = {}
|
| 275 |
+
for key, value in schema.items():
|
| 276 |
+
if key in unsupported_keys or key in fields_to_remove or key in validation_fields:
|
| 277 |
+
continue
|
| 278 |
+
|
| 279 |
+
if key == "type" and isinstance(value, list):
|
| 280 |
+
# type: ["string", "null"] -> type: "string", nullable: true
|
| 281 |
+
has_null = any(
|
| 282 |
+
isinstance(t, str) and t.strip() and t.strip().lower() == "null" for t in value
|
| 283 |
+
)
|
| 284 |
+
non_null_types = [
|
| 285 |
+
t.strip()
|
| 286 |
+
for t in value
|
| 287 |
+
if isinstance(t, str) and t.strip() and t.strip().lower() != "null"
|
| 288 |
+
]
|
| 289 |
+
|
| 290 |
+
cleaned[key] = non_null_types[0] if non_null_types else "string"
|
| 291 |
+
if has_null:
|
| 292 |
+
cleaned["nullable"] = True
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
if key == "description" and validations:
|
| 296 |
+
cleaned[key] = f"{value} ({', '.join(validations)})"
|
| 297 |
+
elif isinstance(value, dict):
|
| 298 |
+
cleaned[key] = clean_json_schema(value)
|
| 299 |
+
elif isinstance(value, list):
|
| 300 |
+
cleaned[key] = [clean_json_schema(item) if isinstance(item, dict) else item for item in value]
|
| 301 |
+
else:
|
| 302 |
+
cleaned[key] = value
|
| 303 |
+
|
| 304 |
+
if validations and "description" not in cleaned:
|
| 305 |
+
cleaned["description"] = f"Validation: {', '.join(validations)}"
|
| 306 |
+
|
| 307 |
+
# 如果有 properties 但没有显式 type,则补齐为 object
|
| 308 |
+
if "properties" in cleaned and "type" not in cleaned:
|
| 309 |
+
cleaned["type"] = "object"
|
| 310 |
+
|
| 311 |
+
return cleaned
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# ============================================================================
|
| 315 |
+
# 4. Tools 转换
|
| 316 |
+
# ============================================================================
|
| 317 |
+
|
| 318 |
+
def convert_tools(anthropic_tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
|
| 319 |
+
"""
|
| 320 |
+
将 Anthropic tools[] 转换为下游 tools(functionDeclarations)结构。
|
| 321 |
+
"""
|
| 322 |
+
if not anthropic_tools:
|
| 323 |
+
return None
|
| 324 |
+
|
| 325 |
+
gemini_tools: List[Dict[str, Any]] = []
|
| 326 |
+
for tool in anthropic_tools:
|
| 327 |
+
name = tool.get("name", "nameless_function")
|
| 328 |
+
description = tool.get("description", "")
|
| 329 |
+
input_schema = tool.get("input_schema", {}) or {}
|
| 330 |
+
parameters = clean_json_schema(input_schema)
|
| 331 |
+
|
| 332 |
+
gemini_tools.append(
|
| 333 |
+
{
|
| 334 |
+
"functionDeclarations": [
|
| 335 |
+
{
|
| 336 |
+
"name": name,
|
| 337 |
+
"description": description,
|
| 338 |
+
"parameters": parameters,
|
| 339 |
+
}
|
| 340 |
+
]
|
| 341 |
+
}
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
return gemini_tools or None
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ============================================================================
|
| 348 |
+
# 5. Messages 转换
|
| 349 |
+
# ============================================================================
|
| 350 |
+
|
| 351 |
+
def _extract_tool_result_output(content: Any) -> str:
|
| 352 |
+
"""从 tool_result.content 中提取输出字符串"""
|
| 353 |
+
if isinstance(content, list):
|
| 354 |
+
if not content:
|
| 355 |
+
return ""
|
| 356 |
+
first = content[0]
|
| 357 |
+
if isinstance(first, dict) and first.get("type") == "text":
|
| 358 |
+
return str(first.get("text", ""))
|
| 359 |
+
return str(first)
|
| 360 |
+
if content is None:
|
| 361 |
+
return ""
|
| 362 |
+
return str(content)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def convert_messages_to_contents(
|
| 366 |
+
messages: List[Dict[str, Any]],
|
| 367 |
+
*,
|
| 368 |
+
include_thinking: bool = True
|
| 369 |
+
) -> List[Dict[str, Any]]:
|
| 370 |
+
"""
|
| 371 |
+
将 Anthropic messages[] 转换为下游 contents[](role: user/model, parts: [])。
|
| 372 |
+
|
| 373 |
+
Args:
|
| 374 |
+
messages: Anthropic 格式的消息列表
|
| 375 |
+
include_thinking: 是否包含 thinking 块
|
| 376 |
+
"""
|
| 377 |
+
contents: List[Dict[str, Any]] = []
|
| 378 |
+
|
| 379 |
+
# 第一遍:构建 tool_use_id -> (name, thoughtsignature) 的映射
|
| 380 |
+
# 注意:存储的是编码后的 ID(可能包含签名)
|
| 381 |
+
tool_use_info: Dict[str, tuple[str, Optional[str]]] = {}
|
| 382 |
+
for msg in messages:
|
| 383 |
+
raw_content = msg.get("content", "")
|
| 384 |
+
if isinstance(raw_content, list):
|
| 385 |
+
for item in raw_content:
|
| 386 |
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
| 387 |
+
encoded_tool_id = item.get("id")
|
| 388 |
+
tool_name = item.get("name")
|
| 389 |
+
if encoded_tool_id and tool_name:
|
| 390 |
+
# 解码获取原始ID和签名
|
| 391 |
+
original_id, thoughtsignature = decode_tool_id_and_signature(encoded_tool_id)
|
| 392 |
+
# 存储映射:编码ID -> (name, thoughtsignature)
|
| 393 |
+
tool_use_info[str(encoded_tool_id)] = (tool_name, thoughtsignature)
|
| 394 |
+
|
| 395 |
+
for msg in messages:
|
| 396 |
+
role = msg.get("role", "user")
|
| 397 |
+
|
| 398 |
+
# system 消息已经由 merge_system_messages 处理,这里跳过
|
| 399 |
+
if role == "system":
|
| 400 |
+
continue
|
| 401 |
+
|
| 402 |
+
# 支持 'assistant' 和 'model' 角色(Google history usage)
|
| 403 |
+
gemini_role = "model" if role in ("assistant", "model") else "user"
|
| 404 |
+
raw_content = msg.get("content", "")
|
| 405 |
+
|
| 406 |
+
parts: List[Dict[str, Any]] = []
|
| 407 |
+
if isinstance(raw_content, str):
|
| 408 |
+
if _is_non_whitespace_text(raw_content):
|
| 409 |
+
parts = [{"text": str(raw_content)}]
|
| 410 |
+
elif isinstance(raw_content, list):
|
| 411 |
+
for item in raw_content:
|
| 412 |
+
if not isinstance(item, dict):
|
| 413 |
+
if _is_non_whitespace_text(item):
|
| 414 |
+
parts.append({"text": str(item)})
|
| 415 |
+
continue
|
| 416 |
+
|
| 417 |
+
item_type = item.get("type")
|
| 418 |
+
if item_type == "thinking":
|
| 419 |
+
if not include_thinking:
|
| 420 |
+
continue
|
| 421 |
+
|
| 422 |
+
thinking_text = item.get("thinking", "")
|
| 423 |
+
if thinking_text is None:
|
| 424 |
+
thinking_text = ""
|
| 425 |
+
|
| 426 |
+
part: Dict[str, Any] = {
|
| 427 |
+
"text": str(thinking_text),
|
| 428 |
+
"thought": True,
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
# 如果有 thoughtsignature 则添加
|
| 432 |
+
thoughtsignature = item.get("thoughtSignature")
|
| 433 |
+
if thoughtsignature:
|
| 434 |
+
part["thoughtSignature"] = thoughtsignature
|
| 435 |
+
|
| 436 |
+
parts.append(part)
|
| 437 |
+
elif item_type == "redacted_thinking":
|
| 438 |
+
if not include_thinking:
|
| 439 |
+
continue
|
| 440 |
+
|
| 441 |
+
thinking_text = item.get("thinking")
|
| 442 |
+
if thinking_text is None:
|
| 443 |
+
thinking_text = item.get("data", "")
|
| 444 |
+
|
| 445 |
+
part_dict: Dict[str, Any] = {
|
| 446 |
+
"text": str(thinking_text or ""),
|
| 447 |
+
"thought": True,
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
# 如果有 thoughtsignature 则添加
|
| 451 |
+
thoughtsignature = item.get("thoughtSignature")
|
| 452 |
+
if thoughtsignature:
|
| 453 |
+
part_dict["thoughtSignature"] = thoughtsignature
|
| 454 |
+
|
| 455 |
+
parts.append(part_dict)
|
| 456 |
+
elif item_type == "text":
|
| 457 |
+
text = item.get("text", "")
|
| 458 |
+
if _is_non_whitespace_text(text):
|
| 459 |
+
parts.append({"text": str(text)})
|
| 460 |
+
elif item_type == "image":
|
| 461 |
+
source = item.get("source", {}) or {}
|
| 462 |
+
if source.get("type") == "base64":
|
| 463 |
+
parts.append(
|
| 464 |
+
{
|
| 465 |
+
"inlineData": {
|
| 466 |
+
"mimeType": source.get("media_type", "image/png"),
|
| 467 |
+
"data": source.get("data", ""),
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
)
|
| 471 |
+
elif item_type == "tool_use":
|
| 472 |
+
encoded_id = item.get("id") or ""
|
| 473 |
+
original_id, thoughtsignature = decode_tool_id_and_signature(encoded_id)
|
| 474 |
+
|
| 475 |
+
fc_part: Dict[str, Any] = {
|
| 476 |
+
"functionCall": {
|
| 477 |
+
"id": original_id, # 使用原始ID,不带签名
|
| 478 |
+
"name": item.get("name"),
|
| 479 |
+
"args": item.get("input", {}) or {},
|
| 480 |
+
}
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
# 如果提取到签名则添加,否则使用占位符以满足 Gemini API 要求
|
| 484 |
+
if thoughtsignature:
|
| 485 |
+
fc_part["thoughtSignature"] = thoughtsignature
|
| 486 |
+
else:
|
| 487 |
+
fc_part["thoughtSignature"] = "skip_thought_signature_validator"
|
| 488 |
+
|
| 489 |
+
parts.append(fc_part)
|
| 490 |
+
elif item_type == "tool_result":
|
| 491 |
+
output = _extract_tool_result_output(item.get("content"))
|
| 492 |
+
encoded_tool_use_id = item.get("tool_use_id") or ""
|
| 493 |
+
|
| 494 |
+
# 解码获取原始ID(functionResponse不需要签名)
|
| 495 |
+
original_tool_use_id, _ = decode_tool_id_and_signature(encoded_tool_use_id)
|
| 496 |
+
|
| 497 |
+
# 从 tool_result 获取 name,如果没有则从映射中查找
|
| 498 |
+
func_name = item.get("name")
|
| 499 |
+
if not func_name and encoded_tool_use_id:
|
| 500 |
+
# 使用编码ID查找映射
|
| 501 |
+
tool_info = tool_use_info.get(str(encoded_tool_use_id))
|
| 502 |
+
if tool_info:
|
| 503 |
+
func_name = tool_info[0] # 获取 name
|
| 504 |
+
if not func_name:
|
| 505 |
+
func_name = "unknown_function"
|
| 506 |
+
|
| 507 |
+
parts.append(
|
| 508 |
+
{
|
| 509 |
+
"functionResponse": {
|
| 510 |
+
"id": original_tool_use_id, # 使用解码后的原始ID以匹配functionCall
|
| 511 |
+
"name": func_name,
|
| 512 |
+
"response": {"output": output},
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
+
)
|
| 516 |
+
else:
|
| 517 |
+
parts.append({"text": json.dumps(item, ensure_ascii=False)})
|
| 518 |
+
else:
|
| 519 |
+
if _is_non_whitespace_text(raw_content):
|
| 520 |
+
parts = [{"text": str(raw_content)}]
|
| 521 |
+
|
| 522 |
+
if not parts:
|
| 523 |
+
continue
|
| 524 |
+
|
| 525 |
+
contents.append({"role": gemini_role, "parts": parts})
|
| 526 |
+
|
| 527 |
+
return contents
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def reorganize_tool_messages(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 531 |
+
"""
|
| 532 |
+
重新组织消息,满足 tool_use/tool_result 约束。
|
| 533 |
+
"""
|
| 534 |
+
tool_results: Dict[str, Dict[str, Any]] = {}
|
| 535 |
+
|
| 536 |
+
for msg in contents:
|
| 537 |
+
for part in msg.get("parts", []) or []:
|
| 538 |
+
if isinstance(part, dict) and "functionResponse" in part:
|
| 539 |
+
tool_id = (part.get("functionResponse") or {}).get("id")
|
| 540 |
+
if tool_id:
|
| 541 |
+
tool_results[str(tool_id)] = part
|
| 542 |
+
|
| 543 |
+
flattened: List[Dict[str, Any]] = []
|
| 544 |
+
for msg in contents:
|
| 545 |
+
role = msg.get("role")
|
| 546 |
+
for part in msg.get("parts", []) or []:
|
| 547 |
+
flattened.append({"role": role, "parts": [part]})
|
| 548 |
+
|
| 549 |
+
new_contents: List[Dict[str, Any]] = []
|
| 550 |
+
i = 0
|
| 551 |
+
while i < len(flattened):
|
| 552 |
+
msg = flattened[i]
|
| 553 |
+
part = msg["parts"][0]
|
| 554 |
+
|
| 555 |
+
if isinstance(part, dict) and "functionResponse" in part:
|
| 556 |
+
i += 1
|
| 557 |
+
continue
|
| 558 |
+
|
| 559 |
+
if isinstance(part, dict) and "functionCall" in part:
|
| 560 |
+
tool_id = (part.get("functionCall") or {}).get("id")
|
| 561 |
+
new_contents.append({"role": "model", "parts": [part]})
|
| 562 |
+
|
| 563 |
+
if tool_id is not None and str(tool_id) in tool_results:
|
| 564 |
+
new_contents.append({"role": "user", "parts": [tool_results[str(tool_id)]]})
|
| 565 |
+
|
| 566 |
+
i += 1
|
| 567 |
+
continue
|
| 568 |
+
|
| 569 |
+
new_contents.append(msg)
|
| 570 |
+
i += 1
|
| 571 |
+
|
| 572 |
+
return new_contents
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
# ============================================================================
|
| 576 |
+
# 7. Tool Choice 转换
|
| 577 |
+
# ============================================================================
|
| 578 |
+
|
| 579 |
+
def convert_tool_choice_to_tool_config(tool_choice: Any) -> Optional[Dict[str, Any]]:
|
| 580 |
+
"""
|
| 581 |
+
将 Anthropic tool_choice 转换为 Gemini toolConfig
|
| 582 |
+
|
| 583 |
+
Args:
|
| 584 |
+
tool_choice: Anthropic 格式的 tool_choice
|
| 585 |
+
- {"type": "auto"}: 模型自动决定是否使用工具
|
| 586 |
+
- {"type": "any"}: 模型必须使用工具
|
| 587 |
+
- {"type": "tool", "name": "tool_name"}: 模型必须使用指定工具
|
| 588 |
+
|
| 589 |
+
Returns:
|
| 590 |
+
Gemini 格式的 toolConfig,如果无效则返回 None
|
| 591 |
+
"""
|
| 592 |
+
if not tool_choice:
|
| 593 |
+
return None
|
| 594 |
+
|
| 595 |
+
if isinstance(tool_choice, dict):
|
| 596 |
+
choice_type = tool_choice.get("type")
|
| 597 |
+
|
| 598 |
+
if choice_type == "auto":
|
| 599 |
+
return {"functionCallingConfig": {"mode": "AUTO"}}
|
| 600 |
+
elif choice_type == "any":
|
| 601 |
+
return {"functionCallingConfig": {"mode": "ANY"}}
|
| 602 |
+
elif choice_type == "tool":
|
| 603 |
+
tool_name = tool_choice.get("name")
|
| 604 |
+
if tool_name:
|
| 605 |
+
return {
|
| 606 |
+
"functionCallingConfig": {
|
| 607 |
+
"mode": "ANY",
|
| 608 |
+
"allowedFunctionNames": [tool_name],
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
# 无效或不支持的 tool_choice,返回 None
|
| 613 |
+
return None
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
# ============================================================================
|
| 617 |
+
# 8. Generation Config 构建
|
| 618 |
+
# ============================================================================
|
| 619 |
+
|
| 620 |
+
def build_generation_config(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 621 |
+
"""
|
| 622 |
+
根据 Anthropic Messages 请求构造下游 generationConfig。
|
| 623 |
+
|
| 624 |
+
Returns:
|
| 625 |
+
generation_config: 生成配置字典
|
| 626 |
+
"""
|
| 627 |
+
config: Dict[str, Any] = {
|
| 628 |
+
"topP": 1,
|
| 629 |
+
"candidateCount": 1,
|
| 630 |
+
"stopSequences": [
|
| 631 |
+
"<|user|>",
|
| 632 |
+
"<|bot|>",
|
| 633 |
+
"<|context_request|>",
|
| 634 |
+
"<|endoftext|>",
|
| 635 |
+
"<|end_of_turn|>",
|
| 636 |
+
],
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
temperature = payload.get("temperature", None)
|
| 640 |
+
config["temperature"] = DEFAULT_TEMPERATURE if temperature is None else temperature
|
| 641 |
+
|
| 642 |
+
top_p = payload.get("top_p", None)
|
| 643 |
+
if top_p is not None:
|
| 644 |
+
config["topP"] = top_p
|
| 645 |
+
|
| 646 |
+
top_k = payload.get("top_k", None)
|
| 647 |
+
if top_k is not None:
|
| 648 |
+
config["topK"] = top_k
|
| 649 |
+
|
| 650 |
+
max_tokens = payload.get("max_tokens")
|
| 651 |
+
if max_tokens is not None:
|
| 652 |
+
config["maxOutputTokens"] = max_tokens
|
| 653 |
+
|
| 654 |
+
# 处理 extended thinking 参数 (plan mode)
|
| 655 |
+
thinking = payload.get("thinking")
|
| 656 |
+
is_plan_mode = False
|
| 657 |
+
if thinking and isinstance(thinking, dict):
|
| 658 |
+
thinking_type = thinking.get("type")
|
| 659 |
+
budget_tokens = thinking.get("budget_tokens")
|
| 660 |
+
|
| 661 |
+
# 如果启用了 extended thinking,设置 thinkingConfig
|
| 662 |
+
if thinking_type == "enabled":
|
| 663 |
+
is_plan_mode = True
|
| 664 |
+
thinking_config: Dict[str, Any] = {}
|
| 665 |
+
|
| 666 |
+
# 设置思考预算,默认使用较大的值以支持计划模式
|
| 667 |
+
if budget_tokens is not None:
|
| 668 |
+
thinking_config["thinkingBudget"] = budget_tokens
|
| 669 |
+
else:
|
| 670 |
+
# 默认给一个较大的思考预算以支持完整的计划生成
|
| 671 |
+
thinking_config["thinkingBudget"] = 48000
|
| 672 |
+
|
| 673 |
+
# 始终包含思考内容,这样才能看到计划
|
| 674 |
+
thinking_config["includeThoughts"] = True
|
| 675 |
+
|
| 676 |
+
config["thinkingConfig"] = thinking_config
|
| 677 |
+
log.info(f"[ANTHROPIC2GEMINI] Extended thinking enabled with budget: {thinking_config['thinkingBudget']}")
|
| 678 |
+
elif thinking_type == "disabled":
|
| 679 |
+
# 明确禁用思考模式
|
| 680 |
+
config["thinkingConfig"] = {
|
| 681 |
+
"includeThoughts": False
|
| 682 |
+
}
|
| 683 |
+
log.info("[ANTHROPIC2GEMINI] Extended thinking explicitly disabled")
|
| 684 |
+
|
| 685 |
+
stop_sequences = payload.get("stop_sequences")
|
| 686 |
+
if isinstance(stop_sequences, list) and stop_sequences:
|
| 687 |
+
config["stopSequences"] = config["stopSequences"] + [str(s) for s in stop_sequences]
|
| 688 |
+
elif is_plan_mode:
|
| 689 |
+
# Plan mode 时清空默认 stop sequences,避免过早停止
|
| 690 |
+
# 默认的 stop sequences 可能会导致模型在生成计划时过早停止
|
| 691 |
+
config["stopSequences"] = []
|
| 692 |
+
log.info("[ANTHROPIC2GEMINI] Plan mode: cleared default stop sequences to prevent premature stopping")
|
| 693 |
+
|
| 694 |
+
# 如果不是 plan mode 且没有自定义 stop_sequences,保持默认值
|
| 695 |
+
# (默认值已经在 config 初始化时设置)
|
| 696 |
+
|
| 697 |
+
return config
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
# ============================================================================
|
| 701 |
+
# 8. 主要转换函数
|
| 702 |
+
# ============================================================================
|
| 703 |
+
|
| 704 |
+
async def anthropic_to_gemini_request(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 705 |
+
"""
|
| 706 |
+
将 Anthropic 格式请求体转换为 Gemini 格式请求体
|
| 707 |
+
|
| 708 |
+
注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
|
| 709 |
+
(如 thinking config 自动设置、search tools、参数范围限制等)
|
| 710 |
+
|
| 711 |
+
Args:
|
| 712 |
+
payload: Anthropic 格式的请求体字典
|
| 713 |
+
|
| 714 |
+
Returns:
|
| 715 |
+
Gemini 格式的请求体字典,包含:
|
| 716 |
+
- contents: 转换后的消息内容
|
| 717 |
+
- generationConfig: 生成配置
|
| 718 |
+
- systemInstruction: 系统指令 (如果有)
|
| 719 |
+
- tools: 工具定义 (如果有)
|
| 720 |
+
- toolConfig: 工具调用配置 (如果有 tool_choice)
|
| 721 |
+
"""
|
| 722 |
+
# 处理连续的system消息(兼容性模式)
|
| 723 |
+
payload = await merge_system_messages(payload)
|
| 724 |
+
|
| 725 |
+
# 提取和转换基础信息
|
| 726 |
+
messages = payload.get("messages") or []
|
| 727 |
+
if not isinstance(messages, list):
|
| 728 |
+
messages = []
|
| 729 |
+
|
| 730 |
+
# [CRITICAL FIX] 过滤并修复 Thinking 块签名
|
| 731 |
+
# 在转换前先过滤无效的 thinking 块
|
| 732 |
+
filter_invalid_thinking_blocks(messages)
|
| 733 |
+
|
| 734 |
+
# 构建生成配置
|
| 735 |
+
generation_config = build_generation_config(payload)
|
| 736 |
+
|
| 737 |
+
# 转换消息内容(始终包含thinking块,由响应端处理)
|
| 738 |
+
contents = convert_messages_to_contents(messages, include_thinking=True)
|
| 739 |
+
|
| 740 |
+
# [CRITICAL FIX] 移除尾部无签名的 thinking 块
|
| 741 |
+
# 对真实请求应用额外的清理
|
| 742 |
+
for content in contents:
|
| 743 |
+
role = content.get("role", "")
|
| 744 |
+
if role == "model": # 只处理 model/assistant 消息
|
| 745 |
+
parts = content.get("parts", [])
|
| 746 |
+
if isinstance(parts, list):
|
| 747 |
+
remove_trailing_unsigned_thinking(parts)
|
| 748 |
+
|
| 749 |
+
contents = reorganize_tool_messages(contents)
|
| 750 |
+
|
| 751 |
+
# 转换工具
|
| 752 |
+
tools = convert_tools(payload.get("tools"))
|
| 753 |
+
|
| 754 |
+
# 转换 tool_choice
|
| 755 |
+
tool_config = convert_tool_choice_to_tool_config(payload.get("tool_choice"))
|
| 756 |
+
|
| 757 |
+
# 构建基础请求数据
|
| 758 |
+
gemini_request = {
|
| 759 |
+
"contents": contents,
|
| 760 |
+
"generationConfig": generation_config,
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
# 如果 merge_system_messages 已经添加了 systemInstruction,使用它
|
| 764 |
+
if "systemInstruction" in payload:
|
| 765 |
+
gemini_request["systemInstruction"] = payload["systemInstruction"]
|
| 766 |
+
|
| 767 |
+
if tools:
|
| 768 |
+
gemini_request["tools"] = tools
|
| 769 |
+
|
| 770 |
+
# 添加 toolConfig(如果有 tool_choice)
|
| 771 |
+
if tool_config:
|
| 772 |
+
gemini_request["toolConfig"] = tool_config
|
| 773 |
+
|
| 774 |
+
return gemini_request
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def gemini_to_anthropic_response(
|
| 778 |
+
gemini_response: Dict[str, Any],
|
| 779 |
+
model: str,
|
| 780 |
+
status_code: int = 200
|
| 781 |
+
) -> Dict[str, Any]:
|
| 782 |
+
"""
|
| 783 |
+
将 Gemini 格式非流式响应转换为 Anthropic 格式非流式响应
|
| 784 |
+
|
| 785 |
+
注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
|
| 786 |
+
|
| 787 |
+
Args:
|
| 788 |
+
gemini_response: Gemini 格式的响应体字典
|
| 789 |
+
model: 模型名称
|
| 790 |
+
status_code: HTTP 状态码 (默认 200)
|
| 791 |
+
|
| 792 |
+
Returns:
|
| 793 |
+
Anthropic 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
|
| 794 |
+
"""
|
| 795 |
+
# 非 2xx 状态码直接返回原始响应
|
| 796 |
+
if not (200 <= status_code < 300):
|
| 797 |
+
return gemini_response
|
| 798 |
+
|
| 799 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 800 |
+
if "response" in gemini_response:
|
| 801 |
+
response_data = gemini_response["response"]
|
| 802 |
+
else:
|
| 803 |
+
response_data = gemini_response
|
| 804 |
+
|
| 805 |
+
# 提取候选结果
|
| 806 |
+
candidate = response_data.get("candidates", [{}])[0] or {}
|
| 807 |
+
parts = candidate.get("content", {}).get("parts", []) or []
|
| 808 |
+
|
| 809 |
+
# 获取 usage metadata
|
| 810 |
+
usage_metadata = {}
|
| 811 |
+
if "usageMetadata" in response_data:
|
| 812 |
+
usage_metadata = response_data["usageMetadata"]
|
| 813 |
+
elif "usageMetadata" in candidate:
|
| 814 |
+
usage_metadata = candidate["usageMetadata"]
|
| 815 |
+
|
| 816 |
+
# 转换内容块
|
| 817 |
+
content = []
|
| 818 |
+
has_tool_use = False
|
| 819 |
+
|
| 820 |
+
for part in parts:
|
| 821 |
+
if not isinstance(part, dict):
|
| 822 |
+
continue
|
| 823 |
+
|
| 824 |
+
# 处理 thinking 块
|
| 825 |
+
if part.get("thought") is True:
|
| 826 |
+
thinking_text = part.get("text", "")
|
| 827 |
+
if thinking_text is None:
|
| 828 |
+
thinking_text = ""
|
| 829 |
+
|
| 830 |
+
block: Dict[str, Any] = {"type": "thinking", "thinking": str(thinking_text)}
|
| 831 |
+
|
| 832 |
+
# 如果有 thoughtsignature 则添加
|
| 833 |
+
thoughtsignature = part.get("thoughtSignature")
|
| 834 |
+
if thoughtsignature:
|
| 835 |
+
block["thoughtSignature"] = thoughtsignature
|
| 836 |
+
|
| 837 |
+
content.append(block)
|
| 838 |
+
continue
|
| 839 |
+
|
| 840 |
+
# 处理文本块
|
| 841 |
+
if "text" in part:
|
| 842 |
+
content.append({"type": "text", "text": part.get("text", "")})
|
| 843 |
+
continue
|
| 844 |
+
|
| 845 |
+
# 处理工具调用
|
| 846 |
+
if "functionCall" in part:
|
| 847 |
+
has_tool_use = True
|
| 848 |
+
fc = part.get("functionCall", {}) or {}
|
| 849 |
+
original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
|
| 850 |
+
thoughtsignature = part.get("thoughtSignature")
|
| 851 |
+
|
| 852 |
+
# 对工具调用ID进行签名编码
|
| 853 |
+
encoded_id = encode_tool_id_with_signature(original_id, thoughtsignature)
|
| 854 |
+
content.append(
|
| 855 |
+
{
|
| 856 |
+
"type": "tool_use",
|
| 857 |
+
"id": encoded_id,
|
| 858 |
+
"name": fc.get("name") or "",
|
| 859 |
+
"input": _remove_nulls_for_tool_input(fc.get("args", {}) or {}),
|
| 860 |
+
}
|
| 861 |
+
)
|
| 862 |
+
continue
|
| 863 |
+
|
| 864 |
+
# 处理图片
|
| 865 |
+
if "inlineData" in part:
|
| 866 |
+
inline = part.get("inlineData", {}) or {}
|
| 867 |
+
content.append(
|
| 868 |
+
{
|
| 869 |
+
"type": "image",
|
| 870 |
+
"source": {
|
| 871 |
+
"type": "base64",
|
| 872 |
+
"media_type": inline.get("mimeType", "image/png"),
|
| 873 |
+
"data": inline.get("data", ""),
|
| 874 |
+
},
|
| 875 |
+
}
|
| 876 |
+
)
|
| 877 |
+
continue
|
| 878 |
+
|
| 879 |
+
# 确定停止原因
|
| 880 |
+
finish_reason = candidate.get("finishReason")
|
| 881 |
+
|
| 882 |
+
# 只有在正常停止(STOP)且有工具调用时才设为 tool_use
|
| 883 |
+
# 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环
|
| 884 |
+
if has_tool_use and finish_reason == "STOP":
|
| 885 |
+
stop_reason = "tool_use"
|
| 886 |
+
elif finish_reason == "MAX_TOKENS":
|
| 887 |
+
stop_reason = "max_tokens"
|
| 888 |
+
else:
|
| 889 |
+
# 其他情况(SAFETY、RECITATION 等)默认为 end_turn
|
| 890 |
+
stop_reason = "end_turn"
|
| 891 |
+
|
| 892 |
+
# 提取 token 使用情况
|
| 893 |
+
input_tokens = usage_metadata.get("promptTokenCount", 0) if isinstance(usage_metadata, dict) else 0
|
| 894 |
+
output_tokens = usage_metadata.get("candidatesTokenCount", 0) if isinstance(usage_metadata, dict) else 0
|
| 895 |
+
|
| 896 |
+
# 构建 Anthropic 响应
|
| 897 |
+
message_id = f"msg_{uuid.uuid4().hex}"
|
| 898 |
+
|
| 899 |
+
return {
|
| 900 |
+
"id": message_id,
|
| 901 |
+
"type": "message",
|
| 902 |
+
"role": "assistant",
|
| 903 |
+
"model": model,
|
| 904 |
+
"content": content,
|
| 905 |
+
"stop_reason": stop_reason,
|
| 906 |
+
"stop_sequence": None,
|
| 907 |
+
"usage": {
|
| 908 |
+
"input_tokens": int(input_tokens or 0),
|
| 909 |
+
"output_tokens": int(output_tokens or 0),
|
| 910 |
+
},
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
async def gemini_stream_to_anthropic_stream(
|
| 915 |
+
gemini_stream: AsyncIterator[bytes],
|
| 916 |
+
model: str,
|
| 917 |
+
status_code: int = 200
|
| 918 |
+
) -> AsyncIterator[bytes]:
|
| 919 |
+
"""
|
| 920 |
+
将 Gemini 格式流式响应转换为 Anthropic SSE 格式流式响应
|
| 921 |
+
|
| 922 |
+
注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
|
| 923 |
+
|
| 924 |
+
Args:
|
| 925 |
+
gemini_stream: Gemini 格式的流式响应 (bytes 迭代器)
|
| 926 |
+
model: 模型名称
|
| 927 |
+
status_code: HTTP 状态码 (默认 200)
|
| 928 |
+
|
| 929 |
+
Yields:
|
| 930 |
+
Anthropic SSE 格式的响应块 (bytes)
|
| 931 |
+
"""
|
| 932 |
+
# 非 2xx 状态码直接转发原始流
|
| 933 |
+
if not (200 <= status_code < 300):
|
| 934 |
+
async for chunk in gemini_stream:
|
| 935 |
+
yield chunk
|
| 936 |
+
return
|
| 937 |
+
|
| 938 |
+
# 初始化状态
|
| 939 |
+
message_id = f"msg_{uuid.uuid4().hex}"
|
| 940 |
+
message_start_sent = False
|
| 941 |
+
current_block_type: Optional[str] = None
|
| 942 |
+
current_block_index = -1
|
| 943 |
+
current_thinking_signature: Optional[str] = None
|
| 944 |
+
has_tool_use = False
|
| 945 |
+
input_tokens = 0
|
| 946 |
+
output_tokens = 0
|
| 947 |
+
finish_reason: Optional[str] = None
|
| 948 |
+
|
| 949 |
+
def _sse_event(event: str, data: Dict[str, Any]) -> bytes:
|
| 950 |
+
"""生成 SSE 事件"""
|
| 951 |
+
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
| 952 |
+
return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
|
| 953 |
+
|
| 954 |
+
def _close_block() -> Optional[bytes]:
|
| 955 |
+
"""关闭当前内容块"""
|
| 956 |
+
nonlocal current_block_type
|
| 957 |
+
if current_block_type is None:
|
| 958 |
+
return None
|
| 959 |
+
event = _sse_event(
|
| 960 |
+
"content_block_stop",
|
| 961 |
+
{"type": "content_block_stop", "index": current_block_index},
|
| 962 |
+
)
|
| 963 |
+
current_block_type = None
|
| 964 |
+
return event
|
| 965 |
+
|
| 966 |
+
# 处理流式数据
|
| 967 |
+
try:
|
| 968 |
+
async for chunk in gemini_stream:
|
| 969 |
+
# 检查是否是 Response 对象(错误情况)
|
| 970 |
+
if isinstance(chunk, Response):
|
| 971 |
+
log.warning(f"[GEMINI_TO_ANTHROPIC] 收到 Response 对象,状态码: {chunk.status_code},直接转发错误")
|
| 972 |
+
# 直接转发错误响应内容,不做格式转换
|
| 973 |
+
error_content = chunk.body if isinstance(chunk.body, bytes) else chunk.body.encode('utf-8')
|
| 974 |
+
yield error_content
|
| 975 |
+
return
|
| 976 |
+
|
| 977 |
+
# 记录接收到的原始chunk
|
| 978 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Raw chunk: {chunk[:200] if chunk else b''}")
|
| 979 |
+
|
| 980 |
+
# 解析 Gemini 流式块
|
| 981 |
+
if not chunk or not chunk.startswith(b"data: "):
|
| 982 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Skipping chunk (not SSE format or empty)")
|
| 983 |
+
continue
|
| 984 |
+
|
| 985 |
+
raw = chunk[6:].strip()
|
| 986 |
+
if raw == b"[DONE]":
|
| 987 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Received [DONE] marker")
|
| 988 |
+
break
|
| 989 |
+
|
| 990 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Parsing JSON: {raw[:200]}")
|
| 991 |
+
|
| 992 |
+
try:
|
| 993 |
+
data = json.loads(raw.decode('utf-8', errors='ignore'))
|
| 994 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Parsed data: {json.dumps(data, ensure_ascii=False)[:300]}")
|
| 995 |
+
except Exception as e:
|
| 996 |
+
log.warning(f"[GEMINI_TO_ANTHROPIC] JSON parse error: {e}")
|
| 997 |
+
continue
|
| 998 |
+
|
| 999 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 1000 |
+
if "response" in data:
|
| 1001 |
+
response = data["response"]
|
| 1002 |
+
else:
|
| 1003 |
+
response = data
|
| 1004 |
+
|
| 1005 |
+
candidate = (response.get("candidates", []) or [{}])[0] or {}
|
| 1006 |
+
parts = (candidate.get("content", {}) or {}).get("parts", []) or []
|
| 1007 |
+
|
| 1008 |
+
# 更新 usage metadata
|
| 1009 |
+
if "usageMetadata" in response:
|
| 1010 |
+
usage = response["usageMetadata"]
|
| 1011 |
+
if isinstance(usage, dict):
|
| 1012 |
+
if "promptTokenCount" in usage:
|
| 1013 |
+
input_tokens = int(usage.get("promptTokenCount", 0) or 0)
|
| 1014 |
+
if "candidatesTokenCount" in usage:
|
| 1015 |
+
output_tokens = int(usage.get("candidatesTokenCount", 0) or 0)
|
| 1016 |
+
|
| 1017 |
+
# 发送 message_start(仅一次)
|
| 1018 |
+
if not message_start_sent:
|
| 1019 |
+
message_start_sent = True
|
| 1020 |
+
yield _sse_event(
|
| 1021 |
+
"message_start",
|
| 1022 |
+
{
|
| 1023 |
+
"type": "message_start",
|
| 1024 |
+
"message": {
|
| 1025 |
+
"id": message_id,
|
| 1026 |
+
"type": "message",
|
| 1027 |
+
"role": "assistant",
|
| 1028 |
+
"model": model,
|
| 1029 |
+
"content": [],
|
| 1030 |
+
"stop_reason": None,
|
| 1031 |
+
"stop_sequence": None,
|
| 1032 |
+
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
| 1033 |
+
},
|
| 1034 |
+
},
|
| 1035 |
+
)
|
| 1036 |
+
|
| 1037 |
+
# 处理各种 parts
|
| 1038 |
+
for part in parts:
|
| 1039 |
+
if not isinstance(part, dict):
|
| 1040 |
+
continue
|
| 1041 |
+
|
| 1042 |
+
# 处理 thinking 块
|
| 1043 |
+
if part.get("thought") is True:
|
| 1044 |
+
thinking_text = part.get("text", "")
|
| 1045 |
+
thoughtsignature = part.get("thoughtSignature")
|
| 1046 |
+
|
| 1047 |
+
# 检查是否需要关闭上一个块并开启新的 thinking 块
|
| 1048 |
+
if current_block_type != "thinking":
|
| 1049 |
+
close_evt = _close_block()
|
| 1050 |
+
if close_evt:
|
| 1051 |
+
yield close_evt
|
| 1052 |
+
|
| 1053 |
+
current_block_index += 1
|
| 1054 |
+
current_block_type = "thinking"
|
| 1055 |
+
current_thinking_signature = thoughtsignature
|
| 1056 |
+
|
| 1057 |
+
block: Dict[str, Any] = {"type": "thinking", "thinking": ""}
|
| 1058 |
+
if thoughtsignature:
|
| 1059 |
+
block["thoughtSignature"] = thoughtsignature
|
| 1060 |
+
yield _sse_event(
|
| 1061 |
+
"content_block_start",
|
| 1062 |
+
{
|
| 1063 |
+
"type": "content_block_start",
|
| 1064 |
+
"index": current_block_index,
|
| 1065 |
+
"content_block": block,
|
| 1066 |
+
},
|
| 1067 |
+
)
|
| 1068 |
+
elif thoughtsignature and thoughtsignature != current_thinking_signature:
|
| 1069 |
+
# 签名变化,需要开启新的 thinking 块
|
| 1070 |
+
close_evt = _close_block()
|
| 1071 |
+
if close_evt:
|
| 1072 |
+
yield close_evt
|
| 1073 |
+
|
| 1074 |
+
current_block_index += 1
|
| 1075 |
+
current_block_type = "thinking"
|
| 1076 |
+
current_thinking_signature = thoughtsignature
|
| 1077 |
+
|
| 1078 |
+
block_new: Dict[str, Any] = {"type": "thinking", "thinking": ""}
|
| 1079 |
+
if thoughtsignature:
|
| 1080 |
+
block_new["thoughtSignature"] = thoughtsignature
|
| 1081 |
+
|
| 1082 |
+
yield _sse_event(
|
| 1083 |
+
"content_block_start",
|
| 1084 |
+
{
|
| 1085 |
+
"type": "content_block_start",
|
| 1086 |
+
"index": current_block_index,
|
| 1087 |
+
"content_block": block_new,
|
| 1088 |
+
},
|
| 1089 |
+
)
|
| 1090 |
+
|
| 1091 |
+
# 发送 thinking 文本增量
|
| 1092 |
+
if thinking_text:
|
| 1093 |
+
yield _sse_event(
|
| 1094 |
+
"content_block_delta",
|
| 1095 |
+
{
|
| 1096 |
+
"type": "content_block_delta",
|
| 1097 |
+
"index": current_block_index,
|
| 1098 |
+
"delta": {"type": "thinking_delta", "thinking": thinking_text},
|
| 1099 |
+
},
|
| 1100 |
+
)
|
| 1101 |
+
continue
|
| 1102 |
+
|
| 1103 |
+
# 处理文本块
|
| 1104 |
+
if "text" in part:
|
| 1105 |
+
text = part.get("text", "")
|
| 1106 |
+
if isinstance(text, str) and not text.strip():
|
| 1107 |
+
continue
|
| 1108 |
+
|
| 1109 |
+
if current_block_type != "text":
|
| 1110 |
+
close_evt = _close_block()
|
| 1111 |
+
if close_evt:
|
| 1112 |
+
yield close_evt
|
| 1113 |
+
|
| 1114 |
+
current_block_index += 1
|
| 1115 |
+
current_block_type = "text"
|
| 1116 |
+
|
| 1117 |
+
yield _sse_event(
|
| 1118 |
+
"content_block_start",
|
| 1119 |
+
{
|
| 1120 |
+
"type": "content_block_start",
|
| 1121 |
+
"index": current_block_index,
|
| 1122 |
+
"content_block": {"type": "text", "text": ""},
|
| 1123 |
+
},
|
| 1124 |
+
)
|
| 1125 |
+
|
| 1126 |
+
if text:
|
| 1127 |
+
yield _sse_event(
|
| 1128 |
+
"content_block_delta",
|
| 1129 |
+
{
|
| 1130 |
+
"type": "content_block_delta",
|
| 1131 |
+
"index": current_block_index,
|
| 1132 |
+
"delta": {"type": "text_delta", "text": text},
|
| 1133 |
+
},
|
| 1134 |
+
)
|
| 1135 |
+
continue
|
| 1136 |
+
|
| 1137 |
+
# 处理工具调用
|
| 1138 |
+
if "functionCall" in part:
|
| 1139 |
+
close_evt = _close_block()
|
| 1140 |
+
if close_evt:
|
| 1141 |
+
yield close_evt
|
| 1142 |
+
|
| 1143 |
+
has_tool_use = True
|
| 1144 |
+
fc = part.get("functionCall", {}) or {}
|
| 1145 |
+
original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
|
| 1146 |
+
thoughtsignature = part.get("thoughtSignature")
|
| 1147 |
+
tool_id = encode_tool_id_with_signature(original_id, thoughtsignature)
|
| 1148 |
+
tool_name = fc.get("name") or ""
|
| 1149 |
+
tool_args = _remove_nulls_for_tool_input(fc.get("args", {}) or {})
|
| 1150 |
+
|
| 1151 |
+
if _anthropic_debug_enabled():
|
| 1152 |
+
log.info(
|
| 1153 |
+
f"[ANTHROPIC][tool_use] 处理工具调用: name={tool_name}, "
|
| 1154 |
+
f"id={tool_id}, has_signature={thoughtsignature is not None}"
|
| 1155 |
+
)
|
| 1156 |
+
|
| 1157 |
+
current_block_index += 1
|
| 1158 |
+
# 注意:工具调用不设置 current_block_type,因为它是独立完整的块
|
| 1159 |
+
|
| 1160 |
+
yield _sse_event(
|
| 1161 |
+
"content_block_start",
|
| 1162 |
+
{
|
| 1163 |
+
"type": "content_block_start",
|
| 1164 |
+
"index": current_block_index,
|
| 1165 |
+
"content_block": {
|
| 1166 |
+
"type": "tool_use",
|
| 1167 |
+
"id": tool_id,
|
| 1168 |
+
"name": tool_name,
|
| 1169 |
+
"input": {},
|
| 1170 |
+
},
|
| 1171 |
+
},
|
| 1172 |
+
)
|
| 1173 |
+
|
| 1174 |
+
input_json = json.dumps(tool_args, ensure_ascii=False, separators=(",", ":"))
|
| 1175 |
+
yield _sse_event(
|
| 1176 |
+
"content_block_delta",
|
| 1177 |
+
{
|
| 1178 |
+
"type": "content_block_delta",
|
| 1179 |
+
"index": current_block_index,
|
| 1180 |
+
"delta": {"type": "input_json_delta", "partial_json": input_json},
|
| 1181 |
+
},
|
| 1182 |
+
)
|
| 1183 |
+
|
| 1184 |
+
yield _sse_event(
|
| 1185 |
+
"content_block_stop",
|
| 1186 |
+
{"type": "content_block_stop", "index": current_block_index},
|
| 1187 |
+
)
|
| 1188 |
+
# 工具调用块已完全关闭,current_block_type 保持为 None
|
| 1189 |
+
|
| 1190 |
+
if _anthropic_debug_enabled():
|
| 1191 |
+
log.info(f"[ANTHROPIC][tool_use] 工具调用块已关闭: index={current_block_index}")
|
| 1192 |
+
|
| 1193 |
+
continue
|
| 1194 |
+
|
| 1195 |
+
# 检查是否结束
|
| 1196 |
+
if candidate.get("finishReason"):
|
| 1197 |
+
finish_reason = candidate.get("finishReason")
|
| 1198 |
+
break
|
| 1199 |
+
|
| 1200 |
+
# 关闭最后的内容块
|
| 1201 |
+
close_evt = _close_block()
|
| 1202 |
+
if close_evt:
|
| 1203 |
+
yield close_evt
|
| 1204 |
+
|
| 1205 |
+
# 确定停止原因
|
| 1206 |
+
# 只有在正常停止(STOP)且有工具调用时才设为 tool_use
|
| 1207 |
+
# 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环
|
| 1208 |
+
if has_tool_use and finish_reason == "STOP":
|
| 1209 |
+
stop_reason = "tool_use"
|
| 1210 |
+
elif finish_reason == "MAX_TOKENS":
|
| 1211 |
+
stop_reason = "max_tokens"
|
| 1212 |
+
else:
|
| 1213 |
+
# 其他情况(SAFETY、RECITATION 等)默认为 end_turn
|
| 1214 |
+
stop_reason = "end_turn"
|
| 1215 |
+
|
| 1216 |
+
if _anthropic_debug_enabled():
|
| 1217 |
+
log.info(
|
| 1218 |
+
f"[ANTHROPIC][stream_end] 流式结束: stop_reason={stop_reason}, "
|
| 1219 |
+
f"has_tool_use={has_tool_use}, finish_reason={finish_reason}, "
|
| 1220 |
+
f"input_tokens={input_tokens}, output_tokens={output_tokens}"
|
| 1221 |
+
)
|
| 1222 |
+
|
| 1223 |
+
# 发送 message_delta 和 message_stop
|
| 1224 |
+
yield _sse_event(
|
| 1225 |
+
"message_delta",
|
| 1226 |
+
{
|
| 1227 |
+
"type": "message_delta",
|
| 1228 |
+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
| 1229 |
+
"usage": {
|
| 1230 |
+
"output_tokens": output_tokens,
|
| 1231 |
+
},
|
| 1232 |
+
},
|
| 1233 |
+
)
|
| 1234 |
+
|
| 1235 |
+
yield _sse_event("message_stop", {"type": "message_stop"})
|
| 1236 |
+
|
| 1237 |
+
except Exception as e:
|
| 1238 |
+
log.error(f"[ANTHROPIC] 流式转换失败: {e}")
|
| 1239 |
+
# 发送错误事件
|
| 1240 |
+
if not message_start_sent:
|
| 1241 |
+
yield _sse_event(
|
| 1242 |
+
"message_start",
|
| 1243 |
+
{
|
| 1244 |
+
"type": "message_start",
|
| 1245 |
+
"message": {
|
| 1246 |
+
"id": message_id,
|
| 1247 |
+
"type": "message",
|
| 1248 |
+
"role": "assistant",
|
| 1249 |
+
"model": model,
|
| 1250 |
+
"content": [],
|
| 1251 |
+
"stop_reason": None,
|
| 1252 |
+
"stop_sequence": None,
|
| 1253 |
+
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
| 1254 |
+
},
|
| 1255 |
+
},
|
| 1256 |
+
)
|
| 1257 |
+
yield _sse_event(
|
| 1258 |
+
"error",
|
| 1259 |
+
{"type": "error", "error": {"type": "api_error", "message": str(e)}},
|
| 1260 |
+
)
|
src/converter/anti_truncation.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Anti-Truncation Module - Ensures complete streaming output
|
| 3 |
+
保持一个流式请求内完整输出的反截断模块
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
from typing import Any, AsyncGenerator, Dict, List, Tuple
|
| 10 |
+
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
|
| 13 |
+
from log import log
|
| 14 |
+
|
| 15 |
+
# 反截断配置
|
| 16 |
+
DONE_MARKER = "[done]"
|
| 17 |
+
CONTINUATION_PROMPT = f"""请从刚才被截断的地方继续输出剩余的所有内容。
|
| 18 |
+
|
| 19 |
+
重要提醒:
|
| 20 |
+
1. 不要重复前面已经输出的内容
|
| 21 |
+
2. 直接继续输出,无需任何前言或解释
|
| 22 |
+
3. 当你完整完成所有内容输出后,必须在最后一行单独输出:{DONE_MARKER}
|
| 23 |
+
4. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记
|
| 24 |
+
|
| 25 |
+
现在请继续输出:"""
|
| 26 |
+
|
| 27 |
+
# 正则替换配置
|
| 28 |
+
REGEX_REPLACEMENTS: List[Tuple[str, str, str]] = [
|
| 29 |
+
(
|
| 30 |
+
"age_pattern", # 替换规则名称
|
| 31 |
+
r"(?:[1-9]|1[0-8])岁(?:的)?|(?:十一|十二|十三|十四|十五|十六|十七|十八|十|一|二|三|四|五|六|七|八|九)岁(?:的)?", # 正则模式
|
| 32 |
+
"", # 替换文本
|
| 33 |
+
),
|
| 34 |
+
# 可在此处添加更多替换规则
|
| 35 |
+
# ("rule_name", r"pattern", "replacement"),
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def apply_regex_replacements(text: str) -> str:
|
| 40 |
+
"""
|
| 41 |
+
对文本应用正则替换规则
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
text: 要处理的文本
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
处理后的文本
|
| 48 |
+
"""
|
| 49 |
+
if not text:
|
| 50 |
+
return text
|
| 51 |
+
|
| 52 |
+
processed_text = text
|
| 53 |
+
replacement_count = 0
|
| 54 |
+
|
| 55 |
+
for rule_name, pattern, replacement in REGEX_REPLACEMENTS:
|
| 56 |
+
try:
|
| 57 |
+
# 编译正则表达式,使用IGNORECASE标志
|
| 58 |
+
regex = re.compile(pattern, re.IGNORECASE)
|
| 59 |
+
|
| 60 |
+
# 执行替换
|
| 61 |
+
new_text, count = regex.subn(replacement, processed_text)
|
| 62 |
+
|
| 63 |
+
if count > 0:
|
| 64 |
+
log.debug(f"Regex replacement '{rule_name}': {count} matches replaced")
|
| 65 |
+
processed_text = new_text
|
| 66 |
+
replacement_count += count
|
| 67 |
+
|
| 68 |
+
except re.error as e:
|
| 69 |
+
log.error(f"Invalid regex pattern in rule '{rule_name}': {e}")
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
if replacement_count > 0:
|
| 73 |
+
log.info(f"Applied {replacement_count} regex replacements to text")
|
| 74 |
+
|
| 75 |
+
return processed_text
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def apply_regex_replacements_to_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 79 |
+
"""
|
| 80 |
+
对请求payload中的文本内容应用正则替换
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
payload: 请求payload
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
应用替换后的payload
|
| 87 |
+
"""
|
| 88 |
+
if not REGEX_REPLACEMENTS:
|
| 89 |
+
return payload
|
| 90 |
+
|
| 91 |
+
modified_payload = payload.copy()
|
| 92 |
+
request_data = modified_payload.get("request", {})
|
| 93 |
+
|
| 94 |
+
# 处理contents中的文本
|
| 95 |
+
contents = request_data.get("contents", [])
|
| 96 |
+
if contents:
|
| 97 |
+
new_contents = []
|
| 98 |
+
for content in contents:
|
| 99 |
+
if isinstance(content, dict):
|
| 100 |
+
new_content = content.copy()
|
| 101 |
+
parts = new_content.get("parts", [])
|
| 102 |
+
if parts:
|
| 103 |
+
new_parts = []
|
| 104 |
+
for part in parts:
|
| 105 |
+
if isinstance(part, dict) and "text" in part:
|
| 106 |
+
new_part = part.copy()
|
| 107 |
+
new_part["text"] = apply_regex_replacements(part["text"])
|
| 108 |
+
new_parts.append(new_part)
|
| 109 |
+
else:
|
| 110 |
+
new_parts.append(part)
|
| 111 |
+
new_content["parts"] = new_parts
|
| 112 |
+
new_contents.append(new_content)
|
| 113 |
+
else:
|
| 114 |
+
new_contents.append(content)
|
| 115 |
+
|
| 116 |
+
request_data["contents"] = new_contents
|
| 117 |
+
modified_payload["request"] = request_data
|
| 118 |
+
log.debug("Applied regex replacements to request contents")
|
| 119 |
+
|
| 120 |
+
return modified_payload
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def apply_anti_truncation(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 124 |
+
"""
|
| 125 |
+
对请求payload应用反截断处理和正则替换
|
| 126 |
+
在systemInstruction中添加提醒,要求模型在结束时输出DONE_MARKER标记
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
payload: 原始请求payload
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
添加了反截断指令并应用了正则替换的payload
|
| 133 |
+
"""
|
| 134 |
+
# 首先应用正则替换
|
| 135 |
+
modified_payload = apply_regex_replacements_to_payload(payload)
|
| 136 |
+
request_data = modified_payload.get("request", {})
|
| 137 |
+
|
| 138 |
+
# 获取或创建systemInstruction
|
| 139 |
+
system_instruction = request_data.get("systemInstruction", {})
|
| 140 |
+
if not system_instruction:
|
| 141 |
+
system_instruction = {"parts": []}
|
| 142 |
+
elif "parts" not in system_instruction:
|
| 143 |
+
system_instruction["parts"] = []
|
| 144 |
+
|
| 145 |
+
# 添加反截断指令
|
| 146 |
+
anti_truncation_instruction = {
|
| 147 |
+
"text": f"""严格执行以下输出结束规则:
|
| 148 |
+
|
| 149 |
+
1. 当你完成完整回答时,必须在输出的最后单独一行输出:{DONE_MARKER}
|
| 150 |
+
2. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记
|
| 151 |
+
3. 只有输出了 {DONE_MARKER} 标记,系统才认为你的回答是完整的
|
| 152 |
+
4. 如果你的回答被截断,系统会要求你继续输出剩余内容
|
| 153 |
+
5. 无论回答长短,都必须以 {DONE_MARKER} 标记结束
|
| 154 |
+
|
| 155 |
+
示例格式:
|
| 156 |
+
```
|
| 157 |
+
你的回答内容...
|
| 158 |
+
更多回答内容...
|
| 159 |
+
{DONE_MARKER}
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
注意:{DONE_MARKER} 必须单独占一行,前面不要有任何其他字符。
|
| 163 |
+
|
| 164 |
+
这个规则对于确保输出完整性极其重要,请严格遵守。"""
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
# 检查是否已经包含反截断指令
|
| 168 |
+
has_done_instruction = any(
|
| 169 |
+
part.get("text", "").find(DONE_MARKER) != -1
|
| 170 |
+
for part in system_instruction["parts"]
|
| 171 |
+
if isinstance(part, dict)
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
if not has_done_instruction:
|
| 175 |
+
system_instruction["parts"].append(anti_truncation_instruction)
|
| 176 |
+
request_data["systemInstruction"] = system_instruction
|
| 177 |
+
modified_payload["request"] = request_data
|
| 178 |
+
|
| 179 |
+
log.debug("Applied anti-truncation instruction to request")
|
| 180 |
+
|
| 181 |
+
return modified_payload
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class AntiTruncationStreamProcessor:
|
| 185 |
+
"""反截断流式处理器"""
|
| 186 |
+
|
| 187 |
+
def __init__(
|
| 188 |
+
self,
|
| 189 |
+
original_request_func,
|
| 190 |
+
payload: Dict[str, Any],
|
| 191 |
+
max_attempts: int = 3,
|
| 192 |
+
enable_prefill_mode: bool = False,
|
| 193 |
+
):
|
| 194 |
+
self.original_request_func = original_request_func
|
| 195 |
+
self.base_payload = payload.copy()
|
| 196 |
+
self.max_attempts = max_attempts
|
| 197 |
+
self.enable_prefill_mode = enable_prefill_mode
|
| 198 |
+
# 使用 StringIO 避免字符串拼接的内存问题
|
| 199 |
+
self.collected_content = io.StringIO()
|
| 200 |
+
self.current_attempt = 0
|
| 201 |
+
|
| 202 |
+
def _get_collected_text(self) -> str:
|
| 203 |
+
"""获取收集的文本内容"""
|
| 204 |
+
return self.collected_content.getvalue()
|
| 205 |
+
|
| 206 |
+
def _append_content(self, content: str):
|
| 207 |
+
"""追加内容到收集器"""
|
| 208 |
+
if content:
|
| 209 |
+
self.collected_content.write(content)
|
| 210 |
+
|
| 211 |
+
def _clear_content(self):
|
| 212 |
+
"""清空收集的内容,释放内存"""
|
| 213 |
+
self.collected_content.close()
|
| 214 |
+
self.collected_content = io.StringIO()
|
| 215 |
+
|
| 216 |
+
async def process_stream(self) -> AsyncGenerator[bytes, None]:
|
| 217 |
+
"""处理流式响应,检测并处理截断"""
|
| 218 |
+
|
| 219 |
+
while self.current_attempt < self.max_attempts:
|
| 220 |
+
self.current_attempt += 1
|
| 221 |
+
|
| 222 |
+
# 构建当前请求payload
|
| 223 |
+
current_payload = self._build_current_payload()
|
| 224 |
+
|
| 225 |
+
log.debug(f"Anti-truncation attempt {self.current_attempt}/{self.max_attempts}")
|
| 226 |
+
|
| 227 |
+
# 发送请求
|
| 228 |
+
try:
|
| 229 |
+
response = await self.original_request_func(current_payload)
|
| 230 |
+
|
| 231 |
+
if not isinstance(response, StreamingResponse):
|
| 232 |
+
# 非流式响应,直接处理
|
| 233 |
+
yield await self._handle_non_streaming_response(response)
|
| 234 |
+
return
|
| 235 |
+
|
| 236 |
+
# 处理流式响应(按行处理)
|
| 237 |
+
chunk_buffer = io.StringIO() # 使用 StringIO 缓存当前轮次的chunk
|
| 238 |
+
found_done_marker = False
|
| 239 |
+
|
| 240 |
+
async for line in response.body_iterator:
|
| 241 |
+
if not line:
|
| 242 |
+
yield line
|
| 243 |
+
continue
|
| 244 |
+
|
| 245 |
+
# 处理上游生成器 yield 出 Response 对象的情况(错误响应)
|
| 246 |
+
from fastapi import Response as FastAPIResponse
|
| 247 |
+
if isinstance(line, FastAPIResponse):
|
| 248 |
+
log.error(f"Anti-truncation: Received Response object from stream (status={line.status_code}), treating as error")
|
| 249 |
+
error_chunk = {
|
| 250 |
+
"error": {
|
| 251 |
+
"message": line.body.decode('utf-8', errors='ignore') if hasattr(line, 'body') and line.body else "Upstream error",
|
| 252 |
+
"type": "api_error",
|
| 253 |
+
"code": line.status_code,
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
yield f"data: {json.dumps(error_chunk)}\n\n".encode()
|
| 257 |
+
yield b"data: [DONE]\n\n"
|
| 258 |
+
return
|
| 259 |
+
|
| 260 |
+
# 处理 bytes 类型的流式数据
|
| 261 |
+
if isinstance(line, bytes):
|
| 262 |
+
# 解码 bytes 为字符串
|
| 263 |
+
line_str = line.decode('utf-8', errors='ignore').strip()
|
| 264 |
+
else:
|
| 265 |
+
line_str = str(line).strip()
|
| 266 |
+
|
| 267 |
+
# 跳过空行
|
| 268 |
+
if not line_str:
|
| 269 |
+
yield line
|
| 270 |
+
continue
|
| 271 |
+
|
| 272 |
+
# 处理 SSE 格式的数据行
|
| 273 |
+
if line_str.startswith("data: "):
|
| 274 |
+
payload_str = line_str[6:] # 去掉 "data: " 前缀
|
| 275 |
+
|
| 276 |
+
# 检查是否是 [DONE] 标记
|
| 277 |
+
if payload_str.strip() == "[DONE]":
|
| 278 |
+
if found_done_marker:
|
| 279 |
+
log.info("Anti-truncation: Found [done] marker, output complete")
|
| 280 |
+
yield line
|
| 281 |
+
# 清理内存
|
| 282 |
+
chunk_buffer.close()
|
| 283 |
+
self._clear_content()
|
| 284 |
+
return
|
| 285 |
+
else:
|
| 286 |
+
log.warning("Anti-truncation: Stream ended without [done] marker")
|
| 287 |
+
# 不发送[DONE],准备继续
|
| 288 |
+
break
|
| 289 |
+
|
| 290 |
+
# 尝试解析 JSON 数据
|
| 291 |
+
try:
|
| 292 |
+
data = json.loads(payload_str)
|
| 293 |
+
content = self._extract_content_from_chunk(data)
|
| 294 |
+
|
| 295 |
+
log.debug(f"Anti-truncation: Extracted content: {repr(content[:100] if content else '')}")
|
| 296 |
+
|
| 297 |
+
if content:
|
| 298 |
+
chunk_buffer.write(content)
|
| 299 |
+
|
| 300 |
+
# 检查是否包含done标记
|
| 301 |
+
has_marker = self._check_done_marker_in_chunk_content(content)
|
| 302 |
+
log.debug(f"Anti-truncation: Check done marker result: {has_marker}, DONE_MARKER='{DONE_MARKER}'")
|
| 303 |
+
if has_marker:
|
| 304 |
+
found_done_marker = True
|
| 305 |
+
log.debug(f"Anti-truncation: Found [done] marker in chunk, content: {content[:200]}")
|
| 306 |
+
|
| 307 |
+
# 清理行中的[done]标记后再发送
|
| 308 |
+
cleaned_line = self._remove_done_marker_from_line(line, line_str, data)
|
| 309 |
+
yield cleaned_line
|
| 310 |
+
|
| 311 |
+
except (json.JSONDecodeError, ValueError):
|
| 312 |
+
# 无法解析的行,直接传递
|
| 313 |
+
yield line
|
| 314 |
+
continue
|
| 315 |
+
else:
|
| 316 |
+
# 非 data: 开头的行,直接传递
|
| 317 |
+
yield line
|
| 318 |
+
|
| 319 |
+
# 更新收集的内容 - 使用 StringIO 高效处理
|
| 320 |
+
chunk_text = chunk_buffer.getvalue()
|
| 321 |
+
if chunk_text:
|
| 322 |
+
self._append_content(chunk_text)
|
| 323 |
+
chunk_buffer.close()
|
| 324 |
+
|
| 325 |
+
log.debug(f"Anti-truncation: After processing stream, found_done_marker={found_done_marker}")
|
| 326 |
+
|
| 327 |
+
# 如果找到了done标记,结束
|
| 328 |
+
if found_done_marker:
|
| 329 |
+
# 立即清理内容释放内存
|
| 330 |
+
self._clear_content()
|
| 331 |
+
yield b"data: [DONE]\n\n"
|
| 332 |
+
return
|
| 333 |
+
|
| 334 |
+
# 只有在单个chunk中没有找到done标记时,才检查累积内容(防止done标记跨chunk出现)
|
| 335 |
+
if not found_done_marker:
|
| 336 |
+
accumulated_text = self._get_collected_text()
|
| 337 |
+
if self._check_done_marker_in_text(accumulated_text):
|
| 338 |
+
log.info("Anti-truncation: Found [done] marker in accumulated content")
|
| 339 |
+
# 立即清理内容释放内存
|
| 340 |
+
self._clear_content()
|
| 341 |
+
yield b"data: [DONE]\n\n"
|
| 342 |
+
return
|
| 343 |
+
|
| 344 |
+
# 如果没找到done标记且不是最后一次尝试,准备续传
|
| 345 |
+
if self.current_attempt < self.max_attempts:
|
| 346 |
+
accumulated_text = self._get_collected_text()
|
| 347 |
+
total_length = len(accumulated_text)
|
| 348 |
+
log.info(
|
| 349 |
+
f"Anti-truncation: No [done] marker found in output (length: {total_length}), preparing continuation (attempt {self.current_attempt + 1})"
|
| 350 |
+
)
|
| 351 |
+
if total_length > 100:
|
| 352 |
+
log.debug(
|
| 353 |
+
f"Anti-truncation: Current collected content ends with: ...{accumulated_text[-100:]}"
|
| 354 |
+
)
|
| 355 |
+
# 在下一次循环中会继续
|
| 356 |
+
continue
|
| 357 |
+
else:
|
| 358 |
+
# 最后一次尝试,直接结束
|
| 359 |
+
log.warning("Anti-truncation: Max attempts reached, ending stream")
|
| 360 |
+
# 立即清理内容释放内存
|
| 361 |
+
self._clear_content()
|
| 362 |
+
yield b"data: [DONE]\n\n"
|
| 363 |
+
return
|
| 364 |
+
|
| 365 |
+
except Exception as e:
|
| 366 |
+
log.error(f"Anti-truncation error in attempt {self.current_attempt}: {str(e)}")
|
| 367 |
+
if self.current_attempt >= self.max_attempts:
|
| 368 |
+
# 发送错误chunk
|
| 369 |
+
error_chunk = {
|
| 370 |
+
"error": {
|
| 371 |
+
"message": f"Anti-truncation failed: {str(e)}",
|
| 372 |
+
"type": "api_error",
|
| 373 |
+
"code": 500,
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
yield f"data: {json.dumps(error_chunk)}\n\n".encode()
|
| 377 |
+
yield b"data: [DONE]\n\n"
|
| 378 |
+
return
|
| 379 |
+
# 否则继续下一次尝试
|
| 380 |
+
|
| 381 |
+
# 如果所有尝试都失败了
|
| 382 |
+
log.error("Anti-truncation: All attempts failed")
|
| 383 |
+
# 清理内存
|
| 384 |
+
self._clear_content()
|
| 385 |
+
yield b"data: [DONE]\n\n"
|
| 386 |
+
|
| 387 |
+
def _build_current_payload(self) -> Dict[str, Any]:
|
| 388 |
+
"""构建当前请求的payload"""
|
| 389 |
+
if self.current_attempt == 1:
|
| 390 |
+
# 第一次请求,使用原始payload(已经包含反截断指令)
|
| 391 |
+
return self.base_payload
|
| 392 |
+
|
| 393 |
+
# 后续请求,添加续传指令
|
| 394 |
+
continuation_payload = self.base_payload.copy()
|
| 395 |
+
request_data = continuation_payload.get("request", {})
|
| 396 |
+
|
| 397 |
+
# 获取原始对话内容
|
| 398 |
+
contents = request_data.get("contents", [])
|
| 399 |
+
new_contents = contents.copy()
|
| 400 |
+
|
| 401 |
+
# 如果有收集到的内容,添加到对话中
|
| 402 |
+
accumulated_text = self._get_collected_text()
|
| 403 |
+
if accumulated_text:
|
| 404 |
+
new_contents.append({"role": "model", "parts": [{"text": accumulated_text}]})
|
| 405 |
+
|
| 406 |
+
# 预填充模式:直接用拼接内容作为末尾 model 预填充,不再增加 user 续写指令
|
| 407 |
+
if self.enable_prefill_mode:
|
| 408 |
+
log.debug("Anti-truncation: Using prefill continuation mode (no user continuation prompt)")
|
| 409 |
+
request_data["contents"] = new_contents
|
| 410 |
+
continuation_payload["request"] = request_data
|
| 411 |
+
return continuation_payload
|
| 412 |
+
|
| 413 |
+
# 构建具体的续写指令,包含前面的内容摘要
|
| 414 |
+
content_summary = ""
|
| 415 |
+
if accumulated_text:
|
| 416 |
+
if len(accumulated_text) > 200:
|
| 417 |
+
content_summary = f'\n\n前面你已经输出了约 {len(accumulated_text)} 个字符的内容,结尾是:\n"...{accumulated_text[-100:]}"'
|
| 418 |
+
else:
|
| 419 |
+
content_summary = f'\n\n前面你已经输出的内容是:\n"{accumulated_text}"'
|
| 420 |
+
|
| 421 |
+
detailed_continuation_prompt = f"""{CONTINUATION_PROMPT}{content_summary}"""
|
| 422 |
+
|
| 423 |
+
# 添加继续指令
|
| 424 |
+
continuation_message = {"role": "user", "parts": [{"text": detailed_continuation_prompt}]}
|
| 425 |
+
new_contents.append(continuation_message)
|
| 426 |
+
|
| 427 |
+
request_data["contents"] = new_contents
|
| 428 |
+
continuation_payload["request"] = request_data
|
| 429 |
+
|
| 430 |
+
return continuation_payload
|
| 431 |
+
|
| 432 |
+
def _extract_content_from_chunk(self, data: Dict[str, Any]) -> str:
|
| 433 |
+
"""从chunk数据中提取文本内容"""
|
| 434 |
+
content = ""
|
| 435 |
+
|
| 436 |
+
# 先尝试解包 response 字段(Gemini API 格式)
|
| 437 |
+
if "response" in data:
|
| 438 |
+
data = data["response"]
|
| 439 |
+
|
| 440 |
+
# 处理 Gemini 格式
|
| 441 |
+
if "candidates" in data:
|
| 442 |
+
for candidate in data["candidates"]:
|
| 443 |
+
if "content" in candidate:
|
| 444 |
+
parts = candidate["content"].get("parts", [])
|
| 445 |
+
for part in parts:
|
| 446 |
+
if "text" in part:
|
| 447 |
+
content += part["text"]
|
| 448 |
+
|
| 449 |
+
# 处理 OpenAI 流式格式(choices/delta)
|
| 450 |
+
elif "choices" in data:
|
| 451 |
+
for choice in data["choices"]:
|
| 452 |
+
if "delta" in choice and "content" in choice["delta"]:
|
| 453 |
+
delta_content = choice["delta"]["content"]
|
| 454 |
+
if delta_content:
|
| 455 |
+
content += delta_content
|
| 456 |
+
|
| 457 |
+
return content
|
| 458 |
+
|
| 459 |
+
async def _handle_non_streaming_response(self, response) -> bytes:
|
| 460 |
+
"""处理非流式响应 - 使用循环代替递归避免栈溢出"""
|
| 461 |
+
# 使用循环代替递归
|
| 462 |
+
while True:
|
| 463 |
+
try:
|
| 464 |
+
# 特殊处理:如果返回的是StreamingResponse,需要读取其body_iterator
|
| 465 |
+
if isinstance(response, StreamingResponse):
|
| 466 |
+
log.error("Anti-truncation: Received StreamingResponse in non-streaming handler - this should not happen")
|
| 467 |
+
# 尝试读取流式响应的内容
|
| 468 |
+
chunks = []
|
| 469 |
+
async for chunk in response.body_iterator:
|
| 470 |
+
chunks.append(chunk)
|
| 471 |
+
content = b"".join(chunks).decode() if chunks else ""
|
| 472 |
+
# 提取响应内容
|
| 473 |
+
elif hasattr(response, "body"):
|
| 474 |
+
content = (
|
| 475 |
+
response.body.decode() if isinstance(response.body, bytes) else response.body
|
| 476 |
+
)
|
| 477 |
+
elif hasattr(response, "content"):
|
| 478 |
+
content = (
|
| 479 |
+
response.content.decode()
|
| 480 |
+
if isinstance(response.content, bytes)
|
| 481 |
+
else response.content
|
| 482 |
+
)
|
| 483 |
+
else:
|
| 484 |
+
log.error(f"Anti-truncation: Unknown response type: {type(response)}")
|
| 485 |
+
content = str(response)
|
| 486 |
+
|
| 487 |
+
# 验证内容不为空
|
| 488 |
+
if not content or not content.strip():
|
| 489 |
+
log.error("Anti-truncation: Received empty response content")
|
| 490 |
+
return json.dumps(
|
| 491 |
+
{
|
| 492 |
+
"error": {
|
| 493 |
+
"message": "Empty response from server",
|
| 494 |
+
"type": "api_error",
|
| 495 |
+
"code": 500,
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
).encode()
|
| 499 |
+
|
| 500 |
+
# 尝试解析 JSON
|
| 501 |
+
try:
|
| 502 |
+
response_data = json.loads(content)
|
| 503 |
+
except json.JSONDecodeError as json_err:
|
| 504 |
+
log.error(f"Anti-truncation: Failed to parse JSON response: {json_err}, content: {content[:200]}")
|
| 505 |
+
# 如果不是 JSON,直接返回原始内容
|
| 506 |
+
return content.encode() if isinstance(content, str) else content
|
| 507 |
+
|
| 508 |
+
# 检查是否包含done标记
|
| 509 |
+
text_content = self._extract_content_from_response(response_data)
|
| 510 |
+
has_done_marker = self._check_done_marker_in_text(text_content)
|
| 511 |
+
|
| 512 |
+
if has_done_marker or self.current_attempt >= self.max_attempts:
|
| 513 |
+
# 找到done标记或达到最大尝试次数,返回结果
|
| 514 |
+
return content.encode() if isinstance(content, str) else content
|
| 515 |
+
|
| 516 |
+
# 需要继续,收集内容并构建下一个请求
|
| 517 |
+
if text_content:
|
| 518 |
+
self._append_content(text_content)
|
| 519 |
+
|
| 520 |
+
log.info("Anti-truncation: Non-streaming response needs continuation")
|
| 521 |
+
|
| 522 |
+
# 增加尝试次数
|
| 523 |
+
self.current_attempt += 1
|
| 524 |
+
|
| 525 |
+
# 构建续传payload并发送下一个请求
|
| 526 |
+
next_payload = self._build_current_payload()
|
| 527 |
+
response = await self.original_request_func(next_payload)
|
| 528 |
+
|
| 529 |
+
# 继续循环处理下一个响应
|
| 530 |
+
|
| 531 |
+
except Exception as e:
|
| 532 |
+
log.error(f"Anti-truncation non-streaming error: {str(e)}")
|
| 533 |
+
return json.dumps(
|
| 534 |
+
{
|
| 535 |
+
"error": {
|
| 536 |
+
"message": f"Anti-truncation failed: {str(e)}",
|
| 537 |
+
"type": "api_error",
|
| 538 |
+
"code": 500,
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
).encode()
|
| 542 |
+
|
| 543 |
+
def _check_done_marker_in_text(self, text: str) -> bool:
|
| 544 |
+
"""检测文本中是否包含DONE_MARKER(只检测指定标记)"""
|
| 545 |
+
if not text:
|
| 546 |
+
return False
|
| 547 |
+
|
| 548 |
+
# 只要文本中出现DONE_MARKER即可
|
| 549 |
+
return DONE_MARKER in text
|
| 550 |
+
|
| 551 |
+
def _check_done_marker_in_chunk_content(self, content: str) -> bool:
|
| 552 |
+
"""检查单个chunk内容中是否包含done标记"""
|
| 553 |
+
return self._check_done_marker_in_text(content)
|
| 554 |
+
|
| 555 |
+
def _extract_content_from_response(self, data: Dict[str, Any]) -> str:
|
| 556 |
+
"""从响应数据中提取文本内容"""
|
| 557 |
+
content = ""
|
| 558 |
+
|
| 559 |
+
# 先尝试解包 response 字段(Gemini API 格式)
|
| 560 |
+
if "response" in data:
|
| 561 |
+
data = data["response"]
|
| 562 |
+
|
| 563 |
+
# 处理Gemini格式
|
| 564 |
+
if "candidates" in data:
|
| 565 |
+
for candidate in data["candidates"]:
|
| 566 |
+
if "content" in candidate:
|
| 567 |
+
parts = candidate["content"].get("parts", [])
|
| 568 |
+
for part in parts:
|
| 569 |
+
if "text" in part:
|
| 570 |
+
content += part["text"]
|
| 571 |
+
|
| 572 |
+
# 处理OpenAI格式
|
| 573 |
+
elif "choices" in data:
|
| 574 |
+
for choice in data["choices"]:
|
| 575 |
+
if "message" in choice and "content" in choice["message"]:
|
| 576 |
+
content += choice["message"]["content"]
|
| 577 |
+
|
| 578 |
+
return content
|
| 579 |
+
|
| 580 |
+
def _remove_done_marker_from_line(self, line: bytes, line_str: str, data: Dict[str, Any]) -> bytes:
|
| 581 |
+
"""从行中移除[done]标记"""
|
| 582 |
+
try:
|
| 583 |
+
# 首先检查是否真的包含[done]标记
|
| 584 |
+
if "[done]" not in line_str.lower():
|
| 585 |
+
return line # 没有[done]标记,直接返回原始行
|
| 586 |
+
|
| 587 |
+
log.info(f"Anti-truncation: Attempting to remove [done] marker from line")
|
| 588 |
+
log.debug(f"Anti-truncation: Original line (first 200 chars): {line_str[:200]}")
|
| 589 |
+
|
| 590 |
+
# 编译正则表达式,匹配[done]标记(忽略大小写,包括可能的空白字符)
|
| 591 |
+
done_pattern = re.compile(r"\s*\[done\]\s*", re.IGNORECASE)
|
| 592 |
+
|
| 593 |
+
# 检查是否有 response 包裹层
|
| 594 |
+
has_response_wrapper = "response" in data
|
| 595 |
+
log.debug(f"Anti-truncation: has_response_wrapper={has_response_wrapper}, data keys={list(data.keys())}")
|
| 596 |
+
if has_response_wrapper:
|
| 597 |
+
# 需要保留外层的 response 字段
|
| 598 |
+
inner_data = data["response"]
|
| 599 |
+
else:
|
| 600 |
+
inner_data = data
|
| 601 |
+
|
| 602 |
+
log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
|
| 603 |
+
|
| 604 |
+
log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
|
| 605 |
+
|
| 606 |
+
# 处理Gemini格式
|
| 607 |
+
if "candidates" in inner_data:
|
| 608 |
+
log.info(f"Anti-truncation: Processing Gemini format to remove [done] marker")
|
| 609 |
+
modified_inner = inner_data.copy()
|
| 610 |
+
modified_inner["candidates"] = []
|
| 611 |
+
|
| 612 |
+
for i, candidate in enumerate(inner_data["candidates"]):
|
| 613 |
+
modified_candidate = candidate.copy()
|
| 614 |
+
# 只在最后一个candidate中清理[done]标记
|
| 615 |
+
is_last_candidate = i == len(inner_data["candidates"]) - 1
|
| 616 |
+
|
| 617 |
+
if "content" in candidate:
|
| 618 |
+
modified_content = candidate["content"].copy()
|
| 619 |
+
if "parts" in modified_content:
|
| 620 |
+
modified_parts = []
|
| 621 |
+
for part in modified_content["parts"]:
|
| 622 |
+
if "text" in part and isinstance(part["text"], str):
|
| 623 |
+
modified_part = part.copy()
|
| 624 |
+
original_text = part["text"]
|
| 625 |
+
# 只在最后一个candidate中清理[done]标记
|
| 626 |
+
if is_last_candidate:
|
| 627 |
+
modified_part["text"] = done_pattern.sub("", part["text"])
|
| 628 |
+
if "[done]" in original_text.lower():
|
| 629 |
+
log.debug(f"Anti-truncation: Removed [done] from text: '{original_text[:100]}' -> '{modified_part['text'][:100]}'")
|
| 630 |
+
modified_parts.append(modified_part)
|
| 631 |
+
else:
|
| 632 |
+
modified_parts.append(part)
|
| 633 |
+
modified_content["parts"] = modified_parts
|
| 634 |
+
modified_candidate["content"] = modified_content
|
| 635 |
+
modified_inner["candidates"].append(modified_candidate)
|
| 636 |
+
|
| 637 |
+
# 如果有 response 包裹层,需要重新包装
|
| 638 |
+
if has_response_wrapper:
|
| 639 |
+
modified_data = data.copy()
|
| 640 |
+
modified_data["response"] = modified_inner
|
| 641 |
+
else:
|
| 642 |
+
modified_data = modified_inner
|
| 643 |
+
|
| 644 |
+
# 重新编码为行格式 - SSE格式需要两个换行符
|
| 645 |
+
json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
|
| 646 |
+
result = f"data: {json_str}\n\n".encode("utf-8")
|
| 647 |
+
log.debug(f"Anti-truncation: Modified line (first 200 chars): {result.decode('utf-8', errors='ignore')[:200]}")
|
| 648 |
+
return result
|
| 649 |
+
|
| 650 |
+
# 处理OpenAI格式
|
| 651 |
+
elif "choices" in inner_data:
|
| 652 |
+
modified_inner = inner_data.copy()
|
| 653 |
+
modified_inner["choices"] = []
|
| 654 |
+
|
| 655 |
+
for choice in inner_data["choices"]:
|
| 656 |
+
modified_choice = choice.copy()
|
| 657 |
+
if "delta" in choice and "content" in choice["delta"]:
|
| 658 |
+
modified_delta = choice["delta"].copy()
|
| 659 |
+
modified_delta["content"] = done_pattern.sub("", choice["delta"]["content"])
|
| 660 |
+
modified_choice["delta"] = modified_delta
|
| 661 |
+
elif "message" in choice and "content" in choice["message"]:
|
| 662 |
+
modified_message = choice["message"].copy()
|
| 663 |
+
modified_message["content"] = done_pattern.sub("", choice["message"]["content"])
|
| 664 |
+
modified_choice["message"] = modified_message
|
| 665 |
+
modified_inner["choices"].append(modified_choice)
|
| 666 |
+
|
| 667 |
+
# 如果有 response 包裹层,需要重新包装
|
| 668 |
+
if has_response_wrapper:
|
| 669 |
+
modified_data = data.copy()
|
| 670 |
+
modified_data["response"] = modified_inner
|
| 671 |
+
else:
|
| 672 |
+
modified_data = modified_inner
|
| 673 |
+
|
| 674 |
+
# 重新编码为行格式 - SSE格式需要两个换行符
|
| 675 |
+
json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
|
| 676 |
+
return f"data: {json_str}\n\n".encode("utf-8")
|
| 677 |
+
|
| 678 |
+
# 如果没有找到支持的格式,返回原始行
|
| 679 |
+
return line
|
| 680 |
+
|
| 681 |
+
except Exception as e:
|
| 682 |
+
log.warning(f"Failed to remove [done] marker from line: {str(e)}")
|
| 683 |
+
return line
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
async def apply_anti_truncation_to_stream(
|
| 687 |
+
request_func,
|
| 688 |
+
payload: Dict[str, Any],
|
| 689 |
+
max_attempts: int = 3,
|
| 690 |
+
enable_prefill_mode: bool = False,
|
| 691 |
+
) -> StreamingResponse:
|
| 692 |
+
"""
|
| 693 |
+
对流式请求应用反截断处理
|
| 694 |
+
|
| 695 |
+
Args:
|
| 696 |
+
request_func: 原始请求函数
|
| 697 |
+
payload: 请求payload
|
| 698 |
+
max_attempts: 最大续传尝试次数
|
| 699 |
+
enable_prefill_mode: 是否启用预填充模式。启用后续传请求不再添加 user 续写指令,
|
| 700 |
+
而是将已收集内容作为末尾 model 内容进行预填充
|
| 701 |
+
|
| 702 |
+
Returns:
|
| 703 |
+
处理后的StreamingResponse
|
| 704 |
+
"""
|
| 705 |
+
|
| 706 |
+
# 首先对payload应用反截断指令
|
| 707 |
+
anti_truncation_payload = apply_anti_truncation(payload)
|
| 708 |
+
|
| 709 |
+
# 创建反截断处理器
|
| 710 |
+
processor = AntiTruncationStreamProcessor(
|
| 711 |
+
lambda p: request_func(p),
|
| 712 |
+
anti_truncation_payload,
|
| 713 |
+
max_attempts,
|
| 714 |
+
enable_prefill_mode,
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
# 返回包装后的流式响应
|
| 718 |
+
return StreamingResponse(processor.process_stream(), media_type="text/event-stream")
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def is_anti_truncation_enabled(request_data: Dict[str, Any]) -> bool:
|
| 722 |
+
"""
|
| 723 |
+
检查请求是否启用了反截断功能
|
| 724 |
+
|
| 725 |
+
Args:
|
| 726 |
+
request_data: 请求数据
|
| 727 |
+
|
| 728 |
+
Returns:
|
| 729 |
+
是否启用反截断
|
| 730 |
+
"""
|
| 731 |
+
return request_data.get("enable_anti_truncation", False)
|
src/converter/fake_stream.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Tuple
|
| 2 |
+
import json
|
| 3 |
+
from src.converter.utils import extract_content_and_reasoning
|
| 4 |
+
from log import log
|
| 5 |
+
from src.converter.openai2gemini import _convert_usage_metadata
|
| 6 |
+
|
| 7 |
+
def safe_get_nested(obj: Any, *keys: str, default: Any = None) -> Any:
|
| 8 |
+
"""安全获取嵌套字典值
|
| 9 |
+
|
| 10 |
+
Args:
|
| 11 |
+
obj: 字典对象
|
| 12 |
+
*keys: 嵌套键路径
|
| 13 |
+
default: 默认值
|
| 14 |
+
|
| 15 |
+
Returns:
|
| 16 |
+
获取到的值或默认值
|
| 17 |
+
"""
|
| 18 |
+
for key in keys:
|
| 19 |
+
if not isinstance(obj, dict):
|
| 20 |
+
return default
|
| 21 |
+
obj = obj.get(key, default)
|
| 22 |
+
if obj is default:
|
| 23 |
+
return default
|
| 24 |
+
return obj
|
| 25 |
+
|
| 26 |
+
def parse_response_for_fake_stream(response_data: Dict[str, Any]) -> tuple:
|
| 27 |
+
"""从完整响应中提取内容和推理内容(用于假流式)
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
response_data: Gemini API 响应数据
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
(content, reasoning_content, finish_reason, images): 内容、推理内容、结束原因和图片数据的元组
|
| 34 |
+
"""
|
| 35 |
+
import json
|
| 36 |
+
|
| 37 |
+
# 处理GeminiCLI的response包装格式
|
| 38 |
+
if "response" in response_data and "candidates" not in response_data:
|
| 39 |
+
log.debug(f"[FAKE_STREAM] Unwrapping response field")
|
| 40 |
+
response_data = response_data["response"]
|
| 41 |
+
|
| 42 |
+
candidates = response_data.get("candidates", [])
|
| 43 |
+
log.debug(f"[FAKE_STREAM] Found {len(candidates)} candidates")
|
| 44 |
+
if not candidates:
|
| 45 |
+
return "", "", "STOP", []
|
| 46 |
+
|
| 47 |
+
candidate = candidates[0]
|
| 48 |
+
finish_reason = candidate.get("finishReason", "STOP")
|
| 49 |
+
parts = safe_get_nested(candidate, "content", "parts", default=[])
|
| 50 |
+
log.debug(f"[FAKE_STREAM] Extracted {len(parts)} parts: {json.dumps(parts, ensure_ascii=False)}")
|
| 51 |
+
content, reasoning_content, images = extract_content_and_reasoning(parts)
|
| 52 |
+
log.debug(f"[FAKE_STREAM] Content length: {len(content)}, Reasoning length: {len(reasoning_content)}, Images count: {len(images)}")
|
| 53 |
+
|
| 54 |
+
return content, reasoning_content, finish_reason, images
|
| 55 |
+
|
| 56 |
+
def extract_fake_stream_content(response: Any) -> Tuple[str, str, Dict[str, int]]:
|
| 57 |
+
"""
|
| 58 |
+
从 Gemini 非流式响应中提取内容,用于假流式处理
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
response: Gemini API 响应对象
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
(content, reasoning_content, usage) 元组
|
| 65 |
+
"""
|
| 66 |
+
from src.converter.utils import extract_content_and_reasoning
|
| 67 |
+
|
| 68 |
+
# 解析响应体
|
| 69 |
+
if hasattr(response, "body"):
|
| 70 |
+
body_str = (
|
| 71 |
+
response.body.decode()
|
| 72 |
+
if isinstance(response.body, bytes)
|
| 73 |
+
else str(response.body)
|
| 74 |
+
)
|
| 75 |
+
elif hasattr(response, "content"):
|
| 76 |
+
body_str = (
|
| 77 |
+
response.content.decode()
|
| 78 |
+
if isinstance(response.content, bytes)
|
| 79 |
+
else str(response.content)
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
body_str = str(response)
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
response_data = json.loads(body_str)
|
| 86 |
+
|
| 87 |
+
# GeminiCLI 返回的格式是 {"response": {...}, "traceId": "..."}
|
| 88 |
+
# 需要先提取 response 字段
|
| 89 |
+
if "response" in response_data:
|
| 90 |
+
gemini_response = response_data["response"]
|
| 91 |
+
else:
|
| 92 |
+
gemini_response = response_data
|
| 93 |
+
|
| 94 |
+
# 从Gemini响应中提取内容,使用思维链分离逻辑
|
| 95 |
+
content = ""
|
| 96 |
+
reasoning_content = ""
|
| 97 |
+
images = []
|
| 98 |
+
if "candidates" in gemini_response and gemini_response["candidates"]:
|
| 99 |
+
# Gemini格式响应 - 使用思维链分离
|
| 100 |
+
candidate = gemini_response["candidates"][0]
|
| 101 |
+
if "content" in candidate and "parts" in candidate["content"]:
|
| 102 |
+
parts = candidate["content"]["parts"]
|
| 103 |
+
content, reasoning_content, images = extract_content_and_reasoning(parts)
|
| 104 |
+
elif "choices" in gemini_response and gemini_response["choices"]:
|
| 105 |
+
# OpenAI格式响应
|
| 106 |
+
content = gemini_response["choices"][0].get("message", {}).get("content", "")
|
| 107 |
+
|
| 108 |
+
# 如果没有正常内容但有思维内容,给出警告
|
| 109 |
+
if not content and reasoning_content:
|
| 110 |
+
log.warning("Fake stream response contains only thinking content")
|
| 111 |
+
content = "[模型正在思考中,请稍后再试或重新提问]"
|
| 112 |
+
|
| 113 |
+
# 如果完全没有内容,提供默认回复
|
| 114 |
+
if not content:
|
| 115 |
+
log.warning(f"No content found in response: {gemini_response}")
|
| 116 |
+
content = "[响应为空,请重新尝试]"
|
| 117 |
+
|
| 118 |
+
# 转换usageMetadata为OpenAI格式
|
| 119 |
+
usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
|
| 120 |
+
|
| 121 |
+
return content, reasoning_content, usage
|
| 122 |
+
|
| 123 |
+
except json.JSONDecodeError:
|
| 124 |
+
# 如果不是JSON,直接返回原始文本
|
| 125 |
+
return body_str, "", None
|
| 126 |
+
|
| 127 |
+
def _build_candidate(parts: List[Dict[str, Any]], finish_reason: str = "STOP") -> Dict[str, Any]:
|
| 128 |
+
"""构建标准候选响应结构
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
parts: parts 列表
|
| 132 |
+
finish_reason: 结束原因
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
候选响应字典
|
| 136 |
+
"""
|
| 137 |
+
return {
|
| 138 |
+
"candidates": [{
|
| 139 |
+
"content": {"parts": parts, "role": "model"},
|
| 140 |
+
"finishReason": finish_reason,
|
| 141 |
+
"index": 0,
|
| 142 |
+
}]
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
def create_openai_heartbeat_chunk() -> Dict[str, Any]:
|
| 146 |
+
"""
|
| 147 |
+
创建 OpenAI 格式的心跳块(用于假流式)
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
心跳响应块字典
|
| 151 |
+
"""
|
| 152 |
+
return {
|
| 153 |
+
"choices": [
|
| 154 |
+
{
|
| 155 |
+
"index": 0,
|
| 156 |
+
"delta": {"role": "assistant", "content": ""},
|
| 157 |
+
"finish_reason": None,
|
| 158 |
+
}
|
| 159 |
+
]
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
def build_gemini_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
|
| 163 |
+
"""构建假流式响应的数据块
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
content: 主要内容
|
| 167 |
+
reasoning_content: 推理内容
|
| 168 |
+
finish_reason: 结束原因
|
| 169 |
+
images: 图片数据列表(可选)
|
| 170 |
+
chunk_size: 每个chunk的字符数(默认50)
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
响应数据块列表
|
| 174 |
+
"""
|
| 175 |
+
if images is None:
|
| 176 |
+
images = []
|
| 177 |
+
|
| 178 |
+
log.debug(f"[build_gemini_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
|
| 179 |
+
chunks = []
|
| 180 |
+
|
| 181 |
+
# 如果没有正常内容但有思维内容,提供默认回复
|
| 182 |
+
if not content:
|
| 183 |
+
default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
|
| 184 |
+
return [_build_candidate([{"text": default_text}], finish_reason)]
|
| 185 |
+
|
| 186 |
+
# 分块发送主要内容
|
| 187 |
+
first_chunk = True
|
| 188 |
+
for i in range(0, len(content), chunk_size):
|
| 189 |
+
chunk_text = content[i:i + chunk_size]
|
| 190 |
+
is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content
|
| 191 |
+
chunk_finish_reason = finish_reason if is_last_chunk else None
|
| 192 |
+
|
| 193 |
+
# 如果是第一个chunk且有图片,将图片包含在parts中
|
| 194 |
+
parts = []
|
| 195 |
+
if first_chunk and images:
|
| 196 |
+
# 在Gemini格式中,需要将image_url格式转换为inlineData格式
|
| 197 |
+
for img in images:
|
| 198 |
+
if img.get("type") == "image_url":
|
| 199 |
+
url = img.get("image_url", {}).get("url", "")
|
| 200 |
+
# 解析 data URL: data:{mime_type};base64,{data}
|
| 201 |
+
if url.startswith("data:"):
|
| 202 |
+
parts_of_url = url.split(";base64,")
|
| 203 |
+
if len(parts_of_url) == 2:
|
| 204 |
+
mime_type = parts_of_url[0].replace("data:", "")
|
| 205 |
+
base64_data = parts_of_url[1]
|
| 206 |
+
parts.append({
|
| 207 |
+
"inlineData": {
|
| 208 |
+
"mimeType": mime_type,
|
| 209 |
+
"data": base64_data
|
| 210 |
+
}
|
| 211 |
+
})
|
| 212 |
+
first_chunk = False
|
| 213 |
+
|
| 214 |
+
parts.append({"text": chunk_text})
|
| 215 |
+
chunk_data = _build_candidate(parts, chunk_finish_reason)
|
| 216 |
+
log.debug(f"[build_gemini_fake_stream_chunks] Generated chunk: {chunk_data}")
|
| 217 |
+
chunks.append(chunk_data)
|
| 218 |
+
|
| 219 |
+
# 如果有推理内容,分块发送
|
| 220 |
+
if reasoning_content:
|
| 221 |
+
for i in range(0, len(reasoning_content), chunk_size):
|
| 222 |
+
chunk_text = reasoning_content[i:i + chunk_size]
|
| 223 |
+
is_last_chunk = i + chunk_size >= len(reasoning_content)
|
| 224 |
+
chunk_finish_reason = finish_reason if is_last_chunk else None
|
| 225 |
+
chunks.append(_build_candidate([{"text": chunk_text, "thought": True}], chunk_finish_reason))
|
| 226 |
+
|
| 227 |
+
log.debug(f"[build_gemini_fake_stream_chunks] Total chunks generated: {len(chunks)}")
|
| 228 |
+
return chunks
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def create_gemini_heartbeat_chunk() -> Dict[str, Any]:
|
| 232 |
+
"""创建 Gemini 格式的心跳数据块
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
心跳数据块
|
| 236 |
+
"""
|
| 237 |
+
chunk = _build_candidate([{"text": ""}])
|
| 238 |
+
chunk["candidates"][0]["finishReason"] = None
|
| 239 |
+
return chunk
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def build_openai_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
|
| 243 |
+
"""构建 OpenAI 格式的假流式响应数据块
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
content: 主要内容
|
| 247 |
+
reasoning_content: 推理内容
|
| 248 |
+
finish_reason: 结束原因(如 "STOP", "MAX_TOKENS")
|
| 249 |
+
model: 模型名称
|
| 250 |
+
images: 图片数据列表(可选)
|
| 251 |
+
chunk_size: 每个chunk的字符数(默认50)
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
OpenAI 格式的响应数据块列表
|
| 255 |
+
"""
|
| 256 |
+
import time
|
| 257 |
+
import uuid
|
| 258 |
+
|
| 259 |
+
if images is None:
|
| 260 |
+
images = []
|
| 261 |
+
|
| 262 |
+
log.debug(f"[build_openai_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
|
| 263 |
+
chunks = []
|
| 264 |
+
response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
| 265 |
+
created = int(time.time())
|
| 266 |
+
|
| 267 |
+
# 映射 Gemini finish_reason 到 OpenAI 格式
|
| 268 |
+
openai_finish_reason = None
|
| 269 |
+
if finish_reason == "STOP":
|
| 270 |
+
openai_finish_reason = "stop"
|
| 271 |
+
elif finish_reason == "MAX_TOKENS":
|
| 272 |
+
openai_finish_reason = "length"
|
| 273 |
+
elif finish_reason in ["SAFETY", "RECITATION"]:
|
| 274 |
+
openai_finish_reason = "content_filter"
|
| 275 |
+
|
| 276 |
+
# 如果没有正常内容但有思维内容,提供默认回复
|
| 277 |
+
if not content:
|
| 278 |
+
default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
|
| 279 |
+
return [{
|
| 280 |
+
"id": response_id,
|
| 281 |
+
"object": "chat.completion.chunk",
|
| 282 |
+
"created": created,
|
| 283 |
+
"model": model,
|
| 284 |
+
"choices": [{
|
| 285 |
+
"index": 0,
|
| 286 |
+
"delta": {"content": default_text},
|
| 287 |
+
"finish_reason": openai_finish_reason,
|
| 288 |
+
}]
|
| 289 |
+
}]
|
| 290 |
+
|
| 291 |
+
# 分块发送主要内容
|
| 292 |
+
first_chunk = True
|
| 293 |
+
for i in range(0, len(content), chunk_size):
|
| 294 |
+
chunk_text = content[i:i + chunk_size]
|
| 295 |
+
is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content
|
| 296 |
+
chunk_finish = openai_finish_reason if is_last_chunk else None
|
| 297 |
+
|
| 298 |
+
delta_content = {}
|
| 299 |
+
|
| 300 |
+
# 如果是第一个chunk且有图片,构建包含图片的content数组
|
| 301 |
+
if first_chunk and images:
|
| 302 |
+
delta_content["content"] = images + [{"type": "text", "text": chunk_text}]
|
| 303 |
+
first_chunk = False
|
| 304 |
+
else:
|
| 305 |
+
delta_content["content"] = chunk_text
|
| 306 |
+
|
| 307 |
+
chunk_data = {
|
| 308 |
+
"id": response_id,
|
| 309 |
+
"object": "chat.completion.chunk",
|
| 310 |
+
"created": created,
|
| 311 |
+
"model": model,
|
| 312 |
+
"choices": [{
|
| 313 |
+
"index": 0,
|
| 314 |
+
"delta": delta_content,
|
| 315 |
+
"finish_reason": chunk_finish,
|
| 316 |
+
}]
|
| 317 |
+
}
|
| 318 |
+
log.debug(f"[build_openai_fake_stream_chunks] Generated chunk: {chunk_data}")
|
| 319 |
+
chunks.append(chunk_data)
|
| 320 |
+
|
| 321 |
+
# 如果有推理内容,分块发送(使用 reasoning_content 字段)
|
| 322 |
+
if reasoning_content:
|
| 323 |
+
for i in range(0, len(reasoning_content), chunk_size):
|
| 324 |
+
chunk_text = reasoning_content[i:i + chunk_size]
|
| 325 |
+
is_last_chunk = i + chunk_size >= len(reasoning_content)
|
| 326 |
+
chunk_finish = openai_finish_reason if is_last_chunk else None
|
| 327 |
+
|
| 328 |
+
chunks.append({
|
| 329 |
+
"id": response_id,
|
| 330 |
+
"object": "chat.completion.chunk",
|
| 331 |
+
"created": created,
|
| 332 |
+
"model": model,
|
| 333 |
+
"choices": [{
|
| 334 |
+
"index": 0,
|
| 335 |
+
"delta": {"reasoning_content": chunk_text},
|
| 336 |
+
"finish_reason": chunk_finish,
|
| 337 |
+
}]
|
| 338 |
+
})
|
| 339 |
+
|
| 340 |
+
log.debug(f"[build_openai_fake_stream_chunks] Total chunks generated: {len(chunks)}")
|
| 341 |
+
return chunks
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def create_anthropic_heartbeat_chunk() -> Dict[str, Any]:
|
| 345 |
+
"""
|
| 346 |
+
创建 Anthropic 格式的心跳块(用于假流式)
|
| 347 |
+
|
| 348 |
+
Returns:
|
| 349 |
+
心跳响应块字典
|
| 350 |
+
"""
|
| 351 |
+
return {
|
| 352 |
+
"type": "ping"
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def build_anthropic_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
|
| 357 |
+
"""构建 Anthropic 格式的假流式响应数据块
|
| 358 |
+
|
| 359 |
+
Args:
|
| 360 |
+
content: 主要内容
|
| 361 |
+
reasoning_content: 推理内容(thinking content)
|
| 362 |
+
finish_reason: 结束原因(如 "STOP", "MAX_TOKENS")
|
| 363 |
+
model: 模型名称
|
| 364 |
+
images: 图片数据列表(可选)
|
| 365 |
+
chunk_size: 每个chunk的字符数(默认50)
|
| 366 |
+
|
| 367 |
+
Returns:
|
| 368 |
+
Anthropic SSE 格式的响应数据块列表
|
| 369 |
+
"""
|
| 370 |
+
import uuid
|
| 371 |
+
|
| 372 |
+
if images is None:
|
| 373 |
+
images = []
|
| 374 |
+
|
| 375 |
+
log.debug(f"[build_anthropic_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
|
| 376 |
+
chunks = []
|
| 377 |
+
message_id = f"msg_{uuid.uuid4().hex}"
|
| 378 |
+
|
| 379 |
+
# 映射 Gemini finish_reason 到 Anthropic 格式
|
| 380 |
+
anthropic_stop_reason = "end_turn"
|
| 381 |
+
if finish_reason == "MAX_TOKENS":
|
| 382 |
+
anthropic_stop_reason = "max_tokens"
|
| 383 |
+
elif finish_reason in ["SAFETY", "RECITATION"]:
|
| 384 |
+
anthropic_stop_reason = "end_turn"
|
| 385 |
+
|
| 386 |
+
# 1. 发送 message_start 事件
|
| 387 |
+
chunks.append({
|
| 388 |
+
"type": "message_start",
|
| 389 |
+
"message": {
|
| 390 |
+
"id": message_id,
|
| 391 |
+
"type": "message",
|
| 392 |
+
"role": "assistant",
|
| 393 |
+
"model": model,
|
| 394 |
+
"content": [],
|
| 395 |
+
"stop_reason": None,
|
| 396 |
+
"stop_sequence": None,
|
| 397 |
+
"usage": {"input_tokens": 0, "output_tokens": 0}
|
| 398 |
+
}
|
| 399 |
+
})
|
| 400 |
+
|
| 401 |
+
# 如果没有正常内容但有思维内容,提供默认回复
|
| 402 |
+
if not content:
|
| 403 |
+
default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
|
| 404 |
+
|
| 405 |
+
# content_block_start
|
| 406 |
+
chunks.append({
|
| 407 |
+
"type": "content_block_start",
|
| 408 |
+
"index": 0,
|
| 409 |
+
"content_block": {"type": "text", "text": ""}
|
| 410 |
+
})
|
| 411 |
+
|
| 412 |
+
# content_block_delta
|
| 413 |
+
chunks.append({
|
| 414 |
+
"type": "content_block_delta",
|
| 415 |
+
"index": 0,
|
| 416 |
+
"delta": {"type": "text_delta", "text": default_text}
|
| 417 |
+
})
|
| 418 |
+
|
| 419 |
+
# content_block_stop
|
| 420 |
+
chunks.append({
|
| 421 |
+
"type": "content_block_stop",
|
| 422 |
+
"index": 0
|
| 423 |
+
})
|
| 424 |
+
|
| 425 |
+
# message_delta
|
| 426 |
+
chunks.append({
|
| 427 |
+
"type": "message_delta",
|
| 428 |
+
"delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None},
|
| 429 |
+
"usage": {"output_tokens": 0}
|
| 430 |
+
})
|
| 431 |
+
|
| 432 |
+
# message_stop
|
| 433 |
+
chunks.append({
|
| 434 |
+
"type": "message_stop"
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
return chunks
|
| 438 |
+
|
| 439 |
+
block_index = 0
|
| 440 |
+
|
| 441 |
+
# 2. 如果有推理内容,先发送 thinking 块
|
| 442 |
+
if reasoning_content:
|
| 443 |
+
# thinking content_block_start
|
| 444 |
+
chunks.append({
|
| 445 |
+
"type": "content_block_start",
|
| 446 |
+
"index": block_index,
|
| 447 |
+
"content_block": {"type": "thinking", "thinking": ""}
|
| 448 |
+
})
|
| 449 |
+
|
| 450 |
+
# 分块发送推理内容
|
| 451 |
+
for i in range(0, len(reasoning_content), chunk_size):
|
| 452 |
+
chunk_text = reasoning_content[i:i + chunk_size]
|
| 453 |
+
chunks.append({
|
| 454 |
+
"type": "content_block_delta",
|
| 455 |
+
"index": block_index,
|
| 456 |
+
"delta": {"type": "thinking_delta", "thinking": chunk_text}
|
| 457 |
+
})
|
| 458 |
+
|
| 459 |
+
# thinking content_block_stop
|
| 460 |
+
chunks.append({
|
| 461 |
+
"type": "content_block_stop",
|
| 462 |
+
"index": block_index
|
| 463 |
+
})
|
| 464 |
+
|
| 465 |
+
block_index += 1
|
| 466 |
+
|
| 467 |
+
# 3. 如果有图片,发送图片块
|
| 468 |
+
if images:
|
| 469 |
+
for img in images:
|
| 470 |
+
if img.get("type") == "image_url":
|
| 471 |
+
url = img.get("image_url", {}).get("url", "")
|
| 472 |
+
# 解析 data URL: data:{mime_type};base64,{data}
|
| 473 |
+
if url.startswith("data:"):
|
| 474 |
+
parts_of_url = url.split(";base64,")
|
| 475 |
+
if len(parts_of_url) == 2:
|
| 476 |
+
mime_type = parts_of_url[0].replace("data:", "")
|
| 477 |
+
base64_data = parts_of_url[1]
|
| 478 |
+
|
| 479 |
+
# image content_block_start
|
| 480 |
+
chunks.append({
|
| 481 |
+
"type": "content_block_start",
|
| 482 |
+
"index": block_index,
|
| 483 |
+
"content_block": {
|
| 484 |
+
"type": "image",
|
| 485 |
+
"source": {
|
| 486 |
+
"type": "base64",
|
| 487 |
+
"media_type": mime_type,
|
| 488 |
+
"data": base64_data
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
})
|
| 492 |
+
|
| 493 |
+
# image content_block_stop
|
| 494 |
+
chunks.append({
|
| 495 |
+
"type": "content_block_stop",
|
| 496 |
+
"index": block_index
|
| 497 |
+
})
|
| 498 |
+
|
| 499 |
+
block_index += 1
|
| 500 |
+
|
| 501 |
+
# 4. 发送主要内容(text 块)
|
| 502 |
+
# text content_block_start
|
| 503 |
+
chunks.append({
|
| 504 |
+
"type": "content_block_start",
|
| 505 |
+
"index": block_index,
|
| 506 |
+
"content_block": {"type": "text", "text": ""}
|
| 507 |
+
})
|
| 508 |
+
|
| 509 |
+
# 分块发送主要内容
|
| 510 |
+
for i in range(0, len(content), chunk_size):
|
| 511 |
+
chunk_text = content[i:i + chunk_size]
|
| 512 |
+
chunks.append({
|
| 513 |
+
"type": "content_block_delta",
|
| 514 |
+
"index": block_index,
|
| 515 |
+
"delta": {"type": "text_delta", "text": chunk_text}
|
| 516 |
+
})
|
| 517 |
+
|
| 518 |
+
# text content_block_stop
|
| 519 |
+
chunks.append({
|
| 520 |
+
"type": "content_block_stop",
|
| 521 |
+
"index": block_index
|
| 522 |
+
})
|
| 523 |
+
|
| 524 |
+
# 5. 发送 message_delta
|
| 525 |
+
chunks.append({
|
| 526 |
+
"type": "message_delta",
|
| 527 |
+
"delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None},
|
| 528 |
+
"usage": {"output_tokens": len(content) + len(reasoning_content)}
|
| 529 |
+
})
|
| 530 |
+
|
| 531 |
+
# 6. 发送 message_stop
|
| 532 |
+
chunks.append({
|
| 533 |
+
"type": "message_stop"
|
| 534 |
+
})
|
| 535 |
+
|
| 536 |
+
log.debug(f"[build_anthropic_fake_stream_chunks] Total chunks generated: {len(chunks)}")
|
| 537 |
+
return chunks
|
src/converter/gemini_fix.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini Format Utilities - 统一的 Gemini 格式处理和转换工具
|
| 3 |
+
提供对 Gemini API 请求体和响应的标准化处理
|
| 4 |
+
────────────────────────────────────────────────────────────────
|
| 5 |
+
"""
|
| 6 |
+
from math import e
|
| 7 |
+
from typing import Any, Dict, Optional
|
| 8 |
+
|
| 9 |
+
from log import log
|
| 10 |
+
|
| 11 |
+
# ==================== Gemini API 配置 ====================
|
| 12 |
+
|
| 13 |
+
# ====================== Model Configuration ======================
|
| 14 |
+
|
| 15 |
+
# Default Safety Settings for Google API
|
| 16 |
+
DEFAULT_SAFETY_SETTINGS = [
|
| 17 |
+
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
| 18 |
+
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
| 19 |
+
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
| 20 |
+
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
| 21 |
+
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
|
| 22 |
+
{"category": "HARM_CATEGORY_IMAGE_HATE", "threshold": "BLOCK_NONE"},
|
| 23 |
+
{"category": "HARM_CATEGORY_IMAGE_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
| 24 |
+
{"category": "HARM_CATEGORY_IMAGE_HARASSMENT", "threshold": "BLOCK_NONE"},
|
| 25 |
+
{"category": "HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
| 26 |
+
{"category": "HARM_CATEGORY_JAILBREAK", "threshold": "BLOCK_NONE"},
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
LITE_SAFETY_SETTINGS = [
|
| 30 |
+
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
| 31 |
+
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
| 32 |
+
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
| 33 |
+
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
| 34 |
+
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
def prepare_image_generation_request(
|
| 38 |
+
request_body: Dict[str, Any],
|
| 39 |
+
model: str
|
| 40 |
+
) -> Dict[str, Any]:
|
| 41 |
+
"""
|
| 42 |
+
图像生成模型请求体后处理
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
request_body: 原始请求体
|
| 46 |
+
model: 模型名称
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
处理后的请求体
|
| 50 |
+
"""
|
| 51 |
+
request_body = request_body.copy()
|
| 52 |
+
model_lower = model.lower()
|
| 53 |
+
|
| 54 |
+
# 解析分辨率
|
| 55 |
+
image_size = "4K" if "-4k" in model_lower else "2K" if "-2k" in model_lower else None
|
| 56 |
+
|
| 57 |
+
# 解析比例
|
| 58 |
+
aspect_ratio = None
|
| 59 |
+
for suffix, ratio in [
|
| 60 |
+
("-21x9", "21:9"), ("-16x9", "16:9"), ("-9x16", "9:16"),
|
| 61 |
+
("-4x3", "4:3"), ("-3x4", "3:4"), ("-1x1", "1:1")
|
| 62 |
+
]:
|
| 63 |
+
if suffix in model_lower:
|
| 64 |
+
aspect_ratio = ratio
|
| 65 |
+
break
|
| 66 |
+
|
| 67 |
+
# 构建 imageConfig
|
| 68 |
+
image_config = {}
|
| 69 |
+
if aspect_ratio:
|
| 70 |
+
image_config["aspectRatio"] = aspect_ratio
|
| 71 |
+
if image_size:
|
| 72 |
+
image_config["imageSize"] = image_size
|
| 73 |
+
|
| 74 |
+
request_body["model"] = "gemini-3.1-flash-image" # 统一使用基础模型名
|
| 75 |
+
request_body["generationConfig"] = {
|
| 76 |
+
"candidateCount": 1,
|
| 77 |
+
"imageConfig": image_config
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# 移除不需要的字段
|
| 81 |
+
for key in ("systemInstruction", "tools", "toolConfig"):
|
| 82 |
+
request_body.pop(key, None)
|
| 83 |
+
|
| 84 |
+
return request_body
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ==================== 模型特性辅助函数 ====================
|
| 88 |
+
|
| 89 |
+
def get_base_model_name(model_name: str) -> str:
|
| 90 |
+
"""移除模型名称中的后缀,返回基础模型名"""
|
| 91 |
+
# 按照从长到短的顺序排列,避免短后缀先于长后缀被匹配
|
| 92 |
+
suffixes = [
|
| 93 |
+
"-maxthinking", "-nothinking", # 兼容旧模式
|
| 94 |
+
"-minimal", "-medium", "-search", "-think", # 中等长度后缀
|
| 95 |
+
"-high", "-max", "-low" # 短后缀
|
| 96 |
+
]
|
| 97 |
+
result = model_name
|
| 98 |
+
changed = True
|
| 99 |
+
# 持续循环直到没有任何后缀可以移除
|
| 100 |
+
while changed:
|
| 101 |
+
changed = False
|
| 102 |
+
for suffix in suffixes:
|
| 103 |
+
if result.endswith(suffix):
|
| 104 |
+
result = result[:-len(suffix)]
|
| 105 |
+
changed = True
|
| 106 |
+
# 不使用 break,继续检查是否还有其他后缀
|
| 107 |
+
return result
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def get_thinking_settings(model_name: str) -> tuple[Optional[int], Optional[str]]:
|
| 111 |
+
"""
|
| 112 |
+
根据模型名称获取思考配置
|
| 113 |
+
|
| 114 |
+
支持两种模式:
|
| 115 |
+
1. CLI 模式思考预算 (Gemini 2.5 系列): -max, -high, -medium, -low, -minimal
|
| 116 |
+
2. CLI 模式思考等级 (Gemini 3 Preview 系列): -high, -medium, -low, -minimal (仅 3-flash)
|
| 117 |
+
3. 兼容旧模式: -maxthinking, -nothinking (不返回给用户)
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
(thinking_budget, thinking_level): 思考预算和思考等级
|
| 121 |
+
"""
|
| 122 |
+
base_model = get_base_model_name(model_name)
|
| 123 |
+
|
| 124 |
+
# ========== 兼容旧模式 (不返回给用户) ==========
|
| 125 |
+
if "-nothinking" in model_name:
|
| 126 |
+
# nothinking 模式: 限制思考
|
| 127 |
+
if "flash" in base_model:
|
| 128 |
+
return 0, None
|
| 129 |
+
return 128, None
|
| 130 |
+
elif "-maxthinking" in model_name:
|
| 131 |
+
# maxthinking 模式: 最大思考预算
|
| 132 |
+
budget = 24576 if "flash" in base_model else 32768
|
| 133 |
+
if "gemini-3" in base_model:
|
| 134 |
+
# Gemini 3 系列不支持 thinkingBudget,返回 high 等级
|
| 135 |
+
return None, "high"
|
| 136 |
+
else:
|
| 137 |
+
return budget, None
|
| 138 |
+
|
| 139 |
+
# ========== 新 CLI 模式: 基于思考预算/等级 ==========
|
| 140 |
+
|
| 141 |
+
# Gemini 3 Preview 系列: 使用 thinkingLevel
|
| 142 |
+
if "gemini-3" in base_model:
|
| 143 |
+
if "-high" in model_name:
|
| 144 |
+
return None, "high"
|
| 145 |
+
elif "-medium" in model_name:
|
| 146 |
+
# 仅 3-flash-preview 支持 medium
|
| 147 |
+
if "flash" in base_model:
|
| 148 |
+
return None, "medium"
|
| 149 |
+
# pro 系列不支持 medium,返回 Default
|
| 150 |
+
return None, None
|
| 151 |
+
elif "-low" in model_name:
|
| 152 |
+
return None, "low"
|
| 153 |
+
elif "-minimal" in model_name:
|
| 154 |
+
return None, None
|
| 155 |
+
else:
|
| 156 |
+
# Default: 不设置 thinking 配置
|
| 157 |
+
return None, None
|
| 158 |
+
|
| 159 |
+
# Gemini 2.5 系列: 使用 thinkingBudget
|
| 160 |
+
elif "gemini-2.5" in base_model:
|
| 161 |
+
if "-max" in model_name:
|
| 162 |
+
# 2.5-flash-max: 24576, 2.5-pro-max: 32768
|
| 163 |
+
budget = 24576 if "flash" in base_model else 32768
|
| 164 |
+
return budget, None
|
| 165 |
+
elif "-high" in model_name:
|
| 166 |
+
# 2.5-flash-high: 16000, 2.5-pro-high: 16000
|
| 167 |
+
return 16000, None
|
| 168 |
+
elif "-medium" in model_name:
|
| 169 |
+
# 2.5-flash-medium: 8192, 2.5-pro-medium: 8192
|
| 170 |
+
return 8192, None
|
| 171 |
+
elif "-low" in model_name:
|
| 172 |
+
# 2.5-flash-low: 1024, 2.5-pro-low: 1024
|
| 173 |
+
return 1024, None
|
| 174 |
+
elif "-minimal" in model_name:
|
| 175 |
+
# 2.5-flash-minimal: 0, 2.5-pro-minimal: 128
|
| 176 |
+
budget = 0 if "flash" in base_model else 128
|
| 177 |
+
return budget, None
|
| 178 |
+
else:
|
| 179 |
+
# Default: 不设置 thinking budget
|
| 180 |
+
return None, None
|
| 181 |
+
|
| 182 |
+
# 其他模型: 不设置 thinking 配置
|
| 183 |
+
return None, None
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def is_search_model(model_name: str) -> bool:
|
| 187 |
+
"""检查是否为搜索模型"""
|
| 188 |
+
return "-search" in model_name
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ==================== 统一的 Gemini 请求后处理 ====================
|
| 192 |
+
|
| 193 |
+
def is_thinking_model(model_name: str) -> bool:
|
| 194 |
+
"""检查是否为思考模型 (包含 -thinking 或 pro)"""
|
| 195 |
+
return "think" in model_name or "pro" in model_name.lower()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
async def normalize_gemini_request(
|
| 199 |
+
request: Dict[str, Any],
|
| 200 |
+
mode: str = "geminicli"
|
| 201 |
+
) -> Dict[str, Any]:
|
| 202 |
+
"""
|
| 203 |
+
规范化 Gemini 请求
|
| 204 |
+
|
| 205 |
+
处理逻辑:
|
| 206 |
+
1. 模型特性处理 (thinking config, search tools)
|
| 207 |
+
3. 参数范围限制 (maxOutputTokens, topK)
|
| 208 |
+
4. 工具清理
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
request: 原始请求字典
|
| 212 |
+
mode: 模式 ("geminicli" 或 "antigravity")
|
| 213 |
+
|
| 214 |
+
Returns:
|
| 215 |
+
规范化后的请求
|
| 216 |
+
"""
|
| 217 |
+
# 导入配置函数
|
| 218 |
+
from config import get_return_thoughts_to_frontend
|
| 219 |
+
|
| 220 |
+
result = request.copy()
|
| 221 |
+
model = result.get("model", "")
|
| 222 |
+
generation_config = (result.get("generationConfig") or {}).copy() # 创建副本避免修改原对象
|
| 223 |
+
tools = result.get("tools")
|
| 224 |
+
system_instruction = result.get("systemInstruction") or result.get("system_instructions")
|
| 225 |
+
|
| 226 |
+
# 记录原始请求
|
| 227 |
+
log.debug(f"[GEMINI_FIX] 原始请求 - 模型: {model}, mode: {mode}, generationConfig: {generation_config}")
|
| 228 |
+
|
| 229 |
+
# 获取配置值
|
| 230 |
+
return_thoughts = await get_return_thoughts_to_frontend()
|
| 231 |
+
|
| 232 |
+
# ========== 模式特定处理 ==========
|
| 233 |
+
if mode == "geminicli":
|
| 234 |
+
# 1. 思考设置
|
| 235 |
+
# 优先使用 get_thinking_settings 获取的思考预算和等级
|
| 236 |
+
thinking_budget, thinking_level = get_thinking_settings(model)
|
| 237 |
+
|
| 238 |
+
# 其次使用传入的思考预算(如果未从模型名称获取)
|
| 239 |
+
if thinking_budget is None and thinking_level is None:
|
| 240 |
+
thinking_budget = generation_config.get("thinkingConfig", {}).get("thinkingBudget")
|
| 241 |
+
thinking_level = generation_config.get("thinkingConfig", {}).get("thinkingLevel")
|
| 242 |
+
|
| 243 |
+
# 假如 is_thinking_model 为真或者思考预算/等级不为空,设置 thinkingConfig
|
| 244 |
+
if is_thinking_model(model) or thinking_budget is not None or thinking_level is not None:
|
| 245 |
+
# 确保 thinkingConfig 存在
|
| 246 |
+
if "thinkingConfig" not in generation_config:
|
| 247 |
+
generation_config["thinkingConfig"] = {}
|
| 248 |
+
|
| 249 |
+
thinking_config = generation_config["thinkingConfig"]
|
| 250 |
+
|
| 251 |
+
# 设置思考预算或等级(互斥)
|
| 252 |
+
if thinking_budget is not None:
|
| 253 |
+
thinking_config["thinkingBudget"] = thinking_budget
|
| 254 |
+
thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突
|
| 255 |
+
elif thinking_level is not None:
|
| 256 |
+
thinking_config["thinkingLevel"] = thinking_level
|
| 257 |
+
thinking_config.pop("thinkingBudget", None) # 避免与 thinkingLevel 冲突
|
| 258 |
+
|
| 259 |
+
# includeThoughts 逻辑:
|
| 260 |
+
# 1. 如果是 pro 模型,为 return_thoughts
|
| 261 |
+
# 2. 如果不是 pro 模型,检查是否有思考预算或思考等级
|
| 262 |
+
base_model = get_base_model_name(model)
|
| 263 |
+
if "pro" in base_model:
|
| 264 |
+
include_thoughts = return_thoughts
|
| 265 |
+
elif "3-flash" in base_model:
|
| 266 |
+
if thinking_level is None:
|
| 267 |
+
include_thoughts = False
|
| 268 |
+
else:
|
| 269 |
+
include_thoughts = return_thoughts
|
| 270 |
+
else:
|
| 271 |
+
# 非 pro 模型: 有思考预算或等级才包含思考
|
| 272 |
+
# 注意: 思考预算为 0 时不包含思考
|
| 273 |
+
if thinking_budget is None or thinking_budget == 0:
|
| 274 |
+
include_thoughts = False
|
| 275 |
+
else:
|
| 276 |
+
include_thoughts = return_thoughts
|
| 277 |
+
|
| 278 |
+
thinking_config["includeThoughts"] = include_thoughts
|
| 279 |
+
|
| 280 |
+
# 2. 搜索模型添加 Google Search
|
| 281 |
+
if is_search_model(model):
|
| 282 |
+
result_tools = result.get("tools") or []
|
| 283 |
+
result["tools"] = result_tools
|
| 284 |
+
if not any(tool.get("googleSearch") for tool in result_tools if isinstance(tool, dict)):
|
| 285 |
+
result_tools.append({"googleSearch": {}})
|
| 286 |
+
|
| 287 |
+
# 3. 模型名称处理
|
| 288 |
+
result["model"] = get_base_model_name(model)
|
| 289 |
+
|
| 290 |
+
elif mode == "antigravity":
|
| 291 |
+
|
| 292 |
+
'''
|
| 293 |
+
# 1. 处理 system_instruction
|
| 294 |
+
custom_prompt = "Please ignore the following [ignore]You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**[/ignore]"
|
| 295 |
+
|
| 296 |
+
# 提取原有的 parts(如果存在)
|
| 297 |
+
existing_parts = []
|
| 298 |
+
if system_instruction:
|
| 299 |
+
if isinstance(system_instruction, dict):
|
| 300 |
+
existing_parts = system_instruction.get("parts", [])
|
| 301 |
+
|
| 302 |
+
# custom_prompt 始终放在第一位,原有内容整体后移
|
| 303 |
+
result["systemInstruction"] = {
|
| 304 |
+
"parts": [{"text": custom_prompt}] + existing_parts
|
| 305 |
+
}
|
| 306 |
+
'''
|
| 307 |
+
|
| 308 |
+
# 2. 判断图片模型
|
| 309 |
+
if "image" in model.lower():
|
| 310 |
+
# 调用图片生成专用处理函数
|
| 311 |
+
return prepare_image_generation_request(result, model)
|
| 312 |
+
else:
|
| 313 |
+
# 3. 思考模型处理
|
| 314 |
+
if is_thinking_model(model) or ("thinkingBudget" in generation_config.get("thinkingConfig", {}) and generation_config["thinkingConfig"]["thinkingBudget"] != 0):
|
| 315 |
+
# 直接设置 thinkingConfig
|
| 316 |
+
if "thinkingConfig" not in generation_config:
|
| 317 |
+
generation_config["thinkingConfig"] = {}
|
| 318 |
+
|
| 319 |
+
thinking_config = generation_config["thinkingConfig"]
|
| 320 |
+
# 优先使用传入的思考预算,否则使用默认值
|
| 321 |
+
if "thinkingBudget" not in thinking_config:
|
| 322 |
+
thinking_config["thinkingBudget"] = 1024
|
| 323 |
+
thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突
|
| 324 |
+
thinking_config["includeThoughts"] = return_thoughts
|
| 325 |
+
|
| 326 |
+
# 检查最后一个 assistant 消息是否以 thinking 块开始
|
| 327 |
+
contents = result.get("contents", [])
|
| 328 |
+
|
| 329 |
+
if "claude" in model.lower():
|
| 330 |
+
# 检测是否有工具调用(MCP场景)
|
| 331 |
+
has_tool_calls = any(
|
| 332 |
+
isinstance(content, dict) and
|
| 333 |
+
any(
|
| 334 |
+
isinstance(part, dict) and ("functionCall" in part or "function_call" in part)
|
| 335 |
+
for part in content.get("parts", [])
|
| 336 |
+
)
|
| 337 |
+
for content in contents
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
if has_tool_calls:
|
| 341 |
+
# MCP 场景:检测到工具调用,移除 thinkingConfig
|
| 342 |
+
log.warning(f"[ANTIGRAVITY] 检测到工具调用(MCP场景),移除 thinkingConfig 避免失效")
|
| 343 |
+
generation_config.pop("thinkingConfig", None)
|
| 344 |
+
else:
|
| 345 |
+
# 非 MCP 场景:填充思考块
|
| 346 |
+
# log.warning(f"[ANTIGRAVITY] 最后一个 assistant 消息不以 thinking 块开始,自动填充思考块")
|
| 347 |
+
|
| 348 |
+
# 找到最后一个 model 角色的 content
|
| 349 |
+
for i in range(len(contents) - 1, -1, -1):
|
| 350 |
+
content = contents[i]
|
| 351 |
+
if isinstance(content, dict) and content.get("role") == "model":
|
| 352 |
+
# 在 parts 开头插入思考块(使用官方跳过验证的虚拟签名)
|
| 353 |
+
parts = content.get("parts", [])
|
| 354 |
+
thinking_part = {
|
| 355 |
+
"text": "...",
|
| 356 |
+
# "thought": True, # 标记为思考块
|
| 357 |
+
"thoughtSignature": "skip_thought_signature_validator" # 官方文档推荐的虚拟签名
|
| 358 |
+
}
|
| 359 |
+
# 如果第一个 part 不是 thinking,则插入
|
| 360 |
+
if not parts or not (isinstance(parts[0], dict) and ("thought" in parts[0] or "thoughtSignature" in parts[0])):
|
| 361 |
+
content["parts"] = [thinking_part] + parts
|
| 362 |
+
log.debug(f"[ANTIGRAVITY] 已在最后一个 assistant 消息开头插入思考块(含跳过验证签名)")
|
| 363 |
+
break
|
| 364 |
+
|
| 365 |
+
# 移除 -thinking 后缀
|
| 366 |
+
model = model.replace("-thinking", "")
|
| 367 |
+
|
| 368 |
+
# 4. Claude 模型关键词映射
|
| 369 |
+
# 使用关键词匹配而不是精确匹配,更灵活地处理各种变体
|
| 370 |
+
original_model = model
|
| 371 |
+
if "opus" in model.lower():
|
| 372 |
+
model = "claude-opus-4-6-thinking"
|
| 373 |
+
elif "sonnet" in model.lower():
|
| 374 |
+
model = "claude-sonnet-4-6"
|
| 375 |
+
elif "haiku" in model.lower():
|
| 376 |
+
model = "gemini-2.5-flash"
|
| 377 |
+
elif "claude" in model.lower():
|
| 378 |
+
# Claude 模型兜底:如果包含 claude 但不是 opus/sonnet/haiku
|
| 379 |
+
model = "claude-sonnet-4-6"
|
| 380 |
+
|
| 381 |
+
result["model"] = model
|
| 382 |
+
if original_model != model:
|
| 383 |
+
log.debug(f"[ANTIGRAVITY] 映射模型: {original_model} -> {model}")
|
| 384 |
+
|
| 385 |
+
# 5. 模型特殊处理:循环移除末尾的 model 消息,保证以用户消息结尾
|
| 386 |
+
# 因为该模型不支持预填充
|
| 387 |
+
if "claude-opus-4-6-thinking" in model.lower() or "claude-sonnet-4-6" in model.lower():
|
| 388 |
+
contents = result.get("contents", [])
|
| 389 |
+
removed_count = 0
|
| 390 |
+
while contents and isinstance(contents[-1], dict) and contents[-1].get("role") == "model":
|
| 391 |
+
contents.pop()
|
| 392 |
+
removed_count += 1
|
| 393 |
+
if removed_count > 0:
|
| 394 |
+
log.warning(f"[ANTIGRAVITY] {model} 不支持预填充,移除了 {removed_count} 条末尾 model 消息")
|
| 395 |
+
result["contents"] = contents
|
| 396 |
+
|
| 397 |
+
# 6. 移除 antigravity 模式不支持的字段
|
| 398 |
+
generation_config.pop("presencePenalty", None)
|
| 399 |
+
generation_config.pop("frequencyPenalty", None)
|
| 400 |
+
generation_config.pop("stopSequences", None)
|
| 401 |
+
|
| 402 |
+
# ========== 公共处理 ==========
|
| 403 |
+
|
| 404 |
+
# 1. 安全设置覆盖
|
| 405 |
+
if "lite" in model.lower():
|
| 406 |
+
result["safetySettings"] = LITE_SAFETY_SETTINGS
|
| 407 |
+
else:
|
| 408 |
+
result["safetySettings"] = DEFAULT_SAFETY_SETTINGS
|
| 409 |
+
|
| 410 |
+
# 2. 参数范围限制
|
| 411 |
+
if generation_config:
|
| 412 |
+
# 强制设置 maxOutputTokens 为 64000
|
| 413 |
+
generation_config["maxOutputTokens"] = 64000
|
| 414 |
+
# 强制设置 topK 为 64
|
| 415 |
+
generation_config["topK"] = 64
|
| 416 |
+
|
| 417 |
+
if "contents" in result:
|
| 418 |
+
cleaned_contents = []
|
| 419 |
+
for content in result["contents"]:
|
| 420 |
+
if isinstance(content, dict) and "parts" in content:
|
| 421 |
+
# 过滤掉空的或无效的 parts
|
| 422 |
+
valid_parts = []
|
| 423 |
+
for part in content["parts"]:
|
| 424 |
+
if not isinstance(part, dict):
|
| 425 |
+
continue
|
| 426 |
+
|
| 427 |
+
# 检查 part 是否有有效的非空值
|
| 428 |
+
# 过滤掉空字典或所有值都为空的 part
|
| 429 |
+
has_valid_value = any(
|
| 430 |
+
value not in (None, "", {}, [])
|
| 431 |
+
for key, value in part.items()
|
| 432 |
+
if key != "thought" # thought 字段可以为空
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
if has_valid_value:
|
| 436 |
+
part = part.copy()
|
| 437 |
+
|
| 438 |
+
# 修复 text 字段:确保是字符串而不是列表
|
| 439 |
+
if "text" in part:
|
| 440 |
+
text_value = part["text"]
|
| 441 |
+
if isinstance(text_value, list):
|
| 442 |
+
# 如果是列表,合并为字符串
|
| 443 |
+
log.warning(f"[GEMINI_FIX] text 字段是列表,自动合并: {text_value}")
|
| 444 |
+
part["text"] = " ".join(str(t) for t in text_value if t)
|
| 445 |
+
elif isinstance(text_value, str):
|
| 446 |
+
# 清理尾随空格
|
| 447 |
+
part["text"] = text_value.rstrip()
|
| 448 |
+
else:
|
| 449 |
+
# 其他类型转为字符串
|
| 450 |
+
log.warning(f"[GEMINI_FIX] text 字段类型异常 ({type(text_value)}), 转为字符串: {text_value}")
|
| 451 |
+
part["text"] = str(text_value)
|
| 452 |
+
|
| 453 |
+
valid_parts.append(part)
|
| 454 |
+
else:
|
| 455 |
+
log.warning(f"[GEMINI_FIX] 移除空的或无效的 part: {part}")
|
| 456 |
+
|
| 457 |
+
# 只添加有有效 parts 的 content
|
| 458 |
+
if valid_parts:
|
| 459 |
+
cleaned_content = content.copy()
|
| 460 |
+
cleaned_content["parts"] = valid_parts
|
| 461 |
+
cleaned_contents.append(cleaned_content)
|
| 462 |
+
else:
|
| 463 |
+
log.warning(f"[GEMINI_FIX] 跳过没有有效 parts 的 content: {content.get('role')}")
|
| 464 |
+
else:
|
| 465 |
+
cleaned_contents.append(content)
|
| 466 |
+
|
| 467 |
+
result["contents"] = cleaned_contents
|
| 468 |
+
|
| 469 |
+
if generation_config:
|
| 470 |
+
result["generationConfig"] = generation_config
|
| 471 |
+
|
| 472 |
+
return result
|
src/converter/openai2gemini.py
ADDED
|
@@ -0,0 +1,1533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAI Transfer Module - Handles conversion between OpenAI and Gemini API formats
|
| 3 |
+
被openai-router调用,负责OpenAI格式与Gemini格式的双向转换
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 10 |
+
|
| 11 |
+
from pypinyin import Style, lazy_pinyin
|
| 12 |
+
|
| 13 |
+
from src.converter.thoughtSignature_fix import (
|
| 14 |
+
encode_tool_id_with_signature,
|
| 15 |
+
decode_tool_id_and_signature,
|
| 16 |
+
)
|
| 17 |
+
from src.converter.utils import merge_system_messages
|
| 18 |
+
|
| 19 |
+
from log import log
|
| 20 |
+
|
| 21 |
+
def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Dict[str, int]:
|
| 22 |
+
"""
|
| 23 |
+
将Gemini的usageMetadata转换为OpenAI格式的usage字段
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
usage_metadata: Gemini API的usageMetadata字段
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
OpenAI格式的usage字典,如果没有usage数据则返回None
|
| 30 |
+
"""
|
| 31 |
+
if not usage_metadata:
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
return {
|
| 35 |
+
"prompt_tokens": usage_metadata.get("promptTokenCount", 0),
|
| 36 |
+
"completion_tokens": usage_metadata.get("candidatesTokenCount", 0),
|
| 37 |
+
"total_tokens": usage_metadata.get("totalTokenCount", 0),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _build_message_with_reasoning(role: str, content: str, reasoning_content: str) -> dict:
|
| 42 |
+
"""构建包含可选推理内容的消息对象"""
|
| 43 |
+
message = {"role": role, "content": content}
|
| 44 |
+
|
| 45 |
+
# 如果有thinking tokens,添加reasoning_content
|
| 46 |
+
if reasoning_content:
|
| 47 |
+
message["reasoning_content"] = reasoning_content
|
| 48 |
+
|
| 49 |
+
return message
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _map_finish_reason(gemini_reason: str) -> str:
|
| 53 |
+
"""
|
| 54 |
+
将Gemini结束原因映射到OpenAI结束原因
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
gemini_reason: 来自Gemini API的结束原因
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
OpenAI兼容的结束原因
|
| 61 |
+
"""
|
| 62 |
+
if gemini_reason == "STOP":
|
| 63 |
+
return "stop"
|
| 64 |
+
elif gemini_reason == "MAX_TOKENS":
|
| 65 |
+
return "length"
|
| 66 |
+
elif gemini_reason in ["SAFETY", "RECITATION"]:
|
| 67 |
+
return "content_filter"
|
| 68 |
+
else:
|
| 69 |
+
# 对于 None 或未知的 finishReason,返回 "stop" 作为默认值
|
| 70 |
+
# 避免返回 None 导致 MCP 客户端误判为响应未完成而循环调用
|
| 71 |
+
return "stop"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ==================== Tool Conversion Functions ====================
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _normalize_function_name(name: str) -> str:
|
| 78 |
+
"""
|
| 79 |
+
规范化函数名以符合 Gemini API 要求
|
| 80 |
+
|
| 81 |
+
规则:
|
| 82 |
+
- 必须以字母或下划线开头
|
| 83 |
+
- 只能包含 a-z, A-Z, 0-9, 下划线, 英文句点, 英文短划线
|
| 84 |
+
- 最大长度 64 个字符
|
| 85 |
+
|
| 86 |
+
转换策略:
|
| 87 |
+
1. 中文字符转换为拼音
|
| 88 |
+
2. 将非法字符替换为下划线
|
| 89 |
+
3. 如果以非字母/下划线开头,添加下划线前缀
|
| 90 |
+
4. 截断到 64 个字符
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
name: 原始函数名
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
规范化后的函数名
|
| 97 |
+
"""
|
| 98 |
+
import re
|
| 99 |
+
|
| 100 |
+
if not name:
|
| 101 |
+
return "_unnamed_function"
|
| 102 |
+
|
| 103 |
+
# 步骤1:转换中文字符为拼音
|
| 104 |
+
if re.search(r"[\u4e00-\u9fff]", name):
|
| 105 |
+
try:
|
| 106 |
+
parts = []
|
| 107 |
+
for char in name:
|
| 108 |
+
if "\u4e00" <= char <= "\u9fff":
|
| 109 |
+
# 中文字符转换为拼音
|
| 110 |
+
pinyin = lazy_pinyin(char, style=Style.NORMAL)
|
| 111 |
+
parts.append("".join(pinyin))
|
| 112 |
+
else:
|
| 113 |
+
parts.append(char)
|
| 114 |
+
normalized = "".join(parts)
|
| 115 |
+
except ImportError:
|
| 116 |
+
log.warning("pypinyin not installed, cannot convert Chinese characters to pinyin")
|
| 117 |
+
normalized = name
|
| 118 |
+
else:
|
| 119 |
+
normalized = name
|
| 120 |
+
|
| 121 |
+
# 步骤2:将非法字符替换为下划线
|
| 122 |
+
# 合法字符:a-z, A-Z, 0-9, _, ., -
|
| 123 |
+
normalized = re.sub(r"[^a-zA-Z0-9_.\-]", "_", normalized)
|
| 124 |
+
|
| 125 |
+
# 步骤3:确保以字母或下划线开头
|
| 126 |
+
if normalized and not (normalized[0].isalpha() or normalized[0] == "_"):
|
| 127 |
+
# 以数字、点或短横线开头,添加下划线前缀
|
| 128 |
+
normalized = "_" + normalized
|
| 129 |
+
|
| 130 |
+
# 步骤4:截断到 64 个字符
|
| 131 |
+
if len(normalized) > 64:
|
| 132 |
+
normalized = normalized[:64]
|
| 133 |
+
|
| 134 |
+
# 步骤5:确保不为空
|
| 135 |
+
if not normalized:
|
| 136 |
+
normalized = "_unnamed_function"
|
| 137 |
+
|
| 138 |
+
return normalized
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _resolve_ref(ref: str, root_schema: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 142 |
+
"""
|
| 143 |
+
解析 $ref 引用
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
ref: 引用路径,如 "#/definitions/MyType"
|
| 147 |
+
root_schema: 根 schema 对象
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
解析后的 schema,如果失败返回 None
|
| 151 |
+
"""
|
| 152 |
+
if not ref.startswith('#/'):
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
path = ref[2:].split('/')
|
| 156 |
+
current = root_schema
|
| 157 |
+
|
| 158 |
+
for segment in path:
|
| 159 |
+
if isinstance(current, dict) and segment in current:
|
| 160 |
+
current = current[segment]
|
| 161 |
+
else:
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
return current if isinstance(current, dict) else None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _clean_schema_for_claude(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any:
|
| 168 |
+
"""
|
| 169 |
+
清理 JSON Schema,转换为 Claude API 支持的格式(符合 JSON Schema draft 2020-12)
|
| 170 |
+
|
| 171 |
+
处理逻辑:
|
| 172 |
+
1. 解析 $ref 引用
|
| 173 |
+
2. 合并 allOf 中的 schema
|
| 174 |
+
3. 转换 anyOf 为更兼容的格式
|
| 175 |
+
4. 保持标准 JSON Schema 类型(不转换为大写)
|
| 176 |
+
5. 处理 array 的 items
|
| 177 |
+
6. 清理 Claude 不支持的字段
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
schema: JSON Schema 对象
|
| 181 |
+
root_schema: 根 schema(用于解析 $ref)
|
| 182 |
+
visited: 已访问的对象集合(防止循环引用)
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
清理后的 schema
|
| 186 |
+
"""
|
| 187 |
+
# 非字典类型直接返回
|
| 188 |
+
if not isinstance(schema, dict):
|
| 189 |
+
return schema
|
| 190 |
+
|
| 191 |
+
# 初始化
|
| 192 |
+
if root_schema is None:
|
| 193 |
+
root_schema = schema
|
| 194 |
+
if visited is None:
|
| 195 |
+
visited = set()
|
| 196 |
+
|
| 197 |
+
# 防止循环引用
|
| 198 |
+
schema_id = id(schema)
|
| 199 |
+
if schema_id in visited:
|
| 200 |
+
return schema
|
| 201 |
+
visited.add(schema_id)
|
| 202 |
+
|
| 203 |
+
# 创建副本避免修改原对象
|
| 204 |
+
result = {}
|
| 205 |
+
|
| 206 |
+
# 1. 处理 $ref
|
| 207 |
+
if "$ref" in schema:
|
| 208 |
+
resolved = _resolve_ref(schema["$ref"], root_schema)
|
| 209 |
+
if resolved:
|
| 210 |
+
import copy
|
| 211 |
+
result = copy.deepcopy(resolved)
|
| 212 |
+
for key, value in schema.items():
|
| 213 |
+
if key != "$ref":
|
| 214 |
+
result[key] = value
|
| 215 |
+
schema = result
|
| 216 |
+
result = {}
|
| 217 |
+
|
| 218 |
+
# 2. 处理 allOf(合并所有 schema)
|
| 219 |
+
if "allOf" in schema:
|
| 220 |
+
all_of_schemas = schema["allOf"]
|
| 221 |
+
for item in all_of_schemas:
|
| 222 |
+
cleaned_item = _clean_schema_for_claude(item, root_schema, visited)
|
| 223 |
+
|
| 224 |
+
if "properties" in cleaned_item:
|
| 225 |
+
if "properties" not in result:
|
| 226 |
+
result["properties"] = {}
|
| 227 |
+
result["properties"].update(cleaned_item["properties"])
|
| 228 |
+
|
| 229 |
+
if "required" in cleaned_item:
|
| 230 |
+
if "required" not in result:
|
| 231 |
+
result["required"] = []
|
| 232 |
+
result["required"].extend(cleaned_item["required"])
|
| 233 |
+
|
| 234 |
+
for key, value in cleaned_item.items():
|
| 235 |
+
if key not in ["properties", "required"]:
|
| 236 |
+
result[key] = value
|
| 237 |
+
|
| 238 |
+
for key, value in schema.items():
|
| 239 |
+
if key not in ["allOf", "properties", "required"]:
|
| 240 |
+
result[key] = value
|
| 241 |
+
elif key in ["properties", "required"] and key not in result:
|
| 242 |
+
result[key] = value
|
| 243 |
+
else:
|
| 244 |
+
result = dict(schema)
|
| 245 |
+
|
| 246 |
+
# 3. 处理 type 数组(如 ["string", "null"])
|
| 247 |
+
if "type" in result:
|
| 248 |
+
type_value = result["type"]
|
| 249 |
+
if isinstance(type_value, list):
|
| 250 |
+
# Claude 支持 type 数组,保持不变
|
| 251 |
+
pass
|
| 252 |
+
|
| 253 |
+
# 4. 处理 array 的 items
|
| 254 |
+
if result.get("type") == "array":
|
| 255 |
+
if "items" not in result:
|
| 256 |
+
result["items"] = {}
|
| 257 |
+
elif isinstance(result["items"], list):
|
| 258 |
+
# Tuple 定义,检查是否所有元素类型相同
|
| 259 |
+
tuple_items = result["items"]
|
| 260 |
+
first_type = tuple_items[0].get("type") if tuple_items else None
|
| 261 |
+
is_homogeneous = all(item.get("type") == first_type for item in tuple_items)
|
| 262 |
+
|
| 263 |
+
if is_homogeneous and first_type:
|
| 264 |
+
result["items"] = _clean_schema_for_claude(tuple_items[0], root_schema, visited)
|
| 265 |
+
else:
|
| 266 |
+
# 异质元组,使用 anyOf 表示
|
| 267 |
+
result["items"] = {
|
| 268 |
+
"anyOf": [_clean_schema_for_claude(item, root_schema, visited) for item in tuple_items]
|
| 269 |
+
}
|
| 270 |
+
else:
|
| 271 |
+
result["items"] = _clean_schema_for_claude(result["items"], root_schema, visited)
|
| 272 |
+
|
| 273 |
+
# 5. 处理 anyOf(保持 anyOf,递归清理)
|
| 274 |
+
if "anyOf" in result:
|
| 275 |
+
result["anyOf"] = [_clean_schema_for_claude(item, root_schema, visited) for item in result["anyOf"]]
|
| 276 |
+
|
| 277 |
+
# 6. 清理 Claude 不支持的字段(根据 JSON Schema 2020-12)
|
| 278 |
+
# Claude API 对某些字段比较严格,移除可能导致问题的字段
|
| 279 |
+
unsupported_keys = {
|
| 280 |
+
"title", "$schema", "strict",
|
| 281 |
+
"additionalItems", # 废弃字段,使用 items 替代
|
| 282 |
+
"exclusiveMaximum", "exclusiveMinimum", # 在 2020-12 中这些应该是数值而非布尔值
|
| 283 |
+
"$defs", "definitions", # 移除 definitions 相关字段避免冲突
|
| 284 |
+
"example", "examples", "readOnly", "writeOnly",
|
| 285 |
+
"const", # const 可能导致问题
|
| 286 |
+
"contentEncoding", "contentMediaType",
|
| 287 |
+
"oneOf", # oneOf 可能导致问题,用 anyOf 替代
|
| 288 |
+
"patternProperties", "dependencies", "propertyNames", # Google API 不支持
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
for key in list(result.keys()):
|
| 292 |
+
if key in unsupported_keys:
|
| 293 |
+
del result[key]
|
| 294 |
+
|
| 295 |
+
# 递归处理 additionalProperties(如果存在)
|
| 296 |
+
if "additionalProperties" in result and isinstance(result["additionalProperties"], dict):
|
| 297 |
+
result["additionalProperties"] = _clean_schema_for_claude(result["additionalProperties"], root_schema, visited)
|
| 298 |
+
|
| 299 |
+
# 7. 递归处理 properties
|
| 300 |
+
if "properties" in result:
|
| 301 |
+
cleaned_props = {}
|
| 302 |
+
for prop_name, prop_schema in result["properties"].items():
|
| 303 |
+
cleaned_props[prop_name] = _clean_schema_for_claude(prop_schema, root_schema, visited)
|
| 304 |
+
result["properties"] = cleaned_props
|
| 305 |
+
|
| 306 |
+
# 8. 确保有 type 字段(如果有 properties 但没有 type)
|
| 307 |
+
if "properties" in result and "type" not in result:
|
| 308 |
+
result["type"] = "object"
|
| 309 |
+
|
| 310 |
+
# 9. 去重 required 数组
|
| 311 |
+
if "required" in result and isinstance(result["required"], list):
|
| 312 |
+
result["required"] = list(dict.fromkeys(result["required"]))
|
| 313 |
+
|
| 314 |
+
return result
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _clean_schema_for_gemini(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any:
|
| 318 |
+
"""
|
| 319 |
+
清理 JSON Schema,转换为 Gemini 支持的格式
|
| 320 |
+
|
| 321 |
+
参考 worker.mjs 的 transformOpenApiSchemaToGemini 实现
|
| 322 |
+
|
| 323 |
+
处理逻辑:
|
| 324 |
+
1. 解析 $ref 引用
|
| 325 |
+
2. 合并 allOf 中的 schema
|
| 326 |
+
3. 转换 anyOf 为 enum(如果可能)
|
| 327 |
+
4. 类型映射(string -> STRING)
|
| 328 |
+
5. 处理 ARRAY 的 items(包括 Tuple)
|
| 329 |
+
6. 将 default 值移到 description
|
| 330 |
+
7. 清理不支持的字段
|
| 331 |
+
|
| 332 |
+
Args:
|
| 333 |
+
schema: JSON Schema 对象
|
| 334 |
+
root_schema: 根 schema(用于解析 $ref)
|
| 335 |
+
visited: 已访问的对象集合(防止循环引用)
|
| 336 |
+
|
| 337 |
+
Returns:
|
| 338 |
+
清理后的 schema
|
| 339 |
+
"""
|
| 340 |
+
# 非字典类型直接返回
|
| 341 |
+
if not isinstance(schema, dict):
|
| 342 |
+
return schema
|
| 343 |
+
|
| 344 |
+
# 初始化
|
| 345 |
+
if root_schema is None:
|
| 346 |
+
root_schema = schema
|
| 347 |
+
if visited is None:
|
| 348 |
+
visited = set()
|
| 349 |
+
|
| 350 |
+
# 防止循环引用
|
| 351 |
+
schema_id = id(schema)
|
| 352 |
+
if schema_id in visited:
|
| 353 |
+
return schema
|
| 354 |
+
visited.add(schema_id)
|
| 355 |
+
|
| 356 |
+
# 创建副本避免修改原对象
|
| 357 |
+
result = {}
|
| 358 |
+
|
| 359 |
+
# 1. 处理 $ref
|
| 360 |
+
if "$ref" in schema:
|
| 361 |
+
resolved = _resolve_ref(schema["$ref"], root_schema)
|
| 362 |
+
if resolved:
|
| 363 |
+
# 检测循环引用:若 resolved 已在 visited 中,直接返回占位符
|
| 364 |
+
resolved_id = id(resolved)
|
| 365 |
+
if resolved_id in visited:
|
| 366 |
+
return {"type": "OBJECT", "description": "(circular reference)"}
|
| 367 |
+
# 将 resolved 的 id 加入 visited,防止后续递归时重复处理
|
| 368 |
+
visited.add(resolved_id)
|
| 369 |
+
# 合并解析后的 schema 和当前 schema(浅拷贝,避免 deepcopy 爆栈)
|
| 370 |
+
merged = dict(resolved)
|
| 371 |
+
# 当前 schema 的其他字段会覆盖解析后的字段
|
| 372 |
+
for key, value in schema.items():
|
| 373 |
+
if key != "$ref":
|
| 374 |
+
merged[key] = value
|
| 375 |
+
schema = merged
|
| 376 |
+
result = {}
|
| 377 |
+
|
| 378 |
+
# 2. 处理 allOf(合并所有 schema)
|
| 379 |
+
if "allOf" in schema:
|
| 380 |
+
all_of_schemas = schema["allOf"]
|
| 381 |
+
for item in all_of_schemas:
|
| 382 |
+
cleaned_item = _clean_schema_for_gemini(item, root_schema, visited)
|
| 383 |
+
|
| 384 |
+
# 合并 properties
|
| 385 |
+
if "properties" in cleaned_item:
|
| 386 |
+
if "properties" not in result:
|
| 387 |
+
result["properties"] = {}
|
| 388 |
+
result["properties"].update(cleaned_item["properties"])
|
| 389 |
+
|
| 390 |
+
# 合并 required
|
| 391 |
+
if "required" in cleaned_item:
|
| 392 |
+
if "required" not in result:
|
| 393 |
+
result["required"] = []
|
| 394 |
+
result["required"].extend(cleaned_item["required"])
|
| 395 |
+
|
| 396 |
+
# 合并其他字段(简单覆盖)
|
| 397 |
+
for key, value in cleaned_item.items():
|
| 398 |
+
if key not in ["properties", "required"]:
|
| 399 |
+
result[key] = value
|
| 400 |
+
|
| 401 |
+
# 复制其他字段
|
| 402 |
+
for key, value in schema.items():
|
| 403 |
+
if key not in ["allOf", "properties", "required"]:
|
| 404 |
+
result[key] = value
|
| 405 |
+
elif key in ["properties", "required"] and key not in result:
|
| 406 |
+
result[key] = value
|
| 407 |
+
else:
|
| 408 |
+
# 复制所有字段
|
| 409 |
+
result = dict(schema)
|
| 410 |
+
|
| 411 |
+
# 3. 类型映射(转换为大写)
|
| 412 |
+
# 注意:Gemini API 的 type 字段必须是字符串,不能是数组
|
| 413 |
+
if "type" in result:
|
| 414 |
+
type_value = result["type"]
|
| 415 |
+
|
| 416 |
+
# 如果 type 是列表,提取主要类型(非 null)
|
| 417 |
+
if isinstance(type_value, list):
|
| 418 |
+
primary_type = next((t for t in type_value if t != "null"), None)
|
| 419 |
+
type_value = primary_type if primary_type else "STRING" # 默认为 STRING
|
| 420 |
+
|
| 421 |
+
# 类型映射
|
| 422 |
+
type_map = {
|
| 423 |
+
"string": "STRING",
|
| 424 |
+
"number": "NUMBER",
|
| 425 |
+
"integer": "INTEGER",
|
| 426 |
+
"boolean": "BOOLEAN",
|
| 427 |
+
"array": "ARRAY",
|
| 428 |
+
"object": "OBJECT",
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
if isinstance(type_value, str) and type_value.lower() in type_map:
|
| 432 |
+
# 确保 result["type"] 是字符串而不是列表
|
| 433 |
+
result["type"] = type_map[type_value.lower()]
|
| 434 |
+
else:
|
| 435 |
+
# 未知类型,删除该字段
|
| 436 |
+
del result["type"]
|
| 437 |
+
|
| 438 |
+
# 4. 处理 ARRAY 的 items
|
| 439 |
+
if result.get("type") == "ARRAY":
|
| 440 |
+
if "items" not in result:
|
| 441 |
+
# 没有 items,默认允许任意类型
|
| 442 |
+
result["items"] = {}
|
| 443 |
+
elif isinstance(result["items"], list):
|
| 444 |
+
# Tuple 定义(items 是数组)
|
| 445 |
+
tuple_items = result["items"]
|
| 446 |
+
|
| 447 |
+
# 提取类型信息用于 description
|
| 448 |
+
tuple_types = [item.get("type", "any") for item in tuple_items]
|
| 449 |
+
tuple_desc = f"(Tuple: [{', '.join(tuple_types)}])"
|
| 450 |
+
|
| 451 |
+
original_desc = result.get("description", "")
|
| 452 |
+
result["description"] = f"{original_desc} {tuple_desc}".strip()
|
| 453 |
+
|
| 454 |
+
# 检查是否所有元素类型相同
|
| 455 |
+
first_type = tuple_items[0].get("type") if tuple_items else None
|
| 456 |
+
is_homogeneous = all(item.get("type") == first_type for item in tuple_items)
|
| 457 |
+
|
| 458 |
+
if is_homogeneous and first_type:
|
| 459 |
+
# 同质元组,转换为 List<Type>
|
| 460 |
+
result["items"] = _clean_schema_for_gemini(tuple_items[0], root_schema, visited)
|
| 461 |
+
else:
|
| 462 |
+
# 异质元组,Gemini 不支持,设为 {}
|
| 463 |
+
result["items"] = {}
|
| 464 |
+
else:
|
| 465 |
+
# 递归处理 items
|
| 466 |
+
result["items"] = _clean_schema_for_gemini(result["items"], root_schema, visited)
|
| 467 |
+
|
| 468 |
+
# 5. 处理 anyOf(尝试转换为 enum)
|
| 469 |
+
if "anyOf" in result:
|
| 470 |
+
any_of_schemas = result["anyOf"]
|
| 471 |
+
|
| 472 |
+
# 递归处理每个 schema
|
| 473 |
+
cleaned_any_of = [_clean_schema_for_gemini(item, root_schema, visited) for item in any_of_schemas]
|
| 474 |
+
|
| 475 |
+
# 尝试提取 enum
|
| 476 |
+
if all("const" in item for item in cleaned_any_of):
|
| 477 |
+
enum_values = [
|
| 478 |
+
str(item["const"])
|
| 479 |
+
for item in cleaned_any_of
|
| 480 |
+
if item.get("const") not in ["", None]
|
| 481 |
+
]
|
| 482 |
+
if enum_values:
|
| 483 |
+
result["type"] = "STRING"
|
| 484 |
+
result["enum"] = enum_values
|
| 485 |
+
elif "type" not in result:
|
| 486 |
+
# 如果不是 enum,尝试取第一个有效的类型定义
|
| 487 |
+
first_valid = next((item for item in cleaned_any_of if item.get("type") or item.get("enum")), None)
|
| 488 |
+
if first_valid:
|
| 489 |
+
result.update(first_valid)
|
| 490 |
+
|
| 491 |
+
# 删除 anyOf
|
| 492 |
+
del result["anyOf"]
|
| 493 |
+
|
| 494 |
+
# 6. 将 default 值移到 description
|
| 495 |
+
if "default" in result:
|
| 496 |
+
default_value = result["default"]
|
| 497 |
+
original_desc = result.get("description", "")
|
| 498 |
+
result["description"] = f"{original_desc} (Default: {json.dumps(default_value)})".strip()
|
| 499 |
+
del result["default"]
|
| 500 |
+
|
| 501 |
+
# 7. 清理不支持的字段
|
| 502 |
+
unsupported_keys = {
|
| 503 |
+
"title", "$schema", "$ref", "strict", "exclusiveMaximum",
|
| 504 |
+
"exclusiveMinimum", "additionalProperties", "oneOf", "allOf",
|
| 505 |
+
"$defs", "definitions", "example", "examples", "readOnly",
|
| 506 |
+
"writeOnly", "const", "additionalItems", "contains",
|
| 507 |
+
"patternProperties", "dependencies", "propertyNames",
|
| 508 |
+
"if", "then", "else", "contentEncoding", "contentMediaType"
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
for key in list(result.keys()):
|
| 512 |
+
if key in unsupported_keys:
|
| 513 |
+
del result[key]
|
| 514 |
+
|
| 515 |
+
# 8. 递归处理 properties
|
| 516 |
+
if "properties" in result:
|
| 517 |
+
cleaned_props = {}
|
| 518 |
+
for prop_name, prop_schema in result["properties"].items():
|
| 519 |
+
cleaned_props[prop_name] = _clean_schema_for_gemini(prop_schema, root_schema, visited)
|
| 520 |
+
result["properties"] = cleaned_props
|
| 521 |
+
|
| 522 |
+
# 9. 确保有 type 字段(如果有 properties 但没有 type)
|
| 523 |
+
if "properties" in result and "type" not in result:
|
| 524 |
+
result["type"] = "OBJECT"
|
| 525 |
+
|
| 526 |
+
# 10. 去重 required 数组
|
| 527 |
+
if "required" in result and isinstance(result["required"], list):
|
| 528 |
+
result["required"] = list(dict.fromkeys(result["required"])) # 保持顺序去重
|
| 529 |
+
|
| 530 |
+
return result
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def fix_tool_call_args_types(
|
| 534 |
+
args: Dict[str, Any],
|
| 535 |
+
parameters_schema: Dict[str, Any]
|
| 536 |
+
) -> Dict[str, Any]:
|
| 537 |
+
"""
|
| 538 |
+
根据工具的参数 schema 修正函数调用参数的类型
|
| 539 |
+
|
| 540 |
+
例如:将字符串 "5" 转换为数字 5,根据 schema 中的 type 定义
|
| 541 |
+
|
| 542 |
+
Args:
|
| 543 |
+
args: 函数调用的参数字典
|
| 544 |
+
parameters_schema: 工具定义中的 parameters schema
|
| 545 |
+
|
| 546 |
+
Returns:
|
| 547 |
+
类型修正后的参数字典
|
| 548 |
+
"""
|
| 549 |
+
if not args or not parameters_schema:
|
| 550 |
+
return args
|
| 551 |
+
|
| 552 |
+
properties = parameters_schema.get("properties", {})
|
| 553 |
+
if not properties:
|
| 554 |
+
return args
|
| 555 |
+
|
| 556 |
+
fixed_args = {}
|
| 557 |
+
for key, value in args.items():
|
| 558 |
+
if key not in properties:
|
| 559 |
+
# 参数不在 schema 中,保持原样
|
| 560 |
+
fixed_args[key] = value
|
| 561 |
+
continue
|
| 562 |
+
|
| 563 |
+
param_schema = properties[key]
|
| 564 |
+
param_type = param_schema.get("type")
|
| 565 |
+
|
| 566 |
+
# 根据 schema 中的类型修正参数值
|
| 567 |
+
if param_type == "number" or param_type == "integer":
|
| 568 |
+
# 如果值是字符串,尝��转换为数字
|
| 569 |
+
if isinstance(value, str):
|
| 570 |
+
try:
|
| 571 |
+
if param_type == "integer":
|
| 572 |
+
fixed_args[key] = int(value)
|
| 573 |
+
else:
|
| 574 |
+
# 尝试转换为 float,如果是整数则保持为 int
|
| 575 |
+
num_value = float(value)
|
| 576 |
+
fixed_args[key] = int(num_value) if num_value.is_integer() else num_value
|
| 577 |
+
log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} ({param_type})")
|
| 578 |
+
except (ValueError, AttributeError):
|
| 579 |
+
# 转换失败,保持原样
|
| 580 |
+
fixed_args[key] = value
|
| 581 |
+
log.warning(f"[OPENAI2GEMINI] 无法将参数 {key} 的值 '{value}' 转换为 {param_type}")
|
| 582 |
+
else:
|
| 583 |
+
fixed_args[key] = value
|
| 584 |
+
elif param_type == "boolean":
|
| 585 |
+
# 如果值是字符串,转换为布尔值
|
| 586 |
+
if isinstance(value, str):
|
| 587 |
+
if value.lower() in ("true", "1", "yes"):
|
| 588 |
+
fixed_args[key] = True
|
| 589 |
+
elif value.lower() in ("false", "0", "no"):
|
| 590 |
+
fixed_args[key] = False
|
| 591 |
+
else:
|
| 592 |
+
fixed_args[key] = value
|
| 593 |
+
if fixed_args[key] != value:
|
| 594 |
+
log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} (boolean)")
|
| 595 |
+
else:
|
| 596 |
+
fixed_args[key] = value
|
| 597 |
+
elif param_type == "string":
|
| 598 |
+
# 如果值不是字符串,转换为字符串
|
| 599 |
+
if not isinstance(value, str):
|
| 600 |
+
fixed_args[key] = str(value)
|
| 601 |
+
log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} {value} -> '{fixed_args[key]}' (string)")
|
| 602 |
+
else:
|
| 603 |
+
fixed_args[key] = value
|
| 604 |
+
else:
|
| 605 |
+
# 其他类型(array, object 等)保持原样
|
| 606 |
+
fixed_args[key] = value
|
| 607 |
+
|
| 608 |
+
return fixed_args
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def convert_openai_tools_to_gemini(openai_tools: List, model: str = "") -> List[Dict[str, Any]]:
|
| 612 |
+
"""
|
| 613 |
+
将 OpenAI tools 格式转换为 Gemini functionDeclarations 格式
|
| 614 |
+
|
| 615 |
+
Args:
|
| 616 |
+
openai_tools: OpenAI 格式的工具列表(可能是字典或 Pydantic 模型)
|
| 617 |
+
model: 模型名称(用于判断是否为 Claude 模型)
|
| 618 |
+
|
| 619 |
+
Returns:
|
| 620 |
+
Gemini 格式的工具列表
|
| 621 |
+
"""
|
| 622 |
+
if not openai_tools:
|
| 623 |
+
return []
|
| 624 |
+
|
| 625 |
+
# 判断是否为 Claude 模型
|
| 626 |
+
is_claude_model = "claude" in model.lower()
|
| 627 |
+
|
| 628 |
+
function_declarations = []
|
| 629 |
+
|
| 630 |
+
for tool in openai_tools:
|
| 631 |
+
if tool.get("type") != "function":
|
| 632 |
+
log.warning(f"Skipping non-function tool type: {tool.get('type')}")
|
| 633 |
+
continue
|
| 634 |
+
|
| 635 |
+
function = tool.get("function")
|
| 636 |
+
if not function:
|
| 637 |
+
log.warning("Tool missing 'function' field")
|
| 638 |
+
continue
|
| 639 |
+
|
| 640 |
+
# 获取并规范化函数名
|
| 641 |
+
original_name = function.get("name")
|
| 642 |
+
if not original_name:
|
| 643 |
+
log.warning("Tool missing 'name' field, using default")
|
| 644 |
+
original_name = "_unnamed_function"
|
| 645 |
+
|
| 646 |
+
normalized_name = _normalize_function_name(original_name)
|
| 647 |
+
|
| 648 |
+
# 如果名称被修改了,记录日志
|
| 649 |
+
if normalized_name != original_name:
|
| 650 |
+
log.debug(f"Function name normalized: '{original_name}' -> '{normalized_name}'")
|
| 651 |
+
|
| 652 |
+
# 构建 Gemini function declaration
|
| 653 |
+
declaration = {
|
| 654 |
+
"name": normalized_name,
|
| 655 |
+
"description": function.get("description", ""),
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
# 添加参数(如果有)- 根据模型选择不同的清理函数
|
| 659 |
+
if "parameters" in function:
|
| 660 |
+
if is_claude_model:
|
| 661 |
+
cleaned_params = _clean_schema_for_claude(function["parameters"])
|
| 662 |
+
log.debug(f"[OPENAI2GEMINI] Using Claude schema cleaning for tool: {normalized_name}")
|
| 663 |
+
else:
|
| 664 |
+
cleaned_params = _clean_schema_for_gemini(function["parameters"])
|
| 665 |
+
|
| 666 |
+
if cleaned_params:
|
| 667 |
+
declaration["parameters"] = cleaned_params
|
| 668 |
+
|
| 669 |
+
function_declarations.append(declaration)
|
| 670 |
+
|
| 671 |
+
if not function_declarations:
|
| 672 |
+
return []
|
| 673 |
+
|
| 674 |
+
# Gemini 格式:工具数组中包含 functionDeclarations
|
| 675 |
+
return [{"functionDeclarations": function_declarations}]
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
def convert_tool_choice_to_tool_config(tool_choice: Union[str, Dict[str, Any]]) -> Dict[str, Any]:
|
| 679 |
+
"""
|
| 680 |
+
将 OpenAI tool_choice 转换为 Gemini toolConfig
|
| 681 |
+
|
| 682 |
+
Args:
|
| 683 |
+
tool_choice: OpenAI 格式的 tool_choice
|
| 684 |
+
|
| 685 |
+
Returns:
|
| 686 |
+
Gemini 格式的 toolConfig
|
| 687 |
+
"""
|
| 688 |
+
if isinstance(tool_choice, str):
|
| 689 |
+
if tool_choice == "auto":
|
| 690 |
+
return {"functionCallingConfig": {"mode": "AUTO"}}
|
| 691 |
+
elif tool_choice == "none":
|
| 692 |
+
return {"functionCallingConfig": {"mode": "NONE"}}
|
| 693 |
+
elif tool_choice == "required":
|
| 694 |
+
return {"functionCallingConfig": {"mode": "ANY"}}
|
| 695 |
+
elif isinstance(tool_choice, dict):
|
| 696 |
+
# {"type": "function", "function": {"name": "my_function"}}
|
| 697 |
+
if tool_choice.get("type") == "function":
|
| 698 |
+
function_name = tool_choice.get("function", {}).get("name")
|
| 699 |
+
if function_name:
|
| 700 |
+
return {
|
| 701 |
+
"functionCallingConfig": {
|
| 702 |
+
"mode": "ANY",
|
| 703 |
+
"allowedFunctionNames": [function_name],
|
| 704 |
+
}
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
# 默认返回 AUTO 模式
|
| 708 |
+
return {"functionCallingConfig": {"mode": "AUTO"}}
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def convert_tool_message_to_function_response(message, all_messages: List = None) -> Dict[str, Any]:
|
| 712 |
+
"""
|
| 713 |
+
将 OpenAI 的 tool role 消息转换为 Gemini functionResponse
|
| 714 |
+
|
| 715 |
+
Args:
|
| 716 |
+
message: OpenAI 格式的工具消息
|
| 717 |
+
all_messages: 所有消息的列表,用于查找 tool_call_id 对应的函数名
|
| 718 |
+
|
| 719 |
+
Returns:
|
| 720 |
+
Gemini 格式的 functionResponse part
|
| 721 |
+
"""
|
| 722 |
+
# 获取 name 字段
|
| 723 |
+
name = getattr(message, "name", None)
|
| 724 |
+
encoded_tool_call_id = getattr(message, "tool_call_id", None) or ""
|
| 725 |
+
|
| 726 |
+
# 解码获取原始ID(functionResponse不需要签名)
|
| 727 |
+
original_tool_call_id, _ = decode_tool_id_and_signature(encoded_tool_call_id)
|
| 728 |
+
|
| 729 |
+
# 如果没有 name,尝试从 all_messages 中查找对应的 tool_call_id
|
| 730 |
+
# 注意:使用编码ID查找,因为存储的是编码ID
|
| 731 |
+
if not name and encoded_tool_call_id and all_messages:
|
| 732 |
+
for msg in all_messages:
|
| 733 |
+
if getattr(msg, "role", None) == "assistant" and hasattr(msg, "tool_calls") and msg.tool_calls:
|
| 734 |
+
for tool_call in msg.tool_calls:
|
| 735 |
+
if getattr(tool_call, "id", None) == encoded_tool_call_id:
|
| 736 |
+
func = getattr(tool_call, "function", None)
|
| 737 |
+
if func:
|
| 738 |
+
name = getattr(func, "name", None)
|
| 739 |
+
break
|
| 740 |
+
if name:
|
| 741 |
+
break
|
| 742 |
+
|
| 743 |
+
# 最终兜底:如果仍然没有 name,使用默认值
|
| 744 |
+
if not name:
|
| 745 |
+
name = "unknown_function"
|
| 746 |
+
log.warning(f"Tool message missing function name, using default: {name}")
|
| 747 |
+
|
| 748 |
+
try:
|
| 749 |
+
# 尝试将 content 解析为 JSON
|
| 750 |
+
response_data = (
|
| 751 |
+
json.loads(message.content) if isinstance(message.content, str) else message.content
|
| 752 |
+
)
|
| 753 |
+
except (json.JSONDecodeError, TypeError):
|
| 754 |
+
# 如果不是有效的 JSON,包装为对象
|
| 755 |
+
response_data = {"result": str(message.content)}
|
| 756 |
+
|
| 757 |
+
# 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象)
|
| 758 |
+
if not isinstance(response_data, dict):
|
| 759 |
+
response_data = {"result": response_data}
|
| 760 |
+
|
| 761 |
+
return {"functionResponse": {"id": original_tool_call_id, "name": name, "response": response_data}}
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
def _reverse_transform_value(value: Any) -> Any:
|
| 765 |
+
"""
|
| 766 |
+
将值转换回原始类型(Gemini 可能将所有值转为字符串)
|
| 767 |
+
|
| 768 |
+
仅处理 Gemini 在工具参数中常见的布尔/空值字符串化情况,
|
| 769 |
+
不再对数字字符串做启发式转换,避免把 schema 声明为 string
|
| 770 |
+
的参数错误还原成 integer。
|
| 771 |
+
|
| 772 |
+
参考 worker.mjs 的 reverseTransformValue
|
| 773 |
+
|
| 774 |
+
Args:
|
| 775 |
+
value: 要转换的值
|
| 776 |
+
|
| 777 |
+
Returns:
|
| 778 |
+
转换后的值
|
| 779 |
+
"""
|
| 780 |
+
if not isinstance(value, str):
|
| 781 |
+
return value
|
| 782 |
+
|
| 783 |
+
# 布尔值
|
| 784 |
+
if value == 'true':
|
| 785 |
+
return True
|
| 786 |
+
if value == 'false':
|
| 787 |
+
return False
|
| 788 |
+
|
| 789 |
+
# null
|
| 790 |
+
if value == 'null':
|
| 791 |
+
return None
|
| 792 |
+
|
| 793 |
+
# 其他情况保持字符串
|
| 794 |
+
return value
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
def _reverse_transform_args(args: Any) -> Any:
|
| 798 |
+
"""
|
| 799 |
+
递归转换函数参数,将字符串转回原始类型
|
| 800 |
+
|
| 801 |
+
参考 worker.mjs 的 reverseTransformArgs
|
| 802 |
+
|
| 803 |
+
Args:
|
| 804 |
+
args: 函数参数(可能是字典、列表或其他类型)
|
| 805 |
+
|
| 806 |
+
Returns:
|
| 807 |
+
转换后的参数
|
| 808 |
+
"""
|
| 809 |
+
if not isinstance(args, (dict, list)):
|
| 810 |
+
return args
|
| 811 |
+
|
| 812 |
+
if isinstance(args, list):
|
| 813 |
+
return [_reverse_transform_args(item) for item in args]
|
| 814 |
+
|
| 815 |
+
# 处理字典
|
| 816 |
+
result = {}
|
| 817 |
+
for key, value in args.items():
|
| 818 |
+
if isinstance(value, (dict, list)):
|
| 819 |
+
result[key] = _reverse_transform_args(value)
|
| 820 |
+
else:
|
| 821 |
+
result[key] = _reverse_transform_value(value)
|
| 822 |
+
|
| 823 |
+
return result
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
def extract_tool_calls_from_parts(
|
| 827 |
+
parts: List[Dict[str, Any]], is_streaming: bool = False
|
| 828 |
+
) -> Tuple[List[Dict[str, Any]], str]:
|
| 829 |
+
"""
|
| 830 |
+
从 Gemini response parts 中提取工具调用和文本内容
|
| 831 |
+
|
| 832 |
+
Args:
|
| 833 |
+
parts: Gemini response 的 parts 数组
|
| 834 |
+
is_streaming: 是否为流式响应(流式响应需要添加 index 字段)
|
| 835 |
+
|
| 836 |
+
Returns:
|
| 837 |
+
(tool_calls, text_content) 元组
|
| 838 |
+
"""
|
| 839 |
+
tool_calls = []
|
| 840 |
+
text_content = ""
|
| 841 |
+
|
| 842 |
+
for idx, part in enumerate(parts):
|
| 843 |
+
# 检查是否是函数调用
|
| 844 |
+
if "functionCall" in part:
|
| 845 |
+
function_call = part["functionCall"]
|
| 846 |
+
# 获取原始ID或生成新ID
|
| 847 |
+
original_id = function_call.get("id") or f"call_{uuid.uuid4().hex[:24]}"
|
| 848 |
+
# 将thoughtSignature编码到ID中以便往返保留
|
| 849 |
+
signature = part.get("thoughtSignature")
|
| 850 |
+
encoded_id = encode_tool_id_with_signature(original_id, signature)
|
| 851 |
+
|
| 852 |
+
# 获取参数并转换类型
|
| 853 |
+
args = function_call.get("args", {})
|
| 854 |
+
# 将字符串类型的值转回原始类型
|
| 855 |
+
args = _reverse_transform_args(args)
|
| 856 |
+
|
| 857 |
+
tool_call = {
|
| 858 |
+
"id": encoded_id,
|
| 859 |
+
"type": "function",
|
| 860 |
+
"function": {
|
| 861 |
+
"name": function_call.get("name", "nameless_function"),
|
| 862 |
+
"arguments": json.dumps(args),
|
| 863 |
+
},
|
| 864 |
+
}
|
| 865 |
+
# 流式响应需要 index 字段
|
| 866 |
+
if is_streaming:
|
| 867 |
+
tool_call["index"] = idx
|
| 868 |
+
tool_calls.append(tool_call)
|
| 869 |
+
|
| 870 |
+
# 提取文本内容(排除 thinking tokens)
|
| 871 |
+
elif "text" in part and not part.get("thought", False):
|
| 872 |
+
text_content += part["text"]
|
| 873 |
+
|
| 874 |
+
return tool_calls, text_content
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
def extract_images_from_content(content: Any) -> Dict[str, Any]:
|
| 878 |
+
"""
|
| 879 |
+
从 OpenAI content 中提取文本和图片
|
| 880 |
+
|
| 881 |
+
Args:
|
| 882 |
+
content: OpenAI 消息的 content 字段(可能是字符串或列表)
|
| 883 |
+
|
| 884 |
+
Returns:
|
| 885 |
+
包含 text 和 images 的字典
|
| 886 |
+
"""
|
| 887 |
+
result = {"text": "", "images": []}
|
| 888 |
+
|
| 889 |
+
if isinstance(content, str):
|
| 890 |
+
result["text"] = content
|
| 891 |
+
elif isinstance(content, list):
|
| 892 |
+
for item in content:
|
| 893 |
+
if isinstance(item, dict):
|
| 894 |
+
if item.get("type") == "text":
|
| 895 |
+
result["text"] += item.get("text", "")
|
| 896 |
+
elif item.get("type") == "image_url":
|
| 897 |
+
image_url = item.get("image_url", {}).get("url", "")
|
| 898 |
+
# 解析 data:image/png;base64,xxx 格式
|
| 899 |
+
if image_url.startswith("data:image/"):
|
| 900 |
+
import re
|
| 901 |
+
match = re.match(r"^data:image/(\w+);base64,(.+)$", image_url)
|
| 902 |
+
if match:
|
| 903 |
+
mime_type = match.group(1)
|
| 904 |
+
base64_data = match.group(2)
|
| 905 |
+
result["images"].append({
|
| 906 |
+
"inlineData": {
|
| 907 |
+
"mimeType": f"image/{mime_type}",
|
| 908 |
+
"data": base64_data
|
| 909 |
+
}
|
| 910 |
+
})
|
| 911 |
+
|
| 912 |
+
return result
|
| 913 |
+
|
| 914 |
+
async def convert_openai_to_gemini_request(openai_request: Dict[str, Any]) -> Dict[str, Any]:
|
| 915 |
+
"""
|
| 916 |
+
将 OpenAI 格式请求体转换为 Gemini 格式请求体
|
| 917 |
+
|
| 918 |
+
注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
|
| 919 |
+
(如 thinking config, search tools, 参数范围限制等)
|
| 920 |
+
|
| 921 |
+
Args:
|
| 922 |
+
openai_request: OpenAI 格式的请求体字典,包含:
|
| 923 |
+
- messages: 消息列表
|
| 924 |
+
- temperature, top_p, max_tokens, stop 等生成参数
|
| 925 |
+
- tools, tool_choice (可选)
|
| 926 |
+
- response_format (可选)
|
| 927 |
+
|
| 928 |
+
Returns:
|
| 929 |
+
Gemini 格式的请求体字典,包含:
|
| 930 |
+
- contents: 转换后的消息内容
|
| 931 |
+
- generationConfig: 生成配置
|
| 932 |
+
- systemInstruction: 系统指令 (如果有)
|
| 933 |
+
- tools, toolConfig (如果有)
|
| 934 |
+
"""
|
| 935 |
+
# 处理连续的system消息(兼容性模式)
|
| 936 |
+
openai_request = await merge_system_messages(openai_request)
|
| 937 |
+
|
| 938 |
+
contents = []
|
| 939 |
+
|
| 940 |
+
# 提取消息列表
|
| 941 |
+
messages = openai_request.get("messages", [])
|
| 942 |
+
|
| 943 |
+
# 构建 tool_call_id -> (name, original_id, signature) 的映射
|
| 944 |
+
tool_call_mapping = {}
|
| 945 |
+
for msg in messages:
|
| 946 |
+
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
| 947 |
+
for tc in msg["tool_calls"]:
|
| 948 |
+
encoded_id = tc.get("id", "")
|
| 949 |
+
func_name = tc.get("function", {}).get("name") or ""
|
| 950 |
+
if encoded_id:
|
| 951 |
+
# 解码获取原始ID和签名
|
| 952 |
+
original_id, signature = decode_tool_id_and_signature(encoded_id)
|
| 953 |
+
tool_call_mapping[encoded_id] = (func_name, original_id, signature)
|
| 954 |
+
|
| 955 |
+
# 构建工具名称到参数 schema 的映射(用于类型修正)
|
| 956 |
+
tool_schemas = {}
|
| 957 |
+
if "tools" in openai_request and openai_request["tools"]:
|
| 958 |
+
for tool in openai_request["tools"]:
|
| 959 |
+
if tool.get("type") == "function":
|
| 960 |
+
function = tool.get("function", {})
|
| 961 |
+
func_name = function.get("name")
|
| 962 |
+
if func_name:
|
| 963 |
+
tool_schemas[func_name] = function.get("parameters", {})
|
| 964 |
+
|
| 965 |
+
# 用于累积连续的 tool message 的 functionResponse parts
|
| 966 |
+
pending_tool_parts = []
|
| 967 |
+
|
| 968 |
+
def flush_pending_tool_parts():
|
| 969 |
+
"""将累积的 tool parts 作为单个 contents 条目追加"""
|
| 970 |
+
nonlocal pending_tool_parts
|
| 971 |
+
if pending_tool_parts:
|
| 972 |
+
contents.append({
|
| 973 |
+
"role": "user",
|
| 974 |
+
"parts": pending_tool_parts
|
| 975 |
+
})
|
| 976 |
+
pending_tool_parts = []
|
| 977 |
+
|
| 978 |
+
for message in messages:
|
| 979 |
+
role = message.get("role", "user")
|
| 980 |
+
content = message.get("content", "")
|
| 981 |
+
|
| 982 |
+
# 处理工具消息(tool role)- 累积到 pending_tool_parts
|
| 983 |
+
if role == "tool":
|
| 984 |
+
tool_call_id = message.get("tool_call_id", "")
|
| 985 |
+
func_name = message.get("name")
|
| 986 |
+
|
| 987 |
+
# 使用映射表查找
|
| 988 |
+
if tool_call_id in tool_call_mapping:
|
| 989 |
+
func_name, original_id, _ = tool_call_mapping[tool_call_id]
|
| 990 |
+
else:
|
| 991 |
+
# 如果没有name,尝试从消息列表中查找
|
| 992 |
+
if not func_name and tool_call_id:
|
| 993 |
+
for msg in messages:
|
| 994 |
+
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
| 995 |
+
for tc in msg["tool_calls"]:
|
| 996 |
+
if tc.get("id") == tool_call_id:
|
| 997 |
+
func_name = tc.get("function", {}).get("name")
|
| 998 |
+
break
|
| 999 |
+
if func_name:
|
| 1000 |
+
break
|
| 1001 |
+
|
| 1002 |
+
# 解码 tool_call_id 获取原始 ID
|
| 1003 |
+
original_id, _ = decode_tool_id_and_signature(tool_call_id)
|
| 1004 |
+
|
| 1005 |
+
# 最终兜底:确保 func_name 不为空
|
| 1006 |
+
if not func_name:
|
| 1007 |
+
func_name = "unknown_function"
|
| 1008 |
+
log.warning(f"Tool message missing function name for tool_call_id={tool_call_id}, using default: {func_name}")
|
| 1009 |
+
|
| 1010 |
+
# 解析响应数据
|
| 1011 |
+
try:
|
| 1012 |
+
response_data = json.loads(content) if isinstance(content, str) else content
|
| 1013 |
+
except (json.JSONDecodeError, TypeError):
|
| 1014 |
+
response_data = {"result": str(content)}
|
| 1015 |
+
|
| 1016 |
+
# 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象)
|
| 1017 |
+
if not isinstance(response_data, dict):
|
| 1018 |
+
response_data = {"result": response_data}
|
| 1019 |
+
|
| 1020 |
+
# 累积 functionResponse part(不立即追加到 contents)
|
| 1021 |
+
pending_tool_parts.append({
|
| 1022 |
+
"functionResponse": {
|
| 1023 |
+
"id": original_id,
|
| 1024 |
+
"name": func_name,
|
| 1025 |
+
"response": response_data
|
| 1026 |
+
}
|
| 1027 |
+
})
|
| 1028 |
+
continue
|
| 1029 |
+
|
| 1030 |
+
# 遇到非 tool 消息时,先 flush 累积的 tool parts
|
| 1031 |
+
flush_pending_tool_parts()
|
| 1032 |
+
|
| 1033 |
+
# system 消息已经由 merge_system_messages 处理,这里跳过
|
| 1034 |
+
if role == "system":
|
| 1035 |
+
continue
|
| 1036 |
+
|
| 1037 |
+
# 将OpenAI角色映射到Gemini角色
|
| 1038 |
+
if role == "assistant":
|
| 1039 |
+
role = "model"
|
| 1040 |
+
|
| 1041 |
+
# 检查是否有tool_calls
|
| 1042 |
+
tool_calls = message.get("tool_calls")
|
| 1043 |
+
if tool_calls:
|
| 1044 |
+
parts = []
|
| 1045 |
+
|
| 1046 |
+
# 如果有文本内容,先添加文本
|
| 1047 |
+
if content:
|
| 1048 |
+
parts.append({"text": content})
|
| 1049 |
+
|
| 1050 |
+
# 添加每个工具调用
|
| 1051 |
+
for tool_call in tool_calls:
|
| 1052 |
+
try:
|
| 1053 |
+
args = (
|
| 1054 |
+
json.loads(tool_call["function"]["arguments"])
|
| 1055 |
+
if isinstance(tool_call["function"]["arguments"], str)
|
| 1056 |
+
else tool_call["function"]["arguments"]
|
| 1057 |
+
)
|
| 1058 |
+
|
| 1059 |
+
# 根据工具的 schema 修正参数类型
|
| 1060 |
+
func_name = tool_call["function"]["name"]
|
| 1061 |
+
if func_name in tool_schemas:
|
| 1062 |
+
args = fix_tool_call_args_types(args, tool_schemas[func_name])
|
| 1063 |
+
|
| 1064 |
+
# 解码工具ID和thoughtSignature
|
| 1065 |
+
encoded_id = tool_call.get("id", "")
|
| 1066 |
+
original_id, signature = decode_tool_id_and_signature(encoded_id)
|
| 1067 |
+
|
| 1068 |
+
# 构建functionCall part
|
| 1069 |
+
function_call_part = {
|
| 1070 |
+
"functionCall": {
|
| 1071 |
+
"id": original_id,
|
| 1072 |
+
"name": func_name,
|
| 1073 |
+
"args": args
|
| 1074 |
+
}
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
# 如果有thoughtSignature则添加,否则使用占位符以满足 Gemini API 要求
|
| 1078 |
+
if signature:
|
| 1079 |
+
function_call_part["thoughtSignature"] = signature
|
| 1080 |
+
else:
|
| 1081 |
+
function_call_part["thoughtSignature"] = "skip_thought_signature_validator"
|
| 1082 |
+
|
| 1083 |
+
parts.append(function_call_part)
|
| 1084 |
+
except (json.JSONDecodeError, KeyError) as e:
|
| 1085 |
+
log.error(f"Failed to parse tool call: {e}")
|
| 1086 |
+
continue
|
| 1087 |
+
|
| 1088 |
+
if parts:
|
| 1089 |
+
contents.append({"role": role, "parts": parts})
|
| 1090 |
+
continue
|
| 1091 |
+
|
| 1092 |
+
# 处理普通内容
|
| 1093 |
+
if isinstance(content, list):
|
| 1094 |
+
parts = []
|
| 1095 |
+
for part in content:
|
| 1096 |
+
if part.get("type") == "text":
|
| 1097 |
+
parts.append({"text": part.get("text", "")})
|
| 1098 |
+
elif part.get("type") == "image_url":
|
| 1099 |
+
image_url = part.get("image_url", {}).get("url")
|
| 1100 |
+
if image_url:
|
| 1101 |
+
try:
|
| 1102 |
+
mime_type, base64_data = image_url.split(";")
|
| 1103 |
+
_, mime_type = mime_type.split(":")
|
| 1104 |
+
_, base64_data = base64_data.split(",")
|
| 1105 |
+
parts.append({
|
| 1106 |
+
"inlineData": {
|
| 1107 |
+
"mimeType": mime_type,
|
| 1108 |
+
"data": base64_data,
|
| 1109 |
+
}
|
| 1110 |
+
})
|
| 1111 |
+
except ValueError:
|
| 1112 |
+
continue
|
| 1113 |
+
if parts:
|
| 1114 |
+
contents.append({"role": role, "parts": parts})
|
| 1115 |
+
elif content:
|
| 1116 |
+
contents.append({"role": role, "parts": [{"text": content}]})
|
| 1117 |
+
|
| 1118 |
+
# 循环结束后,flush 剩余的 tool parts(如果消息列表以 tool 消息结尾)
|
| 1119 |
+
flush_pending_tool_parts()
|
| 1120 |
+
|
| 1121 |
+
# 构建生成配置
|
| 1122 |
+
generation_config = {}
|
| 1123 |
+
model = openai_request.get("model", "")
|
| 1124 |
+
|
| 1125 |
+
# 基础参数映射
|
| 1126 |
+
if "temperature" in openai_request:
|
| 1127 |
+
generation_config["temperature"] = openai_request["temperature"]
|
| 1128 |
+
if "top_p" in openai_request:
|
| 1129 |
+
generation_config["topP"] = openai_request["top_p"]
|
| 1130 |
+
if "top_k" in openai_request:
|
| 1131 |
+
generation_config["topK"] = openai_request["top_k"]
|
| 1132 |
+
if "max_tokens" in openai_request or "max_completion_tokens" in openai_request:
|
| 1133 |
+
# max_completion_tokens 优先于 max_tokens
|
| 1134 |
+
max_tokens = openai_request.get("max_completion_tokens") or openai_request.get("max_tokens")
|
| 1135 |
+
generation_config["maxOutputTokens"] = max_tokens
|
| 1136 |
+
if "stop" in openai_request:
|
| 1137 |
+
stop = openai_request["stop"]
|
| 1138 |
+
generation_config["stopSequences"] = [stop] if isinstance(stop, str) else stop
|
| 1139 |
+
if "frequency_penalty" in openai_request:
|
| 1140 |
+
generation_config["frequencyPenalty"] = openai_request["frequency_penalty"]
|
| 1141 |
+
if "presence_penalty" in openai_request:
|
| 1142 |
+
generation_config["presencePenalty"] = openai_request["presence_penalty"]
|
| 1143 |
+
if "n" in openai_request:
|
| 1144 |
+
generation_config["candidateCount"] = openai_request["n"]
|
| 1145 |
+
if "seed" in openai_request:
|
| 1146 |
+
generation_config["seed"] = openai_request["seed"]
|
| 1147 |
+
|
| 1148 |
+
# 处理 response_format
|
| 1149 |
+
if "response_format" in openai_request and openai_request["response_format"]:
|
| 1150 |
+
response_format = openai_request["response_format"]
|
| 1151 |
+
format_type = response_format.get("type")
|
| 1152 |
+
|
| 1153 |
+
if format_type == "json_schema":
|
| 1154 |
+
# JSON Schema 模式
|
| 1155 |
+
if "json_schema" in response_format and "schema" in response_format["json_schema"]:
|
| 1156 |
+
schema = response_format["json_schema"]["schema"]
|
| 1157 |
+
# 清理 schema
|
| 1158 |
+
generation_config["responseSchema"] = _clean_schema_for_gemini(schema)
|
| 1159 |
+
generation_config["responseMimeType"] = "application/json"
|
| 1160 |
+
elif format_type == "json_object":
|
| 1161 |
+
# JSON Object 模式
|
| 1162 |
+
generation_config["responseMimeType"] = "application/json"
|
| 1163 |
+
elif format_type == "text":
|
| 1164 |
+
# Text 模式
|
| 1165 |
+
generation_config["responseMimeType"] = "text/plain"
|
| 1166 |
+
|
| 1167 |
+
# 如果contents为空,添加默认用户消息
|
| 1168 |
+
if not contents:
|
| 1169 |
+
contents.append({"role": "user", "parts": [{"text": "请根据系统指令回答。"}]})
|
| 1170 |
+
|
| 1171 |
+
# 构建基础请求
|
| 1172 |
+
gemini_request = {
|
| 1173 |
+
"contents": contents,
|
| 1174 |
+
"generationConfig": generation_config
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
# 如果 merge_system_messages 已经添加了 systemInstruction,使用它
|
| 1178 |
+
if "systemInstruction" in openai_request:
|
| 1179 |
+
gemini_request["systemInstruction"] = openai_request["systemInstruction"]
|
| 1180 |
+
|
| 1181 |
+
# 处理工具 - 传递 model 参数以便根据模型类型选择清理策略
|
| 1182 |
+
model = openai_request.get("model", "")
|
| 1183 |
+
if "tools" in openai_request and openai_request["tools"]:
|
| 1184 |
+
gemini_request["tools"] = convert_openai_tools_to_gemini(openai_request["tools"], model)
|
| 1185 |
+
|
| 1186 |
+
# 处理tool_choice
|
| 1187 |
+
if "tool_choice" in openai_request and openai_request["tool_choice"]:
|
| 1188 |
+
gemini_request["toolConfig"] = convert_tool_choice_to_tool_config(openai_request["tool_choice"])
|
| 1189 |
+
|
| 1190 |
+
return gemini_request
|
| 1191 |
+
|
| 1192 |
+
|
| 1193 |
+
def convert_gemini_to_openai_response(
|
| 1194 |
+
gemini_response: Union[Dict[str, Any], Any],
|
| 1195 |
+
model: str,
|
| 1196 |
+
status_code: int = 200
|
| 1197 |
+
) -> Dict[str, Any]:
|
| 1198 |
+
"""
|
| 1199 |
+
将 Gemini 格式非流式响应转换为 OpenAI 格式非流式响应
|
| 1200 |
+
|
| 1201 |
+
注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始响应
|
| 1202 |
+
|
| 1203 |
+
Args:
|
| 1204 |
+
gemini_response: Gemini 格式的响应体 (字典或响应对象)
|
| 1205 |
+
model: 模型名称
|
| 1206 |
+
status_code: HTTP 状态码 (默认 200)
|
| 1207 |
+
|
| 1208 |
+
Returns:
|
| 1209 |
+
OpenAI 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
|
| 1210 |
+
"""
|
| 1211 |
+
# 非 2xx 状态码直���返回原始响应
|
| 1212 |
+
if not (200 <= status_code < 300):
|
| 1213 |
+
if isinstance(gemini_response, dict):
|
| 1214 |
+
return gemini_response
|
| 1215 |
+
else:
|
| 1216 |
+
# 如果是响应对象,尝试解析为字典
|
| 1217 |
+
try:
|
| 1218 |
+
if hasattr(gemini_response, "json"):
|
| 1219 |
+
return gemini_response.json()
|
| 1220 |
+
elif hasattr(gemini_response, "body"):
|
| 1221 |
+
body = gemini_response.body
|
| 1222 |
+
if isinstance(body, bytes):
|
| 1223 |
+
return json.loads(body.decode())
|
| 1224 |
+
return json.loads(str(body))
|
| 1225 |
+
else:
|
| 1226 |
+
return {"error": str(gemini_response)}
|
| 1227 |
+
except Exception:
|
| 1228 |
+
return {"error": str(gemini_response)}
|
| 1229 |
+
|
| 1230 |
+
# 确保是字典格式
|
| 1231 |
+
if not isinstance(gemini_response, dict):
|
| 1232 |
+
try:
|
| 1233 |
+
if hasattr(gemini_response, "json"):
|
| 1234 |
+
gemini_response = gemini_response.json()
|
| 1235 |
+
elif hasattr(gemini_response, "body"):
|
| 1236 |
+
body = gemini_response.body
|
| 1237 |
+
if isinstance(body, bytes):
|
| 1238 |
+
gemini_response = json.loads(body.decode())
|
| 1239 |
+
else:
|
| 1240 |
+
gemini_response = json.loads(str(body))
|
| 1241 |
+
else:
|
| 1242 |
+
gemini_response = json.loads(str(gemini_response))
|
| 1243 |
+
except Exception:
|
| 1244 |
+
return {"error": "Invalid response format"}
|
| 1245 |
+
|
| 1246 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 1247 |
+
if "response" in gemini_response:
|
| 1248 |
+
gemini_response = gemini_response["response"]
|
| 1249 |
+
|
| 1250 |
+
# 转换为 OpenAI 格式
|
| 1251 |
+
choices = []
|
| 1252 |
+
|
| 1253 |
+
for candidate in gemini_response.get("candidates", []):
|
| 1254 |
+
role = candidate.get("content", {}).get("role", "assistant")
|
| 1255 |
+
|
| 1256 |
+
# 将Gemini角色映射回OpenAI角色
|
| 1257 |
+
if role == "model":
|
| 1258 |
+
role = "assistant"
|
| 1259 |
+
|
| 1260 |
+
# 提取并分离thinking tokens和常规内容
|
| 1261 |
+
parts = candidate.get("content", {}).get("parts", [])
|
| 1262 |
+
|
| 1263 |
+
# 提取工具调用和文本内容
|
| 1264 |
+
tool_calls, text_content = extract_tool_calls_from_parts(parts)
|
| 1265 |
+
|
| 1266 |
+
# 提取多种类型的内容
|
| 1267 |
+
content_parts = []
|
| 1268 |
+
reasoning_parts = []
|
| 1269 |
+
|
| 1270 |
+
for part in parts:
|
| 1271 |
+
# 处理 executableCode(代码生成)
|
| 1272 |
+
if "executableCode" in part:
|
| 1273 |
+
exec_code = part["executableCode"]
|
| 1274 |
+
lang = exec_code.get("language", "python").lower()
|
| 1275 |
+
code = exec_code.get("code", "")
|
| 1276 |
+
# 添加代码块(前后加换行符确保 Markdown 渲染正确)
|
| 1277 |
+
content_parts.append(f"\n```{lang}\n{code}\n```\n")
|
| 1278 |
+
|
| 1279 |
+
# 处理 codeExecutionResult(代码执行结果)
|
| 1280 |
+
elif "codeExecutionResult" in part:
|
| 1281 |
+
result = part["codeExecutionResult"]
|
| 1282 |
+
outcome = result.get("outcome")
|
| 1283 |
+
output = result.get("output", "")
|
| 1284 |
+
|
| 1285 |
+
if output:
|
| 1286 |
+
label = "output" if outcome == "OUTCOME_OK" else "error"
|
| 1287 |
+
content_parts.append(f"\n```{label}\n{output}\n```\n")
|
| 1288 |
+
|
| 1289 |
+
# 处理 thought(思考内容)
|
| 1290 |
+
elif part.get("thought", False) and "text" in part:
|
| 1291 |
+
reasoning_parts.append(part["text"])
|
| 1292 |
+
|
| 1293 |
+
# 处理普通文本(非思考内容)
|
| 1294 |
+
elif "text" in part and not part.get("thought", False):
|
| 1295 |
+
# 这部分已经在 extract_tool_calls_from_parts 中处理
|
| 1296 |
+
pass
|
| 1297 |
+
|
| 1298 |
+
# 处理 inlineData(图片)
|
| 1299 |
+
elif "inlineData" in part:
|
| 1300 |
+
inline_data = part["inlineData"]
|
| 1301 |
+
mime_type = inline_data.get("mimeType", "image/png")
|
| 1302 |
+
base64_data = inline_data.get("data", "")
|
| 1303 |
+
# 使用 Markdown 格式
|
| 1304 |
+
content_parts.append(f"")
|
| 1305 |
+
|
| 1306 |
+
# 合并所有内容部分
|
| 1307 |
+
if content_parts:
|
| 1308 |
+
# 使用双换行符连接各部分,确保块之间有间距
|
| 1309 |
+
additional_content = "\n\n".join(content_parts)
|
| 1310 |
+
if text_content:
|
| 1311 |
+
text_content = text_content + "\n\n" + additional_content
|
| 1312 |
+
else:
|
| 1313 |
+
text_content = additional_content
|
| 1314 |
+
|
| 1315 |
+
# 合并 reasoning content
|
| 1316 |
+
reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else ""
|
| 1317 |
+
|
| 1318 |
+
# 构建消息对象
|
| 1319 |
+
message = {"role": role}
|
| 1320 |
+
|
| 1321 |
+
# 获取 Gemini 的 finishReason
|
| 1322 |
+
gemini_finish_reason = candidate.get("finishReason")
|
| 1323 |
+
|
| 1324 |
+
# 如果有工具调用
|
| 1325 |
+
if tool_calls:
|
| 1326 |
+
message["tool_calls"] = tool_calls
|
| 1327 |
+
message["content"] = text_content if text_content else None
|
| 1328 |
+
# 只有在正常停止(STOP)时才设为 tool_calls,其他情况保持原始 finish_reason
|
| 1329 |
+
# 这样可以避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环
|
| 1330 |
+
if gemini_finish_reason == "STOP":
|
| 1331 |
+
finish_reason = "tool_calls"
|
| 1332 |
+
else:
|
| 1333 |
+
finish_reason = _map_finish_reason(gemini_finish_reason)
|
| 1334 |
+
else:
|
| 1335 |
+
message["content"] = text_content
|
| 1336 |
+
finish_reason = _map_finish_reason(gemini_finish_reason)
|
| 1337 |
+
|
| 1338 |
+
# 添加 reasoning content (如果有)
|
| 1339 |
+
if reasoning_content:
|
| 1340 |
+
message["reasoning_content"] = reasoning_content
|
| 1341 |
+
|
| 1342 |
+
choices.append({
|
| 1343 |
+
"index": candidate.get("index", 0),
|
| 1344 |
+
"message": message,
|
| 1345 |
+
"finish_reason": finish_reason,
|
| 1346 |
+
})
|
| 1347 |
+
|
| 1348 |
+
# 转换 usageMetadata
|
| 1349 |
+
usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
|
| 1350 |
+
|
| 1351 |
+
response_data = {
|
| 1352 |
+
"id": str(uuid.uuid4()),
|
| 1353 |
+
"object": "chat.completion",
|
| 1354 |
+
"created": int(time.time()),
|
| 1355 |
+
"model": model,
|
| 1356 |
+
"choices": choices,
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
if usage:
|
| 1360 |
+
response_data["usage"] = usage
|
| 1361 |
+
|
| 1362 |
+
return response_data
|
| 1363 |
+
|
| 1364 |
+
|
| 1365 |
+
def convert_gemini_to_openai_stream(
|
| 1366 |
+
gemini_stream_chunk: str,
|
| 1367 |
+
model: str,
|
| 1368 |
+
response_id: str,
|
| 1369 |
+
status_code: int = 200
|
| 1370 |
+
) -> Optional[str]:
|
| 1371 |
+
"""
|
| 1372 |
+
将 Gemini 格式流式响应块转换为 OpenAI SSE 格式流式响应
|
| 1373 |
+
|
| 1374 |
+
注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始内容
|
| 1375 |
+
|
| 1376 |
+
Args:
|
| 1377 |
+
gemini_stream_chunk: Gemini 格式的流式响应块 (字符串,通常是 "data: {json}" 格式)
|
| 1378 |
+
model: 模型名称
|
| 1379 |
+
response_id: 此流式响应的一致ID
|
| 1380 |
+
status_code: HTTP 状态码 (默认 200)
|
| 1381 |
+
|
| 1382 |
+
Returns:
|
| 1383 |
+
OpenAI SSE 格式的响应字符串 (如 "data: {json}\n\n"),
|
| 1384 |
+
或原始内容 (如果状态码不是 2xx),
|
| 1385 |
+
或 None (如果解析失败)
|
| 1386 |
+
"""
|
| 1387 |
+
# 非 2xx 状态码直接返回原始内容
|
| 1388 |
+
if not (200 <= status_code < 300):
|
| 1389 |
+
return gemini_stream_chunk
|
| 1390 |
+
|
| 1391 |
+
# 解析 Gemini 流式块
|
| 1392 |
+
try:
|
| 1393 |
+
# 去除 "data: " 前缀
|
| 1394 |
+
if isinstance(gemini_stream_chunk, bytes):
|
| 1395 |
+
if gemini_stream_chunk.startswith(b"data: "):
|
| 1396 |
+
payload_str = gemini_stream_chunk[len(b"data: "):].strip().decode("utf-8")
|
| 1397 |
+
else:
|
| 1398 |
+
payload_str = gemini_stream_chunk.strip().decode("utf-8")
|
| 1399 |
+
else:
|
| 1400 |
+
if gemini_stream_chunk.startswith("data: "):
|
| 1401 |
+
payload_str = gemini_stream_chunk[len("data: "):].strip()
|
| 1402 |
+
else:
|
| 1403 |
+
payload_str = gemini_stream_chunk.strip()
|
| 1404 |
+
|
| 1405 |
+
# 跳过空块
|
| 1406 |
+
if not payload_str:
|
| 1407 |
+
return None
|
| 1408 |
+
|
| 1409 |
+
# 解析 JSON
|
| 1410 |
+
gemini_chunk = json.loads(payload_str)
|
| 1411 |
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
| 1412 |
+
# 解析失败,跳过此块
|
| 1413 |
+
return None
|
| 1414 |
+
|
| 1415 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 1416 |
+
if "response" in gemini_chunk:
|
| 1417 |
+
gemini_response = gemini_chunk["response"]
|
| 1418 |
+
else:
|
| 1419 |
+
gemini_response = gemini_chunk
|
| 1420 |
+
|
| 1421 |
+
# 转换为 OpenAI 流式格式
|
| 1422 |
+
choices = []
|
| 1423 |
+
|
| 1424 |
+
for candidate in gemini_response.get("candidates", []):
|
| 1425 |
+
role = candidate.get("content", {}).get("role", "assistant")
|
| 1426 |
+
|
| 1427 |
+
# 将Gemini角色映射回OpenAI角色
|
| 1428 |
+
if role == "model":
|
| 1429 |
+
role = "assistant"
|
| 1430 |
+
|
| 1431 |
+
# 提取并分离thinking tokens和常规内容
|
| 1432 |
+
parts = candidate.get("content", {}).get("parts", [])
|
| 1433 |
+
|
| 1434 |
+
# 提取工具调用和文本内容 (流式需要 index)
|
| 1435 |
+
tool_calls, text_content = extract_tool_calls_from_parts(parts, is_streaming=True)
|
| 1436 |
+
|
| 1437 |
+
# 提取多种类型的内容
|
| 1438 |
+
content_parts = []
|
| 1439 |
+
reasoning_parts = []
|
| 1440 |
+
|
| 1441 |
+
for part in parts:
|
| 1442 |
+
# 处理 executableCode(代码生成)
|
| 1443 |
+
if "executableCode" in part:
|
| 1444 |
+
exec_code = part["executableCode"]
|
| 1445 |
+
lang = exec_code.get("language", "python").lower()
|
| 1446 |
+
code = exec_code.get("code", "")
|
| 1447 |
+
content_parts.append(f"\n```{lang}\n{code}\n```\n")
|
| 1448 |
+
|
| 1449 |
+
# 处理 codeExecutionResult(代码执行结果)
|
| 1450 |
+
elif "codeExecutionResult" in part:
|
| 1451 |
+
result = part["codeExecutionResult"]
|
| 1452 |
+
outcome = result.get("outcome")
|
| 1453 |
+
output = result.get("output", "")
|
| 1454 |
+
|
| 1455 |
+
if output:
|
| 1456 |
+
label = "output" if outcome == "OUTCOME_OK" else "error"
|
| 1457 |
+
content_parts.append(f"\n```{label}\n{output}\n```\n")
|
| 1458 |
+
|
| 1459 |
+
# 处理 thought(思考内容)
|
| 1460 |
+
elif part.get("thought", False) and "text" in part:
|
| 1461 |
+
reasoning_parts.append(part["text"])
|
| 1462 |
+
|
| 1463 |
+
# 处理普通文本(非思考内容)
|
| 1464 |
+
elif "text" in part and not part.get("thought", False):
|
| 1465 |
+
# 这部分已经在 extract_tool_calls_from_parts 中处理
|
| 1466 |
+
pass
|
| 1467 |
+
|
| 1468 |
+
# 处理 inlineData(图片)
|
| 1469 |
+
elif "inlineData" in part:
|
| 1470 |
+
inline_data = part["inlineData"]
|
| 1471 |
+
mime_type = inline_data.get("mimeType", "image/png")
|
| 1472 |
+
base64_data = inline_data.get("data", "")
|
| 1473 |
+
content_parts.append(f"")
|
| 1474 |
+
|
| 1475 |
+
# 合并所有内容部分
|
| 1476 |
+
if content_parts:
|
| 1477 |
+
additional_content = "\n\n".join(content_parts)
|
| 1478 |
+
if text_content:
|
| 1479 |
+
text_content = text_content + "\n\n" + additional_content
|
| 1480 |
+
else:
|
| 1481 |
+
text_content = additional_content
|
| 1482 |
+
|
| 1483 |
+
# 合并 reasoning content
|
| 1484 |
+
reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else ""
|
| 1485 |
+
|
| 1486 |
+
# 构建 delta 对象
|
| 1487 |
+
delta = {}
|
| 1488 |
+
|
| 1489 |
+
if tool_calls:
|
| 1490 |
+
delta["tool_calls"] = tool_calls
|
| 1491 |
+
if text_content:
|
| 1492 |
+
delta["content"] = text_content
|
| 1493 |
+
elif text_content:
|
| 1494 |
+
delta["content"] = text_content
|
| 1495 |
+
|
| 1496 |
+
if reasoning_content:
|
| 1497 |
+
delta["reasoning_content"] = reasoning_content
|
| 1498 |
+
|
| 1499 |
+
# 获取 Gemini 的 finishReason
|
| 1500 |
+
gemini_finish_reason = candidate.get("finishReason")
|
| 1501 |
+
finish_reason = _map_finish_reason(gemini_finish_reason)
|
| 1502 |
+
|
| 1503 |
+
# 只有在正常停止(STOP)且有工具调用时才设为 tool_calls
|
| 1504 |
+
# 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环
|
| 1505 |
+
if tool_calls and gemini_finish_reason == "STOP":
|
| 1506 |
+
finish_reason = "tool_calls"
|
| 1507 |
+
|
| 1508 |
+
choices.append({
|
| 1509 |
+
"index": candidate.get("index", 0),
|
| 1510 |
+
"delta": delta,
|
| 1511 |
+
"finish_reason": finish_reason,
|
| 1512 |
+
})
|
| 1513 |
+
|
| 1514 |
+
# 转换 usageMetadata (只在流结束时存在)
|
| 1515 |
+
usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
|
| 1516 |
+
|
| 1517 |
+
# 构建 OpenAI 流式响应
|
| 1518 |
+
response_data = {
|
| 1519 |
+
"id": response_id,
|
| 1520 |
+
"object": "chat.completion.chunk",
|
| 1521 |
+
"created": int(time.time()),
|
| 1522 |
+
"model": model,
|
| 1523 |
+
"choices": choices,
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
# 只在有 usage 数据且有 finish_reason 时添加 usage
|
| 1527 |
+
if usage:
|
| 1528 |
+
has_finish_reason = any(choice.get("finish_reason") for choice in choices)
|
| 1529 |
+
if has_finish_reason:
|
| 1530 |
+
response_data["usage"] = usage
|
| 1531 |
+
|
| 1532 |
+
# 转换为 SSE 格式: "data: {json}\n\n"
|
| 1533 |
+
return f"data: {json.dumps(response_data)}\n\n"
|
src/converter/thoughtSignature_fix.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
thoughtSignature 处理公共模块
|
| 3 |
+
|
| 4 |
+
提供统一的 thoughtSignature 编码/解码功能,用于在工具调用ID中保留签名信息。
|
| 5 |
+
这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段。
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Optional, Tuple
|
| 9 |
+
|
| 10 |
+
# 在工具调用ID中嵌入thoughtSignature的分隔符
|
| 11 |
+
# 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段
|
| 12 |
+
THOUGHT_SIGNATURE_SEPARATOR = "__thought__"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def encode_tool_id_with_signature(tool_id: str, signature: Optional[str]) -> str:
|
| 16 |
+
"""
|
| 17 |
+
将 thoughtSignature 编码到工具调用ID中,以便往返保留。
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
tool_id: 原始工具调用ID
|
| 21 |
+
signature: thoughtSignature(可选)
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
编码后的工具调用ID
|
| 25 |
+
|
| 26 |
+
Examples:
|
| 27 |
+
>>> encode_tool_id_with_signature("call_123", "abc")
|
| 28 |
+
'call_123__thought__abc'
|
| 29 |
+
>>> encode_tool_id_with_signature("call_123", None)
|
| 30 |
+
'call_123'
|
| 31 |
+
"""
|
| 32 |
+
if not signature:
|
| 33 |
+
return tool_id
|
| 34 |
+
return f"{tool_id}{THOUGHT_SIGNATURE_SEPARATOR}{signature}"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def decode_tool_id_and_signature(encoded_id: str) -> Tuple[str, Optional[str]]:
|
| 38 |
+
"""
|
| 39 |
+
从编码的ID中提取原始工具ID和thoughtSignature。
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
encoded_id: 编码的工具调用ID
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
(原始工具ID, thoughtSignature) 元组
|
| 46 |
+
|
| 47 |
+
Examples:
|
| 48 |
+
>>> decode_tool_id_and_signature("call_123__thought__abc")
|
| 49 |
+
('call_123', 'abc')
|
| 50 |
+
>>> decode_tool_id_and_signature("call_123")
|
| 51 |
+
('call_123', None)
|
| 52 |
+
"""
|
| 53 |
+
if not encoded_id or THOUGHT_SIGNATURE_SEPARATOR not in encoded_id:
|
| 54 |
+
return encoded_id, None
|
| 55 |
+
parts = encoded_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
|
| 56 |
+
return parts[0], parts[1] if len(parts) == 2 else None
|
src/converter/utils.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def extract_content_and_reasoning(parts: list) -> tuple:
|
| 5 |
+
"""从Gemini响应部件中提取内容和推理内容
|
| 6 |
+
|
| 7 |
+
Args:
|
| 8 |
+
parts: Gemini 响应中的 parts 列表
|
| 9 |
+
|
| 10 |
+
Returns:
|
| 11 |
+
(content, reasoning_content, images): 文本内容、推理内容和图片数据的元组
|
| 12 |
+
- content: 文本内容字符串
|
| 13 |
+
- reasoning_content: 推理内容字符串
|
| 14 |
+
- images: 图片数据列表,每个元素格式为:
|
| 15 |
+
{
|
| 16 |
+
"type": "image_url",
|
| 17 |
+
"image_url": {
|
| 18 |
+
"url": "data:{mime_type};base64,{base64_data}"
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
"""
|
| 22 |
+
content = ""
|
| 23 |
+
reasoning_content = ""
|
| 24 |
+
images = []
|
| 25 |
+
|
| 26 |
+
for part in parts:
|
| 27 |
+
# 提取文本内容
|
| 28 |
+
text = part.get("text", "")
|
| 29 |
+
if text:
|
| 30 |
+
if part.get("thought", False):
|
| 31 |
+
reasoning_content += text
|
| 32 |
+
else:
|
| 33 |
+
content += text
|
| 34 |
+
|
| 35 |
+
# 提取图片数据
|
| 36 |
+
if "inlineData" in part:
|
| 37 |
+
inline_data = part["inlineData"]
|
| 38 |
+
mime_type = inline_data.get("mimeType", "image/png")
|
| 39 |
+
base64_data = inline_data.get("data", "")
|
| 40 |
+
images.append({
|
| 41 |
+
"type": "image_url",
|
| 42 |
+
"image_url": {
|
| 43 |
+
"url": f"data:{mime_type};base64,{base64_data}"
|
| 44 |
+
}
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
return content, reasoning_content, images
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def merge_system_messages(request_body: Dict[str, Any]) -> Dict[str, Any]:
|
| 51 |
+
"""
|
| 52 |
+
根据兼容性模式处理请求体中的system消息
|
| 53 |
+
|
| 54 |
+
- 兼容性模式关闭(False):将连续的system消息合并为systemInstruction
|
| 55 |
+
- 兼容性模式开启(True):将所有system消息转换为user消息
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
request_body: OpenAI或Claude格式的请求体,包含messages字段
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
处理后的请求体
|
| 62 |
+
|
| 63 |
+
Example (兼容性模式关闭):
|
| 64 |
+
输入:
|
| 65 |
+
{
|
| 66 |
+
"messages": [
|
| 67 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 68 |
+
{"role": "system", "content": "You are an expert in Python."},
|
| 69 |
+
{"role": "user", "content": "Hello"}
|
| 70 |
+
]
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
输出:
|
| 74 |
+
{
|
| 75 |
+
"systemInstruction": {
|
| 76 |
+
"parts": [
|
| 77 |
+
{"text": "You are a helpful assistant."},
|
| 78 |
+
{"text": "You are an expert in Python."}
|
| 79 |
+
]
|
| 80 |
+
},
|
| 81 |
+
"messages": [
|
| 82 |
+
{"role": "user", "content": "Hello"}
|
| 83 |
+
]
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
Example (兼容性模式开启):
|
| 87 |
+
输入:
|
| 88 |
+
{
|
| 89 |
+
"messages": [
|
| 90 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 91 |
+
{"role": "user", "content": "Hello"}
|
| 92 |
+
]
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
输出:
|
| 96 |
+
{
|
| 97 |
+
"messages": [
|
| 98 |
+
{"role": "user", "content": "You are a helpful assistant."},
|
| 99 |
+
{"role": "user", "content": "Hello"}
|
| 100 |
+
]
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
Example (Anthropic格式,兼容性模式关闭):
|
| 104 |
+
输入:
|
| 105 |
+
{
|
| 106 |
+
"system": "You are a helpful assistant.",
|
| 107 |
+
"messages": [
|
| 108 |
+
{"role": "user", "content": "Hello"}
|
| 109 |
+
]
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
输出:
|
| 113 |
+
{
|
| 114 |
+
"systemInstruction": {
|
| 115 |
+
"parts": [
|
| 116 |
+
{"text": "You are a helpful assistant."}
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
"messages": [
|
| 120 |
+
{"role": "user", "content": "Hello"}
|
| 121 |
+
]
|
| 122 |
+
}
|
| 123 |
+
"""
|
| 124 |
+
from config import get_compatibility_mode_enabled
|
| 125 |
+
|
| 126 |
+
compatibility_mode = await get_compatibility_mode_enabled()
|
| 127 |
+
|
| 128 |
+
# 处理 Anthropic 格式的顶层 system 参数
|
| 129 |
+
# Anthropic API 规范: system 是顶层参数,不在 messages 中
|
| 130 |
+
system_content = request_body.get("system")
|
| 131 |
+
if system_content:
|
| 132 |
+
system_parts = []
|
| 133 |
+
|
| 134 |
+
if isinstance(system_content, str):
|
| 135 |
+
if system_content.strip():
|
| 136 |
+
system_parts.append({"text": system_content})
|
| 137 |
+
elif isinstance(system_content, list):
|
| 138 |
+
# system 可以是包含多个块的列表
|
| 139 |
+
for item in system_content:
|
| 140 |
+
if isinstance(item, dict):
|
| 141 |
+
if item.get("type") == "text" and item.get("text", "").strip():
|
| 142 |
+
system_parts.append({"text": item["text"]})
|
| 143 |
+
elif isinstance(item, str) and item.strip():
|
| 144 |
+
system_parts.append({"text": item})
|
| 145 |
+
|
| 146 |
+
if system_parts:
|
| 147 |
+
if compatibility_mode:
|
| 148 |
+
# 兼容性模式:将 system 转换为 user 消息插入到 messages 开头
|
| 149 |
+
user_system_message = {
|
| 150 |
+
"role": "user",
|
| 151 |
+
"content": system_content if isinstance(system_content, str) else
|
| 152 |
+
"\n".join(part["text"] for part in system_parts)
|
| 153 |
+
}
|
| 154 |
+
messages = request_body.get("messages", [])
|
| 155 |
+
request_body = request_body.copy()
|
| 156 |
+
request_body["messages"] = [user_system_message] + messages
|
| 157 |
+
else:
|
| 158 |
+
# 非兼容性模式:添加为 systemInstruction
|
| 159 |
+
request_body = request_body.copy()
|
| 160 |
+
request_body["systemInstruction"] = {"parts": system_parts}
|
| 161 |
+
|
| 162 |
+
messages = request_body.get("messages", [])
|
| 163 |
+
if not messages:
|
| 164 |
+
return request_body
|
| 165 |
+
|
| 166 |
+
compatibility_mode = await get_compatibility_mode_enabled()
|
| 167 |
+
|
| 168 |
+
if compatibility_mode:
|
| 169 |
+
# 兼容性模式开启:将所有system消息转换为user消息
|
| 170 |
+
converted_messages = []
|
| 171 |
+
for message in messages:
|
| 172 |
+
if message.get("role") == "system":
|
| 173 |
+
# 创建新的消息对象,将role改为user
|
| 174 |
+
converted_message = message.copy()
|
| 175 |
+
converted_message["role"] = "user"
|
| 176 |
+
converted_messages.append(converted_message)
|
| 177 |
+
else:
|
| 178 |
+
converted_messages.append(message)
|
| 179 |
+
|
| 180 |
+
result = request_body.copy()
|
| 181 |
+
result["messages"] = converted_messages
|
| 182 |
+
return result
|
| 183 |
+
else:
|
| 184 |
+
# 兼容性模式关闭:提取连续的system消息合并为systemInstruction
|
| 185 |
+
system_parts = []
|
| 186 |
+
|
| 187 |
+
# 如果已经从顶层 system 参数创建了 systemInstruction,获取现有的 parts
|
| 188 |
+
if "systemInstruction" in request_body:
|
| 189 |
+
existing_instruction = request_body.get("systemInstruction", {})
|
| 190 |
+
if isinstance(existing_instruction, dict):
|
| 191 |
+
system_parts = existing_instruction.get("parts", []).copy()
|
| 192 |
+
|
| 193 |
+
remaining_messages = []
|
| 194 |
+
collecting_system = True
|
| 195 |
+
|
| 196 |
+
for message in messages:
|
| 197 |
+
role = message.get("role", "")
|
| 198 |
+
content = message.get("content", "")
|
| 199 |
+
|
| 200 |
+
if role == "system" and collecting_system:
|
| 201 |
+
# 提取system消息的文本内容
|
| 202 |
+
if isinstance(content, str):
|
| 203 |
+
if content.strip():
|
| 204 |
+
system_parts.append({"text": content})
|
| 205 |
+
elif isinstance(content, list):
|
| 206 |
+
# 处理列表格式的content
|
| 207 |
+
for item in content:
|
| 208 |
+
if isinstance(item, dict):
|
| 209 |
+
if item.get("type") == "text" and item.get("text", "").strip():
|
| 210 |
+
system_parts.append({"text": item["text"]})
|
| 211 |
+
elif isinstance(item, str) and item.strip():
|
| 212 |
+
system_parts.append({"text": item})
|
| 213 |
+
else:
|
| 214 |
+
# 遇到非system消息,停止收集
|
| 215 |
+
collecting_system = False
|
| 216 |
+
if role == "system":
|
| 217 |
+
# 将后续的system消息转换为user消息
|
| 218 |
+
converted_message = message.copy()
|
| 219 |
+
converted_message["role"] = "user"
|
| 220 |
+
remaining_messages.append(converted_message)
|
| 221 |
+
else:
|
| 222 |
+
remaining_messages.append(message)
|
| 223 |
+
|
| 224 |
+
# 如果没有找到任何system消息(包括顶层参数和messages中的),返回原始请求体
|
| 225 |
+
if not system_parts:
|
| 226 |
+
return request_body
|
| 227 |
+
|
| 228 |
+
# 构建新的请求体
|
| 229 |
+
result = request_body.copy()
|
| 230 |
+
|
| 231 |
+
# 添加或更新systemInstruction
|
| 232 |
+
result["systemInstruction"] = {"parts": system_parts}
|
| 233 |
+
|
| 234 |
+
# 更新messages列表(移除已处理的system消息)
|
| 235 |
+
result["messages"] = remaining_messages
|
| 236 |
+
|
| 237 |
+
return result
|
src/credential_manager.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
凭证管理器
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import time
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 9 |
+
|
| 10 |
+
from log import log
|
| 11 |
+
|
| 12 |
+
from src.google_oauth_api import Credentials
|
| 13 |
+
from src.storage_adapter import get_storage_adapter
|
| 14 |
+
|
| 15 |
+
class CredentialManager:
|
| 16 |
+
"""
|
| 17 |
+
统一凭证管理器
|
| 18 |
+
所有存储操作通过storage_adapter进行
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
# 核心状态
|
| 23 |
+
self._initialized = False
|
| 24 |
+
self._storage_adapter = None
|
| 25 |
+
|
| 26 |
+
# 并发控制(简化)
|
| 27 |
+
# 后端数据库自行处理并发,credential_manager 不再使用本地锁
|
| 28 |
+
|
| 29 |
+
async def _ensure_initialized(self):
|
| 30 |
+
"""确保管理器已初始化(内部使用)"""
|
| 31 |
+
if not self._initialized or self._storage_adapter is None:
|
| 32 |
+
await self.initialize()
|
| 33 |
+
|
| 34 |
+
async def initialize(self):
|
| 35 |
+
"""初始化凭证管理器"""
|
| 36 |
+
if self._initialized and self._storage_adapter is not None:
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
# 初始化统一存储适配器
|
| 40 |
+
self._storage_adapter = await get_storage_adapter()
|
| 41 |
+
self._initialized = True
|
| 42 |
+
|
| 43 |
+
async def close(self):
|
| 44 |
+
"""清理资源"""
|
| 45 |
+
log.debug("Closing credential manager...")
|
| 46 |
+
self._initialized = False
|
| 47 |
+
log.debug("Credential manager closed")
|
| 48 |
+
|
| 49 |
+
async def get_valid_credential(
|
| 50 |
+
self, mode: str = "geminicli", model_name: Optional[str] = None
|
| 51 |
+
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
| 52 |
+
"""
|
| 53 |
+
获取有效的凭证 - 随机负载均衡版
|
| 54 |
+
每次随机选择一个可用的凭证(未禁用、未冷却、符合preview要求)
|
| 55 |
+
如果刷新失败会自动禁用失效凭证并重试获取下一个可用凭证
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
mode: 凭证模式 ("geminicli" 或 "antigravity")
|
| 59 |
+
model_name: 完整模型名,用于模型级冷却检查和preview筛选
|
| 60 |
+
- geminicli: 完整模型名
|
| 61 |
+
- 包含 "preview" 的模型只能使用 preview=True 的凭证
|
| 62 |
+
- 不包含 "preview" 的模型优先使用 preview=False 的凭证
|
| 63 |
+
- antigravity: 完整模型名(如 "gemini-2.0-flash-exp")
|
| 64 |
+
"""
|
| 65 |
+
await self._ensure_initialized()
|
| 66 |
+
|
| 67 |
+
# 最多重试3次
|
| 68 |
+
max_retries = 3
|
| 69 |
+
for attempt in range(max_retries):
|
| 70 |
+
result = await self._storage_adapter._backend.get_next_available_credential(
|
| 71 |
+
mode=mode, model_name=model_name
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# 如果没有可用凭证,直接返回None
|
| 75 |
+
if not result:
|
| 76 |
+
if attempt == 0:
|
| 77 |
+
log.warning(f"没有可用凭证 (mode={mode}, model_name={model_name})")
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
filename, credential_data = result
|
| 81 |
+
|
| 82 |
+
# Token 刷新检查
|
| 83 |
+
if await self._should_refresh_token(credential_data):
|
| 84 |
+
log.debug(f"Token需要刷新 - 文件: {filename} (mode={mode})")
|
| 85 |
+
refreshed_data = await self._refresh_token(credential_data, filename, mode=mode)
|
| 86 |
+
if refreshed_data:
|
| 87 |
+
# 刷新成功,返回凭证
|
| 88 |
+
credential_data = refreshed_data
|
| 89 |
+
log.debug(f"Token刷新成功: {filename} (mode={mode})")
|
| 90 |
+
return filename, credential_data
|
| 91 |
+
else:
|
| 92 |
+
# 刷新失败(_refresh_token内部已自动禁用失效凭证)
|
| 93 |
+
log.warning(f"Token刷新失败,尝试获取下一个凭证: {filename} (mode={mode}, attempt={attempt+1}/{max_retries})")
|
| 94 |
+
# 继续循环,尝试获取下一个可用凭证
|
| 95 |
+
continue
|
| 96 |
+
else:
|
| 97 |
+
# Token有效,直接返回
|
| 98 |
+
return filename, credential_data
|
| 99 |
+
|
| 100 |
+
# 重试次数用尽
|
| 101 |
+
log.error(f"重试{max_retries}次后仍无可用凭证 (mode={mode}, model_name={model_name})")
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
async def add_credential(self, credential_name: str, credential_data: Dict[str, Any]):
|
| 105 |
+
"""
|
| 106 |
+
新增或更新一个凭证
|
| 107 |
+
存储层会自动处理轮换顺序
|
| 108 |
+
"""
|
| 109 |
+
await self._ensure_initialized()
|
| 110 |
+
await self._storage_adapter.store_credential(credential_name, credential_data)
|
| 111 |
+
log.info(f"Credential added/updated: {credential_name}")
|
| 112 |
+
|
| 113 |
+
async def add_antigravity_credential(self, credential_name: str, credential_data: Dict[str, Any]):
|
| 114 |
+
"""
|
| 115 |
+
新增或更新一个Antigravity凭证
|
| 116 |
+
存储层会自动处理轮换顺序
|
| 117 |
+
"""
|
| 118 |
+
await self._ensure_initialized()
|
| 119 |
+
await self._storage_adapter.store_credential(credential_name, credential_data, mode="antigravity")
|
| 120 |
+
log.info(f"Antigravity credential added/updated: {credential_name}")
|
| 121 |
+
|
| 122 |
+
async def remove_credential(self, credential_name: str, mode: str = "geminicli") -> bool:
|
| 123 |
+
"""删除一个凭证"""
|
| 124 |
+
await self._ensure_initialized()
|
| 125 |
+
try:
|
| 126 |
+
await self._storage_adapter.delete_credential(credential_name, mode=mode)
|
| 127 |
+
log.info(f"Credential removed: {credential_name} (mode={mode})")
|
| 128 |
+
return True
|
| 129 |
+
except Exception as e:
|
| 130 |
+
log.error(f"Error removing credential {credential_name}: {e}")
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
async def update_credential_state(self, credential_name: str, state_updates: Dict[str, Any], mode: str = "geminicli"):
|
| 134 |
+
"""更新凭证状态"""
|
| 135 |
+
log.debug(f"[CredMgr] update_credential_state 开始: credential_name={credential_name}, state_updates={state_updates}, mode={mode}")
|
| 136 |
+
log.debug(f"[CredMgr] 调用 _ensure_initialized...")
|
| 137 |
+
await self._ensure_initialized()
|
| 138 |
+
log.debug(f"[CredMgr] _ensure_initialized 完成")
|
| 139 |
+
try:
|
| 140 |
+
log.debug(f"[CredMgr] 调用 storage_adapter.update_credential_state...")
|
| 141 |
+
success = await self._storage_adapter.update_credential_state(
|
| 142 |
+
credential_name, state_updates, mode=mode
|
| 143 |
+
)
|
| 144 |
+
log.debug(f"[CredMgr] storage_adapter.update_credential_state 返回: {success}")
|
| 145 |
+
if success:
|
| 146 |
+
log.debug(f"Updated credential state: {credential_name} (mode={mode})")
|
| 147 |
+
else:
|
| 148 |
+
log.warning(f"Failed to update credential state: {credential_name} (mode={mode})")
|
| 149 |
+
return success
|
| 150 |
+
except Exception as e:
|
| 151 |
+
log.error(f"Error updating credential state {credential_name}: {e}")
|
| 152 |
+
return False
|
| 153 |
+
|
| 154 |
+
async def set_cred_disabled(self, credential_name: str, disabled: bool, mode: str = "geminicli"):
|
| 155 |
+
"""设置凭证的启用/禁用状态"""
|
| 156 |
+
try:
|
| 157 |
+
log.info(f"[CredMgr] set_cred_disabled 开始: credential_name={credential_name}, disabled={disabled}, mode={mode}")
|
| 158 |
+
success = await self.update_credential_state(
|
| 159 |
+
credential_name, {"disabled": disabled}, mode=mode
|
| 160 |
+
)
|
| 161 |
+
log.info(f"[CredMgr] update_credential_state 返回: success={success}")
|
| 162 |
+
if success:
|
| 163 |
+
action = "disabled" if disabled else "enabled"
|
| 164 |
+
log.info(f"Credential {action}: {credential_name} (mode={mode})")
|
| 165 |
+
else:
|
| 166 |
+
log.warning(f"[CredMgr] 设置禁用状态失败: credential_name={credential_name}, disabled={disabled}")
|
| 167 |
+
return success
|
| 168 |
+
except Exception as e:
|
| 169 |
+
log.error(f"Error setting credential disabled state {credential_name}: {e}")
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
async def get_creds_status(self) -> Dict[str, Dict[str, Any]]:
|
| 173 |
+
"""获取所有凭证的状态"""
|
| 174 |
+
await self._ensure_initialized()
|
| 175 |
+
try:
|
| 176 |
+
return await self._storage_adapter.get_all_credential_states()
|
| 177 |
+
except Exception as e:
|
| 178 |
+
log.error(f"Error getting credential statuses: {e}")
|
| 179 |
+
return {}
|
| 180 |
+
|
| 181 |
+
async def get_creds_summary(self) -> List[Dict[str, Any]]:
|
| 182 |
+
"""
|
| 183 |
+
获取所有凭证的摘要信息(轻量级,不包含完整凭证数据)
|
| 184 |
+
使用后端的高性能查询
|
| 185 |
+
"""
|
| 186 |
+
await self._ensure_initialized()
|
| 187 |
+
try:
|
| 188 |
+
return await self._storage_adapter._backend.get_credentials_summary()
|
| 189 |
+
except Exception as e:
|
| 190 |
+
log.error(f"Error getting credentials summary: {e}")
|
| 191 |
+
return []
|
| 192 |
+
|
| 193 |
+
async def get_or_fetch_user_email(self, credential_name: str, mode: str = "geminicli") -> Optional[str]:
|
| 194 |
+
"""获取或获取用户邮箱地址"""
|
| 195 |
+
try:
|
| 196 |
+
# 确保已初始化
|
| 197 |
+
await self._ensure_initialized()
|
| 198 |
+
|
| 199 |
+
# 从状态中获取缓存的邮箱
|
| 200 |
+
state = await self._storage_adapter.get_credential_state(credential_name, mode=mode)
|
| 201 |
+
cached_email = state.get("user_email") if state else None
|
| 202 |
+
|
| 203 |
+
if cached_email:
|
| 204 |
+
return cached_email
|
| 205 |
+
|
| 206 |
+
# 如果没有缓存,从凭证数据获取
|
| 207 |
+
credential_data = await self._storage_adapter.get_credential(credential_name, mode=mode)
|
| 208 |
+
if not credential_data:
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
# 创建凭证对象并自动刷新 token
|
| 212 |
+
from .google_oauth_api import Credentials, get_user_email
|
| 213 |
+
|
| 214 |
+
credentials = Credentials.from_dict(credential_data)
|
| 215 |
+
if not credentials:
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
# 自动刷新 token(如果需要)
|
| 219 |
+
token_refreshed = await credentials.refresh_if_needed()
|
| 220 |
+
|
| 221 |
+
# 如果 token 被刷新了,更新存储
|
| 222 |
+
if token_refreshed:
|
| 223 |
+
log.info(f"Token已自动刷新: {credential_name} (mode={mode})")
|
| 224 |
+
updated_data = credentials.to_dict()
|
| 225 |
+
await self._storage_adapter.store_credential(credential_name, updated_data, mode=mode)
|
| 226 |
+
|
| 227 |
+
# 获取邮箱
|
| 228 |
+
email = await get_user_email(credentials)
|
| 229 |
+
|
| 230 |
+
if email:
|
| 231 |
+
# 缓存邮箱地址
|
| 232 |
+
await self._storage_adapter.update_credential_state(
|
| 233 |
+
credential_name, {"user_email": email}, mode=mode
|
| 234 |
+
)
|
| 235 |
+
return email
|
| 236 |
+
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
log.error(f"Error fetching user email for {credential_name}: {e}")
|
| 241 |
+
return None
|
| 242 |
+
|
| 243 |
+
async def record_api_call_result(
|
| 244 |
+
self,
|
| 245 |
+
credential_name: str,
|
| 246 |
+
success: bool,
|
| 247 |
+
error_code: Optional[int] = None,
|
| 248 |
+
cooldown_until: Optional[float] = None,
|
| 249 |
+
mode: str = "geminicli",
|
| 250 |
+
model_name: Optional[str] = None,
|
| 251 |
+
error_message: Optional[str] = None
|
| 252 |
+
):
|
| 253 |
+
"""
|
| 254 |
+
记录API调用结果
|
| 255 |
+
|
| 256 |
+
Args:
|
| 257 |
+
credential_name: 凭证名称
|
| 258 |
+
success: 是否成功
|
| 259 |
+
error_code: 错误码(如果失败)
|
| 260 |
+
cooldown_until: 冷却截止时间戳(Unix时间戳,针对429 QUOTA_EXHAUSTED)
|
| 261 |
+
mode: 凭证模式 ("geminicli" 或 "antigravity")
|
| 262 |
+
model_name: 模型名(用于设置模型级冷却)
|
| 263 |
+
error_message: 错误信息(如果失败)
|
| 264 |
+
"""
|
| 265 |
+
await self._ensure_initialized()
|
| 266 |
+
try:
|
| 267 |
+
if success:
|
| 268 |
+
# 条件写入:仅当凭证有错误状态或模型冷却时才写 DB,零内存缓存
|
| 269 |
+
# fire-and-forget,不阻塞请求链路
|
| 270 |
+
asyncio.create_task(
|
| 271 |
+
self._storage_adapter._backend.record_success(
|
| 272 |
+
credential_name, model_name=model_name, mode=mode
|
| 273 |
+
)
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
elif error_code:
|
| 277 |
+
# 记录错误码和错误信息
|
| 278 |
+
error_messages = {}
|
| 279 |
+
if error_message:
|
| 280 |
+
error_messages[str(error_code)] = error_message
|
| 281 |
+
|
| 282 |
+
state_updates = {
|
| 283 |
+
"error_codes": [error_code],
|
| 284 |
+
"error_messages": error_messages,
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
await self.update_credential_state(credential_name, state_updates, mode=mode)
|
| 288 |
+
|
| 289 |
+
# 设置模型级冷却
|
| 290 |
+
if cooldown_until is not None and model_name:
|
| 291 |
+
if hasattr(self._storage_adapter._backend, 'set_model_cooldown'):
|
| 292 |
+
await self._storage_adapter._backend.set_model_cooldown(
|
| 293 |
+
credential_name, model_name, cooldown_until, mode=mode
|
| 294 |
+
)
|
| 295 |
+
log.info(
|
| 296 |
+
f"设置模型级冷却: {credential_name}, model_name={model_name}, "
|
| 297 |
+
f"冷却至: {datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
except Exception as e:
|
| 301 |
+
log.error(f"Error recording API call result for {credential_name}: {e}")
|
| 302 |
+
|
| 303 |
+
async def _should_refresh_token(self, credential_data: Dict[str, Any]) -> bool:
|
| 304 |
+
"""检查token是否需要刷新"""
|
| 305 |
+
try:
|
| 306 |
+
# 如果没有access_token或过期时间,需要刷新
|
| 307 |
+
if not credential_data.get("access_token") and not credential_data.get("token"):
|
| 308 |
+
log.debug("没有access_token,需要刷新")
|
| 309 |
+
return True
|
| 310 |
+
|
| 311 |
+
expiry_str = credential_data.get("expiry")
|
| 312 |
+
if not expiry_str:
|
| 313 |
+
log.debug("没有过期时间,需要刷新")
|
| 314 |
+
return True
|
| 315 |
+
|
| 316 |
+
# 解析过期时间
|
| 317 |
+
try:
|
| 318 |
+
if isinstance(expiry_str, str):
|
| 319 |
+
if "+" in expiry_str:
|
| 320 |
+
file_expiry = datetime.fromisoformat(expiry_str)
|
| 321 |
+
elif expiry_str.endswith("Z"):
|
| 322 |
+
file_expiry = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
|
| 323 |
+
else:
|
| 324 |
+
file_expiry = datetime.fromisoformat(expiry_str)
|
| 325 |
+
else:
|
| 326 |
+
log.debug("过期时间格式无效,需要刷新")
|
| 327 |
+
return True
|
| 328 |
+
|
| 329 |
+
# 确保时区信息
|
| 330 |
+
if file_expiry.tzinfo is None:
|
| 331 |
+
file_expiry = file_expiry.replace(tzinfo=timezone.utc)
|
| 332 |
+
|
| 333 |
+
# 检查是否还有至少5分钟有效期
|
| 334 |
+
now = datetime.now(timezone.utc)
|
| 335 |
+
time_left = (file_expiry - now).total_seconds()
|
| 336 |
+
|
| 337 |
+
log.debug(
|
| 338 |
+
f"Token时间检查: "
|
| 339 |
+
f"当前UTC时间={now.isoformat()}, "
|
| 340 |
+
f"过期时间={file_expiry.isoformat()}, "
|
| 341 |
+
f"剩余时间={int(time_left/60)}分{int(time_left%60)}秒"
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
if time_left > 300: # 5分钟缓冲
|
| 345 |
+
return False
|
| 346 |
+
else:
|
| 347 |
+
log.debug(f"Token即将过期(剩余{int(time_left/60)}分钟),需要刷新")
|
| 348 |
+
return True
|
| 349 |
+
|
| 350 |
+
except Exception as e:
|
| 351 |
+
log.warning(f"解析过期时间失败: {e},需要刷新")
|
| 352 |
+
return True
|
| 353 |
+
|
| 354 |
+
except Exception as e:
|
| 355 |
+
log.error(f"检查token过期时出错: {e}")
|
| 356 |
+
return True
|
| 357 |
+
|
| 358 |
+
async def _refresh_token(
|
| 359 |
+
self, credential_data: Dict[str, Any], filename: str, mode: str = "geminicli"
|
| 360 |
+
) -> Optional[Dict[str, Any]]:
|
| 361 |
+
"""刷新token并更新存储"""
|
| 362 |
+
await self._ensure_initialized()
|
| 363 |
+
try:
|
| 364 |
+
# 创建Credentials对象
|
| 365 |
+
creds = Credentials.from_dict(credential_data)
|
| 366 |
+
|
| 367 |
+
# 检查是否可以刷新
|
| 368 |
+
if not creds.refresh_token:
|
| 369 |
+
log.error(f"没有refresh_token,无法刷新: {filename} (mode={mode})")
|
| 370 |
+
# 自动禁用没有refresh_token的凭证
|
| 371 |
+
try:
|
| 372 |
+
await self.update_credential_state(filename, {"disabled": True}, mode=mode)
|
| 373 |
+
log.warning(f"凭证已自动禁用(缺少refresh_token): {filename}")
|
| 374 |
+
except Exception as e:
|
| 375 |
+
log.error(f"禁用凭证失败 {filename}: {e}")
|
| 376 |
+
return None
|
| 377 |
+
|
| 378 |
+
# 刷新token
|
| 379 |
+
log.debug(f"正在刷新token: {filename} (mode={mode})")
|
| 380 |
+
await creds.refresh()
|
| 381 |
+
|
| 382 |
+
# 更新凭证数据
|
| 383 |
+
if creds.access_token:
|
| 384 |
+
credential_data["access_token"] = creds.access_token
|
| 385 |
+
# 保持兼容性
|
| 386 |
+
credential_data["token"] = creds.access_token
|
| 387 |
+
|
| 388 |
+
if creds.expires_at:
|
| 389 |
+
credential_data["expiry"] = creds.expires_at.isoformat()
|
| 390 |
+
|
| 391 |
+
# 保存到存储
|
| 392 |
+
await self._storage_adapter.store_credential(filename, credential_data, mode=mode)
|
| 393 |
+
log.info(f"Token刷新成功并已保存: {filename} (mode={mode})")
|
| 394 |
+
|
| 395 |
+
return credential_data
|
| 396 |
+
|
| 397 |
+
except Exception as e:
|
| 398 |
+
error_msg = str(e)
|
| 399 |
+
log.error(f"Token刷新失败 {filename} (mode={mode}): {error_msg}")
|
| 400 |
+
|
| 401 |
+
# 尝试提取HTTP状态码(TokenError可能携带status_code属性)
|
| 402 |
+
status_code = None
|
| 403 |
+
if hasattr(e, 'status_code'):
|
| 404 |
+
status_code = e.status_code
|
| 405 |
+
|
| 406 |
+
# 检查是否是凭证永久失效的错误(只有明确的400/403等才判定为永久失效)
|
| 407 |
+
is_permanent_failure = self._is_permanent_refresh_failure(error_msg, status_code)
|
| 408 |
+
|
| 409 |
+
if is_permanent_failure:
|
| 410 |
+
log.warning(f"检测到凭证永久失效 (HTTP {status_code}): {filename}")
|
| 411 |
+
# 记录失效状态
|
| 412 |
+
if status_code:
|
| 413 |
+
await self.record_api_call_result(filename, False, status_code, mode=mode)
|
| 414 |
+
else:
|
| 415 |
+
await self.record_api_call_result(filename, False, 400, mode=mode)
|
| 416 |
+
|
| 417 |
+
# 禁用失效凭证
|
| 418 |
+
try:
|
| 419 |
+
# 直接禁用该凭证(随机选择机制会自动跳过它)
|
| 420 |
+
disabled_ok = await self.update_credential_state(filename, {"disabled": True}, mode=mode)
|
| 421 |
+
if disabled_ok:
|
| 422 |
+
log.warning(f"永久失效凭证已禁用: {filename}")
|
| 423 |
+
else:
|
| 424 |
+
log.warning("永久失效凭证禁用失败,将由上层逻辑继续处理")
|
| 425 |
+
except Exception as e2:
|
| 426 |
+
log.error(f"禁用永久失效凭证时出错 {filename}: {e2}")
|
| 427 |
+
else:
|
| 428 |
+
# 网络错误或其他临时性错误,不封禁凭证
|
| 429 |
+
log.warning(f"Token刷新失败但非永久性错误 (HTTP {status_code}),不封禁凭证: {filename}")
|
| 430 |
+
|
| 431 |
+
return None
|
| 432 |
+
|
| 433 |
+
def _is_permanent_refresh_failure(self, error_msg: str, status_code: Optional[int] = None) -> bool:
|
| 434 |
+
"""
|
| 435 |
+
判断是否是凭证永久失效的错误
|
| 436 |
+
|
| 437 |
+
Args:
|
| 438 |
+
error_msg: 错误信息
|
| 439 |
+
status_code: HTTP状态码(如果有)
|
| 440 |
+
|
| 441 |
+
Returns:
|
| 442 |
+
True表示凭证永久失效应封禁,False表示临时错误不应封禁
|
| 443 |
+
"""
|
| 444 |
+
# 优先使用HTTP状态码判断
|
| 445 |
+
if status_code is not None:
|
| 446 |
+
# 400/401/403 明确表示凭证有问题,应该封禁
|
| 447 |
+
if status_code in [400, 401, 403]:
|
| 448 |
+
log.debug(f"检测到客户端错误状态码 {status_code},判定为永久失效")
|
| 449 |
+
return True
|
| 450 |
+
# 500/502/503/504 是服务器错误,不应封禁凭证
|
| 451 |
+
elif status_code in [500, 502, 503, 504]:
|
| 452 |
+
log.debug(f"检测到服务器错误状态码 {status_code},不应封禁凭证")
|
| 453 |
+
return False
|
| 454 |
+
# 429 (限流) 不应封禁凭证
|
| 455 |
+
elif status_code == 429:
|
| 456 |
+
log.debug("检测到限流错误 429,不应封禁凭证")
|
| 457 |
+
return False
|
| 458 |
+
|
| 459 |
+
# 如果没有状态码,回退到错误信息匹配(谨慎判断)
|
| 460 |
+
# 只有明确的凭证失效错误才判定为永久失效
|
| 461 |
+
permanent_error_patterns = [
|
| 462 |
+
"invalid_grant",
|
| 463 |
+
"refresh_token_expired",
|
| 464 |
+
"invalid_refresh_token",
|
| 465 |
+
"unauthorized_client",
|
| 466 |
+
"access_denied",
|
| 467 |
+
]
|
| 468 |
+
|
| 469 |
+
error_msg_lower = error_msg.lower()
|
| 470 |
+
for pattern in permanent_error_patterns:
|
| 471 |
+
if pattern.lower() in error_msg_lower:
|
| 472 |
+
log.debug(f"错误信息匹配到永久失效模式: {pattern}")
|
| 473 |
+
return True
|
| 474 |
+
|
| 475 |
+
# 默认认为是临时错误(如网络问题),不应封禁凭证
|
| 476 |
+
log.debug("未匹配到明确的永久失效模式,判定为临时错误")
|
| 477 |
+
return False
|
| 478 |
+
|
| 479 |
+
class _CredentialManagerSingleton:
|
| 480 |
+
"""单例包装器,支持懒加载和自动初始化"""
|
| 481 |
+
|
| 482 |
+
_instance: Optional[CredentialManager] = None
|
| 483 |
+
_lock = None
|
| 484 |
+
|
| 485 |
+
def __init__(self):
|
| 486 |
+
self._manager = None
|
| 487 |
+
|
| 488 |
+
async def _get_or_create(self) -> CredentialManager:
|
| 489 |
+
"""获取或创建单例实例(线程安全)"""
|
| 490 |
+
if self._instance is None:
|
| 491 |
+
# 简单的实例创建(异步环境下一般不需要复杂的锁)
|
| 492 |
+
if self._instance is None:
|
| 493 |
+
self._instance = CredentialManager()
|
| 494 |
+
await self._instance.initialize()
|
| 495 |
+
log.debug("CredentialManager singleton initialized")
|
| 496 |
+
|
| 497 |
+
return self._instance
|
| 498 |
+
|
| 499 |
+
def __getattr__(self, name):
|
| 500 |
+
"""代理所有方法调用到真实的 CredentialManager 实例"""
|
| 501 |
+
async def _async_wrapper(*args, **kwargs):
|
| 502 |
+
manager = await self._get_or_create()
|
| 503 |
+
method = getattr(manager, name)
|
| 504 |
+
return await method(*args, **kwargs)
|
| 505 |
+
|
| 506 |
+
return _async_wrapper
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
# 全局单例实例 - 直接导入即可使用
|
| 510 |
+
credential_manager = _CredentialManagerSingleton()
|
src/google_oauth_api.py
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Google OAuth2 认证模块
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
import asyncio
|
| 7 |
+
from datetime import datetime, timedelta, timezone
|
| 8 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 9 |
+
from urllib.parse import urlencode
|
| 10 |
+
|
| 11 |
+
import jwt
|
| 12 |
+
|
| 13 |
+
from config import (
|
| 14 |
+
get_googleapis_proxy_url,
|
| 15 |
+
get_oauth_proxy_url,
|
| 16 |
+
get_resource_manager_api_url,
|
| 17 |
+
get_service_usage_api_url,
|
| 18 |
+
)
|
| 19 |
+
from log import log
|
| 20 |
+
|
| 21 |
+
from src.httpx_client import get_async, post_async
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TokenError(Exception):
|
| 25 |
+
"""Token相关错误"""
|
| 26 |
+
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Credentials:
|
| 31 |
+
"""凭证类"""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
access_token: str,
|
| 36 |
+
refresh_token: str = None,
|
| 37 |
+
client_id: str = None,
|
| 38 |
+
client_secret: str = None,
|
| 39 |
+
expires_at: datetime = None,
|
| 40 |
+
project_id: str = None,
|
| 41 |
+
):
|
| 42 |
+
self.access_token = access_token
|
| 43 |
+
self.refresh_token = refresh_token
|
| 44 |
+
self.client_id = client_id
|
| 45 |
+
self.client_secret = client_secret
|
| 46 |
+
self.expires_at = expires_at
|
| 47 |
+
self.project_id = project_id
|
| 48 |
+
|
| 49 |
+
# 反代配置将在使用时异步获取
|
| 50 |
+
self.oauth_base_url = None
|
| 51 |
+
self.token_endpoint = None
|
| 52 |
+
|
| 53 |
+
def is_expired(self) -> bool:
|
| 54 |
+
"""检查token是否过期"""
|
| 55 |
+
if not self.expires_at:
|
| 56 |
+
return True
|
| 57 |
+
|
| 58 |
+
# 提前3分钟认为过期
|
| 59 |
+
buffer = timedelta(minutes=3)
|
| 60 |
+
return (self.expires_at - buffer) <= datetime.now(timezone.utc)
|
| 61 |
+
|
| 62 |
+
async def refresh_if_needed(self) -> bool:
|
| 63 |
+
"""如果需要则刷新token"""
|
| 64 |
+
if not self.is_expired():
|
| 65 |
+
return False
|
| 66 |
+
|
| 67 |
+
if not self.refresh_token:
|
| 68 |
+
raise TokenError("需要刷新令牌但未提供")
|
| 69 |
+
|
| 70 |
+
await self.refresh()
|
| 71 |
+
return True
|
| 72 |
+
|
| 73 |
+
async def refresh(self):
|
| 74 |
+
"""刷新访问令牌"""
|
| 75 |
+
if not self.refresh_token:
|
| 76 |
+
raise TokenError("无刷新令牌")
|
| 77 |
+
|
| 78 |
+
data = {
|
| 79 |
+
"client_id": self.client_id,
|
| 80 |
+
"client_secret": self.client_secret,
|
| 81 |
+
"refresh_token": self.refresh_token,
|
| 82 |
+
"grant_type": "refresh_token",
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
oauth_base_url = await get_oauth_proxy_url()
|
| 87 |
+
token_url = f"{oauth_base_url.rstrip('/')}/token"
|
| 88 |
+
response = await post_async(
|
| 89 |
+
token_url,
|
| 90 |
+
data=data,
|
| 91 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 92 |
+
)
|
| 93 |
+
response.raise_for_status()
|
| 94 |
+
|
| 95 |
+
token_data = response.json()
|
| 96 |
+
self.access_token = token_data["access_token"]
|
| 97 |
+
|
| 98 |
+
if "expires_in" in token_data:
|
| 99 |
+
expires_in = int(token_data["expires_in"])
|
| 100 |
+
current_utc = datetime.now(timezone.utc)
|
| 101 |
+
self.expires_at = current_utc + timedelta(seconds=expires_in)
|
| 102 |
+
log.debug(
|
| 103 |
+
f"Token刷新: 当前UTC时间={current_utc.isoformat()}, "
|
| 104 |
+
f"有效期={expires_in}秒, "
|
| 105 |
+
f"过期时间={self.expires_at.isoformat()}"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if "refresh_token" in token_data:
|
| 109 |
+
self.refresh_token = token_data["refresh_token"]
|
| 110 |
+
|
| 111 |
+
log.debug(f"Token刷新成功,过期时间: {self.expires_at}")
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
error_msg = str(e)
|
| 115 |
+
status_code = None
|
| 116 |
+
if hasattr(e, 'response') and hasattr(e.response, 'status_code'):
|
| 117 |
+
status_code = e.response.status_code
|
| 118 |
+
error_msg = f"Token刷新失败 (HTTP {status_code}): {error_msg}"
|
| 119 |
+
else:
|
| 120 |
+
error_msg = f"Token刷新失败: {error_msg}"
|
| 121 |
+
|
| 122 |
+
log.error(error_msg)
|
| 123 |
+
token_error = TokenError(error_msg)
|
| 124 |
+
token_error.status_code = status_code
|
| 125 |
+
raise token_error
|
| 126 |
+
|
| 127 |
+
@classmethod
|
| 128 |
+
def from_dict(cls, data: Dict[str, Any]) -> "Credentials":
|
| 129 |
+
"""从字典创建凭证"""
|
| 130 |
+
# 处理过期时间
|
| 131 |
+
expires_at = None
|
| 132 |
+
if "expiry" in data and data["expiry"]:
|
| 133 |
+
try:
|
| 134 |
+
expiry_str = data["expiry"]
|
| 135 |
+
if isinstance(expiry_str, str):
|
| 136 |
+
if expiry_str.endswith("Z"):
|
| 137 |
+
expires_at = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
|
| 138 |
+
elif "+" in expiry_str:
|
| 139 |
+
expires_at = datetime.fromisoformat(expiry_str)
|
| 140 |
+
else:
|
| 141 |
+
expires_at = datetime.fromisoformat(expiry_str).replace(tzinfo=timezone.utc)
|
| 142 |
+
except ValueError:
|
| 143 |
+
log.warning(f"无法解析过期时间: {expiry_str}")
|
| 144 |
+
|
| 145 |
+
return cls(
|
| 146 |
+
access_token=data.get("token") or data.get("access_token", ""),
|
| 147 |
+
refresh_token=data.get("refresh_token"),
|
| 148 |
+
client_id=data.get("client_id"),
|
| 149 |
+
client_secret=data.get("client_secret"),
|
| 150 |
+
expires_at=expires_at,
|
| 151 |
+
project_id=data.get("project_id"),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 155 |
+
"""转为字典"""
|
| 156 |
+
result = {
|
| 157 |
+
"access_token": self.access_token,
|
| 158 |
+
"refresh_token": self.refresh_token,
|
| 159 |
+
"client_id": self.client_id,
|
| 160 |
+
"client_secret": self.client_secret,
|
| 161 |
+
"project_id": self.project_id,
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
if self.expires_at:
|
| 165 |
+
result["expiry"] = self.expires_at.isoformat()
|
| 166 |
+
|
| 167 |
+
return result
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class Flow:
|
| 171 |
+
"""OAuth流程类"""
|
| 172 |
+
|
| 173 |
+
def __init__(
|
| 174 |
+
self, client_id: str, client_secret: str, scopes: List[str], redirect_uri: str = None
|
| 175 |
+
):
|
| 176 |
+
self.client_id = client_id
|
| 177 |
+
self.client_secret = client_secret
|
| 178 |
+
self.scopes = scopes
|
| 179 |
+
self.redirect_uri = redirect_uri
|
| 180 |
+
|
| 181 |
+
# 反代配置将在使用时异步获取
|
| 182 |
+
self.oauth_base_url = None
|
| 183 |
+
self.token_endpoint = None
|
| 184 |
+
self.auth_endpoint = "https://accounts.google.com/o/oauth2/auth"
|
| 185 |
+
|
| 186 |
+
self.credentials: Optional[Credentials] = None
|
| 187 |
+
|
| 188 |
+
def get_auth_url(self, state: str = None, **kwargs) -> str:
|
| 189 |
+
"""生成授权URL"""
|
| 190 |
+
params = {
|
| 191 |
+
"client_id": self.client_id,
|
| 192 |
+
"redirect_uri": self.redirect_uri,
|
| 193 |
+
"scope": " ".join(self.scopes),
|
| 194 |
+
"response_type": "code",
|
| 195 |
+
"access_type": "offline",
|
| 196 |
+
"prompt": "consent",
|
| 197 |
+
"include_granted_scopes": "true",
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
if state:
|
| 201 |
+
params["state"] = state
|
| 202 |
+
|
| 203 |
+
params.update(kwargs)
|
| 204 |
+
return f"{self.auth_endpoint}?{urlencode(params)}"
|
| 205 |
+
|
| 206 |
+
async def exchange_code(self, code: str) -> Credentials:
|
| 207 |
+
"""用授权码换取token"""
|
| 208 |
+
data = {
|
| 209 |
+
"client_id": self.client_id,
|
| 210 |
+
"client_secret": self.client_secret,
|
| 211 |
+
"redirect_uri": self.redirect_uri,
|
| 212 |
+
"code": code,
|
| 213 |
+
"grant_type": "authorization_code",
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
try:
|
| 217 |
+
oauth_base_url = await get_oauth_proxy_url()
|
| 218 |
+
token_url = f"{oauth_base_url.rstrip('/')}/token"
|
| 219 |
+
response = await post_async(
|
| 220 |
+
token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
| 221 |
+
)
|
| 222 |
+
response.raise_for_status()
|
| 223 |
+
|
| 224 |
+
token_data = response.json()
|
| 225 |
+
|
| 226 |
+
# 计算过期时间
|
| 227 |
+
expires_at = None
|
| 228 |
+
if "expires_in" in token_data:
|
| 229 |
+
expires_in = int(token_data["expires_in"])
|
| 230 |
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
| 231 |
+
|
| 232 |
+
# 创建凭证对象
|
| 233 |
+
self.credentials = Credentials(
|
| 234 |
+
access_token=token_data["access_token"],
|
| 235 |
+
refresh_token=token_data.get("refresh_token"),
|
| 236 |
+
client_id=self.client_id,
|
| 237 |
+
client_secret=self.client_secret,
|
| 238 |
+
expires_at=expires_at,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
return self.credentials
|
| 242 |
+
|
| 243 |
+
except Exception as e:
|
| 244 |
+
error_msg = f"获取token失败: {str(e)}"
|
| 245 |
+
log.error(error_msg)
|
| 246 |
+
raise TokenError(error_msg)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
class ServiceAccount:
|
| 250 |
+
"""Service Account类"""
|
| 251 |
+
|
| 252 |
+
def __init__(
|
| 253 |
+
self, email: str, private_key: str, project_id: str = None, scopes: List[str] = None
|
| 254 |
+
):
|
| 255 |
+
self.email = email
|
| 256 |
+
self.private_key = private_key
|
| 257 |
+
self.project_id = project_id
|
| 258 |
+
self.scopes = scopes or []
|
| 259 |
+
|
| 260 |
+
# 反代配置将在使用时异步获取
|
| 261 |
+
self.oauth_base_url = None
|
| 262 |
+
self.token_endpoint = None
|
| 263 |
+
|
| 264 |
+
self.access_token: Optional[str] = None
|
| 265 |
+
self.expires_at: Optional[datetime] = None
|
| 266 |
+
|
| 267 |
+
def is_expired(self) -> bool:
|
| 268 |
+
"""检查token是否过期"""
|
| 269 |
+
if not self.expires_at:
|
| 270 |
+
return True
|
| 271 |
+
|
| 272 |
+
buffer = timedelta(minutes=3)
|
| 273 |
+
return (self.expires_at - buffer) <= datetime.now(timezone.utc)
|
| 274 |
+
|
| 275 |
+
def create_jwt(self) -> str:
|
| 276 |
+
"""创建JWT令牌"""
|
| 277 |
+
now = int(time.time())
|
| 278 |
+
|
| 279 |
+
payload = {
|
| 280 |
+
"iss": self.email,
|
| 281 |
+
"scope": " ".join(self.scopes) if self.scopes else "",
|
| 282 |
+
"aud": self.token_endpoint,
|
| 283 |
+
"exp": now + 3600,
|
| 284 |
+
"iat": now,
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
return jwt.encode(payload, self.private_key, algorithm="RS256")
|
| 288 |
+
|
| 289 |
+
async def get_access_token(self) -> str:
|
| 290 |
+
"""获取访问令牌"""
|
| 291 |
+
if not self.is_expired() and self.access_token:
|
| 292 |
+
return self.access_token
|
| 293 |
+
|
| 294 |
+
assertion = self.create_jwt()
|
| 295 |
+
|
| 296 |
+
data = {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion}
|
| 297 |
+
|
| 298 |
+
try:
|
| 299 |
+
oauth_base_url = await get_oauth_proxy_url()
|
| 300 |
+
token_url = f"{oauth_base_url.rstrip('/')}/token"
|
| 301 |
+
response = await post_async(
|
| 302 |
+
token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
| 303 |
+
)
|
| 304 |
+
response.raise_for_status()
|
| 305 |
+
|
| 306 |
+
token_data = response.json()
|
| 307 |
+
self.access_token = token_data["access_token"]
|
| 308 |
+
|
| 309 |
+
if "expires_in" in token_data:
|
| 310 |
+
expires_in = int(token_data["expires_in"])
|
| 311 |
+
self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
| 312 |
+
|
| 313 |
+
return self.access_token
|
| 314 |
+
|
| 315 |
+
except Exception as e:
|
| 316 |
+
error_msg = f"Service Account获取token失败: {str(e)}"
|
| 317 |
+
log.error(error_msg)
|
| 318 |
+
raise TokenError(error_msg)
|
| 319 |
+
|
| 320 |
+
@classmethod
|
| 321 |
+
def from_dict(cls, data: Dict[str, Any], scopes: List[str] = None) -> "ServiceAccount":
|
| 322 |
+
"""从字典创建Service Account凭证"""
|
| 323 |
+
return cls(
|
| 324 |
+
email=data["client_email"],
|
| 325 |
+
private_key=data["private_key"],
|
| 326 |
+
project_id=data.get("project_id"),
|
| 327 |
+
scopes=scopes,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# 工具函数
|
| 332 |
+
async def get_user_info(credentials: Credentials) -> Optional[Dict[str, Any]]:
|
| 333 |
+
"""获取用户信息"""
|
| 334 |
+
await credentials.refresh_if_needed()
|
| 335 |
+
|
| 336 |
+
try:
|
| 337 |
+
googleapis_base_url = await get_googleapis_proxy_url()
|
| 338 |
+
userinfo_url = f"{googleapis_base_url.rstrip('/')}/oauth2/v2/userinfo"
|
| 339 |
+
response = await get_async(
|
| 340 |
+
userinfo_url, headers={"Authorization": f"Bearer {credentials.access_token}"}
|
| 341 |
+
)
|
| 342 |
+
response.raise_for_status()
|
| 343 |
+
return response.json()
|
| 344 |
+
except Exception as e:
|
| 345 |
+
log.error(f"获取用户信息失败: {e}")
|
| 346 |
+
return None
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
async def get_user_email(credentials: Credentials) -> Optional[str]:
|
| 350 |
+
"""获取用户邮箱地址"""
|
| 351 |
+
try:
|
| 352 |
+
# 确保凭证有效
|
| 353 |
+
await credentials.refresh_if_needed()
|
| 354 |
+
|
| 355 |
+
# 调用Google userinfo API获取邮箱
|
| 356 |
+
user_info = await get_user_info(credentials)
|
| 357 |
+
if user_info:
|
| 358 |
+
email = user_info.get("email")
|
| 359 |
+
if email:
|
| 360 |
+
log.info(f"成功获取邮箱地址: {email}")
|
| 361 |
+
return email
|
| 362 |
+
else:
|
| 363 |
+
log.warning(f"userinfo响应中没有邮箱信息: {user_info}")
|
| 364 |
+
return None
|
| 365 |
+
else:
|
| 366 |
+
log.warning("获取用户信息失败")
|
| 367 |
+
return None
|
| 368 |
+
|
| 369 |
+
except Exception as e:
|
| 370 |
+
log.error(f"获取用户邮箱失败: {e}")
|
| 371 |
+
return None
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
async def fetch_user_email_from_file(cred_data: Dict[str, Any]) -> Optional[str]:
|
| 375 |
+
"""从凭证数据获取用户邮箱地址(支持统一存储)"""
|
| 376 |
+
try:
|
| 377 |
+
# 直接从凭证数据创建凭证对象
|
| 378 |
+
credentials = Credentials.from_dict(cred_data)
|
| 379 |
+
if not credentials or not credentials.access_token:
|
| 380 |
+
log.warning("无法从凭证数据创建凭证对象或获取访问令牌")
|
| 381 |
+
return None
|
| 382 |
+
|
| 383 |
+
# 获取邮箱
|
| 384 |
+
return await get_user_email(credentials)
|
| 385 |
+
|
| 386 |
+
except Exception as e:
|
| 387 |
+
log.error(f"从凭证数据获取用户邮箱失败: {e}")
|
| 388 |
+
return None
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
async def validate_token(token: str) -> Optional[Dict[str, Any]]:
|
| 392 |
+
"""验证访问令牌"""
|
| 393 |
+
try:
|
| 394 |
+
oauth_base_url = await get_oauth_proxy_url()
|
| 395 |
+
tokeninfo_url = f"{oauth_base_url.rstrip('/')}/tokeninfo?access_token={token}"
|
| 396 |
+
|
| 397 |
+
response = await get_async(tokeninfo_url)
|
| 398 |
+
response.raise_for_status()
|
| 399 |
+
return response.json()
|
| 400 |
+
except Exception as e:
|
| 401 |
+
log.error(f"验证令牌失败: {e}")
|
| 402 |
+
return None
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
async def enable_required_apis(credentials: Credentials, project_id: str) -> bool:
|
| 406 |
+
"""自动启用必需的API服务"""
|
| 407 |
+
try:
|
| 408 |
+
# 确保凭证有效
|
| 409 |
+
if credentials.is_expired() and credentials.refresh_token:
|
| 410 |
+
await credentials.refresh()
|
| 411 |
+
|
| 412 |
+
headers = {
|
| 413 |
+
"Authorization": f"Bearer {credentials.access_token}",
|
| 414 |
+
"Content-Type": "application/json",
|
| 415 |
+
"User-Agent": "geminicli-oauth/1.0",
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
# 需要启用的服务列表
|
| 419 |
+
required_services = [
|
| 420 |
+
"geminicloudassist.googleapis.com", # Gemini Cloud Assist API
|
| 421 |
+
"cloudaicompanion.googleapis.com", # Gemini for Google Cloud API
|
| 422 |
+
]
|
| 423 |
+
|
| 424 |
+
for service in required_services:
|
| 425 |
+
log.info(f"正在检查并启用服务: {service}")
|
| 426 |
+
|
| 427 |
+
# 检查服务是否已启用
|
| 428 |
+
service_usage_base_url = await get_service_usage_api_url()
|
| 429 |
+
check_url = (
|
| 430 |
+
f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}"
|
| 431 |
+
)
|
| 432 |
+
try:
|
| 433 |
+
check_response = await get_async(check_url, headers=headers)
|
| 434 |
+
if check_response.status_code == 200:
|
| 435 |
+
service_data = check_response.json()
|
| 436 |
+
if service_data.get("state") == "ENABLED":
|
| 437 |
+
log.info(f"服务 {service} 已启用")
|
| 438 |
+
continue
|
| 439 |
+
except Exception as e:
|
| 440 |
+
log.debug(f"检查服务状态失败,将尝试启用: {e}")
|
| 441 |
+
|
| 442 |
+
# 启用服务
|
| 443 |
+
enable_url = f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}:enable"
|
| 444 |
+
try:
|
| 445 |
+
enable_response = await post_async(enable_url, headers=headers, json={})
|
| 446 |
+
|
| 447 |
+
if enable_response.status_code in [200, 201]:
|
| 448 |
+
log.info(f"✅ 成功启用服务: {service}")
|
| 449 |
+
elif enable_response.status_code == 400:
|
| 450 |
+
error_data = enable_response.json()
|
| 451 |
+
if "already enabled" in error_data.get("error", {}).get("message", "").lower():
|
| 452 |
+
log.info(f"✅ 服务 {service} 已经启用")
|
| 453 |
+
else:
|
| 454 |
+
log.warning(f"⚠️ 启用服务 {service} 时出现警告: {error_data}")
|
| 455 |
+
else:
|
| 456 |
+
log.warning(
|
| 457 |
+
f"⚠️ 启用服务 {service} 失败: {enable_response.status_code} - {enable_response.text}"
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
except Exception as e:
|
| 461 |
+
log.warning(f"⚠️ 启用服务 {service} 时发生异常: {e}")
|
| 462 |
+
|
| 463 |
+
return True
|
| 464 |
+
|
| 465 |
+
except Exception as e:
|
| 466 |
+
log.error(f"启用API服务时发生错误: {e}")
|
| 467 |
+
return False
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
async def get_user_projects(credentials: Credentials) -> List[Dict[str, Any]]:
|
| 471 |
+
"""获取用户可访问的Google Cloud项目列表"""
|
| 472 |
+
try:
|
| 473 |
+
# 确保凭证有效
|
| 474 |
+
if credentials.is_expired() and credentials.refresh_token:
|
| 475 |
+
await credentials.refresh()
|
| 476 |
+
|
| 477 |
+
headers = {
|
| 478 |
+
"Authorization": f"Bearer {credentials.access_token}",
|
| 479 |
+
"User-Agent": "geminicli-oauth/1.0",
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
# 使用Resource Manager API的正确域名和端点
|
| 483 |
+
resource_manager_base_url = await get_resource_manager_api_url()
|
| 484 |
+
url = f"{resource_manager_base_url.rstrip('/')}/v1/projects"
|
| 485 |
+
log.info(f"正在调用API: {url}")
|
| 486 |
+
response = await get_async(url, headers=headers)
|
| 487 |
+
|
| 488 |
+
log.info(f"API响应状态码: {response.status_code}")
|
| 489 |
+
if response.status_code != 200:
|
| 490 |
+
log.error(f"API响应内容: {response.text}")
|
| 491 |
+
|
| 492 |
+
if response.status_code == 200:
|
| 493 |
+
data = response.json()
|
| 494 |
+
projects = data.get("projects", [])
|
| 495 |
+
# 只返回活跃的项目
|
| 496 |
+
active_projects = [
|
| 497 |
+
project for project in projects if project.get("lifecycleState") == "ACTIVE"
|
| 498 |
+
]
|
| 499 |
+
log.info(f"获取到 {len(active_projects)} 个活跃项目")
|
| 500 |
+
return active_projects
|
| 501 |
+
else:
|
| 502 |
+
log.warning(f"获取项目列表失败: {response.status_code} - {response.text}")
|
| 503 |
+
return []
|
| 504 |
+
|
| 505 |
+
except Exception as e:
|
| 506 |
+
log.error(f"获取用户项目列表失败: {e}")
|
| 507 |
+
return []
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
async def select_default_project(projects: List[Dict[str, Any]]) -> Optional[str]:
|
| 511 |
+
"""从项目列表中选择默认项目"""
|
| 512 |
+
if not projects:
|
| 513 |
+
return None
|
| 514 |
+
|
| 515 |
+
# 策略1:查找显示名称或项目ID包含"default"的项目
|
| 516 |
+
for project in projects:
|
| 517 |
+
display_name = project.get("displayName", "").lower()
|
| 518 |
+
# Google API returns projectId in camelCase
|
| 519 |
+
project_id = project.get("projectId", "")
|
| 520 |
+
if "default" in display_name or "default" in project_id.lower():
|
| 521 |
+
log.info(f"选择默认项目: {project_id} ({project.get('displayName', project_id)})")
|
| 522 |
+
return project_id
|
| 523 |
+
|
| 524 |
+
# 策略2:选择第一个项目
|
| 525 |
+
first_project = projects[0]
|
| 526 |
+
# Google API returns projectId in camelCase
|
| 527 |
+
project_id = first_project.get("projectId", "")
|
| 528 |
+
log.info(
|
| 529 |
+
f"选择第一个项目作为默认: {project_id} ({first_project.get('displayName', project_id)})"
|
| 530 |
+
)
|
| 531 |
+
return project_id
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
async def fetch_project_id_and_tier(
|
| 535 |
+
access_token: str,
|
| 536 |
+
user_agent: str,
|
| 537 |
+
api_base_url: str,
|
| 538 |
+
include_credits: bool = False,
|
| 539 |
+
) -> Tuple[Optional[str], Optional[str]] | Tuple[Optional[str], Optional[str], Optional[int]]:
|
| 540 |
+
"""
|
| 541 |
+
从 API 获取 project_id 和订阅等级
|
| 542 |
+
|
| 543 |
+
Args:
|
| 544 |
+
access_token: Google OAuth access token
|
| 545 |
+
user_agent: User-Agent header
|
| 546 |
+
api_base_url: API base URL (e.g., antigravity or code assist endpoint)
|
| 547 |
+
|
| 548 |
+
Returns:
|
| 549 |
+
默认返回 (project_id, subscription_tier)
|
| 550 |
+
当 include_credits=True 时返回 (project_id, subscription_tier, credit_amount)
|
| 551 |
+
subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None
|
| 552 |
+
credit_amount 为积分数量(整数)或 None
|
| 553 |
+
"""
|
| 554 |
+
headers = {
|
| 555 |
+
'User-Agent': user_agent,
|
| 556 |
+
'Authorization': f'Bearer {access_token}',
|
| 557 |
+
'Content-Type': 'application/json',
|
| 558 |
+
'Accept-Encoding': 'gzip'
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
def _map_raw_tier(raw_tier: Optional[str]) -> Optional[str]:
|
| 562 |
+
"""将 loadCodeAssist 返回的 raw tier 映射为统一 tier。"""
|
| 563 |
+
if not raw_tier:
|
| 564 |
+
return None
|
| 565 |
+
|
| 566 |
+
tier_mapping = {
|
| 567 |
+
"g1-ultra-tier": "ultra",
|
| 568 |
+
"ws-ai-ultra-business-tier": "ultra",
|
| 569 |
+
"g1-pro-tier": "pro",
|
| 570 |
+
"helium-tier": "pro",
|
| 571 |
+
"standard-tier": "pro",
|
| 572 |
+
"free-tier": "free",
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
return tier_mapping.get(raw_tier.lower(), "pro")
|
| 576 |
+
|
| 577 |
+
subscription_tier = None
|
| 578 |
+
credit_amount: Optional[int] = None
|
| 579 |
+
|
| 580 |
+
# 步骤 1: 尝试 loadCodeAssist
|
| 581 |
+
try:
|
| 582 |
+
project_id, raw_tier, raw_credit_amount = await _try_load_code_assist(api_base_url, headers)
|
| 583 |
+
subscription_tier = _map_raw_tier(raw_tier)
|
| 584 |
+
|
| 585 |
+
if raw_credit_amount is not None:
|
| 586 |
+
try:
|
| 587 |
+
credit_amount = int(raw_credit_amount)
|
| 588 |
+
log.info(
|
| 589 |
+
f"[fetch_project_id_and_tier] Found credit_amount: {credit_amount}"
|
| 590 |
+
)
|
| 591 |
+
except (TypeError, ValueError):
|
| 592 |
+
log.warning(
|
| 593 |
+
f"[fetch_project_id_and_tier] Invalid credit_amount: {raw_credit_amount}"
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
if raw_tier:
|
| 597 |
+
log.info(
|
| 598 |
+
f"[fetch_project_id_and_tier] Raw tier '{raw_tier}' mapped to '{subscription_tier}'"
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
if project_id:
|
| 602 |
+
if include_credits:
|
| 603 |
+
return project_id, subscription_tier, credit_amount
|
| 604 |
+
return project_id, subscription_tier
|
| 605 |
+
|
| 606 |
+
log.warning("[fetch_project_id_and_tier] loadCodeAssist did not return project_id, falling back to onboardUser")
|
| 607 |
+
|
| 608 |
+
except Exception as e:
|
| 609 |
+
log.warning(f"[fetch_project_id_and_tier] loadCodeAssist failed: {type(e).__name__}: {e}")
|
| 610 |
+
log.warning("[fetch_project_id_and_tier] Falling back to onboardUser")
|
| 611 |
+
|
| 612 |
+
# 步骤 2: 回退到 onboardUser
|
| 613 |
+
try:
|
| 614 |
+
project_id = await _try_onboard_user(api_base_url, headers)
|
| 615 |
+
if project_id:
|
| 616 |
+
if include_credits:
|
| 617 |
+
return project_id, subscription_tier, credit_amount
|
| 618 |
+
return project_id, subscription_tier
|
| 619 |
+
|
| 620 |
+
log.error("[fetch_project_id_and_tier] Failed to get project_id from both loadCodeAssist and onboardUser")
|
| 621 |
+
if include_credits:
|
| 622 |
+
return None, subscription_tier, credit_amount
|
| 623 |
+
return None, subscription_tier
|
| 624 |
+
|
| 625 |
+
except Exception as e:
|
| 626 |
+
log.error(f"[fetch_project_id_and_tier] onboardUser failed: {type(e).__name__}: {e}")
|
| 627 |
+
import traceback
|
| 628 |
+
log.debug(f"[fetch_project_id_and_tier] Traceback: {traceback.format_exc()}")
|
| 629 |
+
if include_credits:
|
| 630 |
+
return None, subscription_tier, credit_amount
|
| 631 |
+
return None, subscription_tier
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
async def _try_load_code_assist(
|
| 635 |
+
api_base_url: str,
|
| 636 |
+
headers: dict
|
| 637 |
+
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
| 638 |
+
"""
|
| 639 |
+
尝试通过 loadCodeAssist 获取 project_id 和订阅等级
|
| 640 |
+
|
| 641 |
+
Returns:
|
| 642 |
+
(project_id, subscription_tier, credit_amount) 元组
|
| 643 |
+
subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None
|
| 644 |
+
credit_amount 为字符串格式积分或 None
|
| 645 |
+
"""
|
| 646 |
+
request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
|
| 647 |
+
request_body = {
|
| 648 |
+
"metadata": {
|
| 649 |
+
"ideType": "ANTIGRAVITY"
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
log.debug(f"[loadCodeAssist] Fetching project_id from: {request_url}")
|
| 654 |
+
log.debug(f"[loadCodeAssist] Request body: {request_body}")
|
| 655 |
+
|
| 656 |
+
response = await post_async(
|
| 657 |
+
request_url,
|
| 658 |
+
json=request_body,
|
| 659 |
+
headers=headers,
|
| 660 |
+
timeout=30.0,
|
| 661 |
+
)
|
| 662 |
+
|
| 663 |
+
log.debug(f"[loadCodeAssist] Response status: {response.status_code}")
|
| 664 |
+
|
| 665 |
+
if response.status_code == 200:
|
| 666 |
+
response_text = response.text
|
| 667 |
+
log.debug(f"[loadCodeAssist] Response body: {response_text}")
|
| 668 |
+
|
| 669 |
+
data = response.json()
|
| 670 |
+
log.debug(f"[loadCodeAssist] Response JSON keys: {list(data.keys())}")
|
| 671 |
+
|
| 672 |
+
# 提取订阅等级 - 优先使用 paidTier(更准确反映实际权益)
|
| 673 |
+
paid_tier = data.get("paidTier", {})
|
| 674 |
+
current_tier = data.get("currentTier", {})
|
| 675 |
+
available_credits = paid_tier.get("availableCredits", []) if isinstance(paid_tier, dict) else []
|
| 676 |
+
|
| 677 |
+
# paidTier.id 优先,然后是 currentTier.id
|
| 678 |
+
subscription_tier = None
|
| 679 |
+
if isinstance(paid_tier, dict) and paid_tier.get("id"):
|
| 680 |
+
subscription_tier = paid_tier.get("id")
|
| 681 |
+
log.info(f"[loadCodeAssist] Found paidTier: {subscription_tier}")
|
| 682 |
+
elif isinstance(current_tier, dict) and current_tier.get("id"):
|
| 683 |
+
subscription_tier = current_tier.get("id")
|
| 684 |
+
log.info(f"[loadCodeAssist] Found currentTier: {subscription_tier}")
|
| 685 |
+
|
| 686 |
+
# 提取积分数量(如果返回了 availableCredits)
|
| 687 |
+
credit_amount = None
|
| 688 |
+
if isinstance(available_credits, list) and available_credits:
|
| 689 |
+
first_credit = available_credits[0]
|
| 690 |
+
if isinstance(first_credit, dict):
|
| 691 |
+
credit_amount = first_credit.get("creditAmount")
|
| 692 |
+
if credit_amount is not None:
|
| 693 |
+
log.info(f"[loadCodeAssist] Found creditAmount: {credit_amount}")
|
| 694 |
+
|
| 695 |
+
# 检查是否有 currentTier(表示用户已激活)
|
| 696 |
+
if current_tier:
|
| 697 |
+
log.info("[loadCodeAssist] User is already activated")
|
| 698 |
+
|
| 699 |
+
# 使用服务器返回的 project_id
|
| 700 |
+
project_id = data.get("cloudaicompanionProject")
|
| 701 |
+
if project_id:
|
| 702 |
+
log.info(f"[loadCodeAssist] Successfully fetched project_id: {project_id}, tier: {subscription_tier}")
|
| 703 |
+
return project_id, subscription_tier, credit_amount
|
| 704 |
+
|
| 705 |
+
log.warning("[loadCodeAssist] No project_id in response")
|
| 706 |
+
return None, subscription_tier, credit_amount
|
| 707 |
+
else:
|
| 708 |
+
log.info("[loadCodeAssist] User not activated yet (no currentTier)")
|
| 709 |
+
return None, None, credit_amount
|
| 710 |
+
else:
|
| 711 |
+
log.warning(f"[loadCodeAssist] Failed: HTTP {response.status_code}")
|
| 712 |
+
log.warning(f"[loadCodeAssist] Response body: {response.text[:500]}")
|
| 713 |
+
raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
async def _try_onboard_user(
|
| 717 |
+
api_base_url: str,
|
| 718 |
+
headers: dict
|
| 719 |
+
) -> Optional[str]:
|
| 720 |
+
"""
|
| 721 |
+
尝试通过 onboardUser 获取 project_id(长时间运行操作,需要轮询)
|
| 722 |
+
|
| 723 |
+
Returns:
|
| 724 |
+
project_id 或 None
|
| 725 |
+
"""
|
| 726 |
+
request_url = f"{api_base_url.rstrip('/')}/v1internal:onboardUser"
|
| 727 |
+
|
| 728 |
+
# 首先需要获取用户的 tier 信息
|
| 729 |
+
tier_id = await _get_onboard_tier(api_base_url, headers)
|
| 730 |
+
if not tier_id:
|
| 731 |
+
log.error("[onboardUser] Failed to determine user tier")
|
| 732 |
+
return None
|
| 733 |
+
|
| 734 |
+
log.info(f"[onboardUser] User tier: {tier_id}")
|
| 735 |
+
|
| 736 |
+
# 构造 onboardUser 请求
|
| 737 |
+
# 注意:FREE tier 不应该包含 cloudaicompanionProject
|
| 738 |
+
request_body = {
|
| 739 |
+
"tierId": tier_id,
|
| 740 |
+
"metadata": {
|
| 741 |
+
"ideType": "ANTIGRAVITY",
|
| 742 |
+
"platform": "PLATFORM_UNSPECIFIED",
|
| 743 |
+
"pluginType": "GEMINI"
|
| 744 |
+
}
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
log.debug(f"[onboardUser] Request URL: {request_url}")
|
| 748 |
+
log.debug(f"[onboardUser] Request body: {request_body}")
|
| 749 |
+
|
| 750 |
+
# onboardUser 是长时间运行操作,需要轮询
|
| 751 |
+
# 最多等待 10 秒(5 次 * 2 秒)
|
| 752 |
+
max_attempts = 5
|
| 753 |
+
attempt = 0
|
| 754 |
+
|
| 755 |
+
while attempt < max_attempts:
|
| 756 |
+
attempt += 1
|
| 757 |
+
log.debug(f"[onboardUser] Polling attempt {attempt}/{max_attempts}")
|
| 758 |
+
|
| 759 |
+
response = await post_async(
|
| 760 |
+
request_url,
|
| 761 |
+
json=request_body,
|
| 762 |
+
headers=headers,
|
| 763 |
+
timeout=30.0,
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
log.debug(f"[onboardUser] Response status: {response.status_code}")
|
| 767 |
+
|
| 768 |
+
if response.status_code == 200:
|
| 769 |
+
data = response.json()
|
| 770 |
+
log.debug(f"[onboardUser] Response data: {data}")
|
| 771 |
+
|
| 772 |
+
# 检查长时间运行操作是否完成
|
| 773 |
+
if data.get("done"):
|
| 774 |
+
log.info("[onboardUser] Operation completed")
|
| 775 |
+
|
| 776 |
+
# 从响应中提取 project_id
|
| 777 |
+
response_data = data.get("response", {})
|
| 778 |
+
project_obj = response_data.get("cloudaicompanionProject", {})
|
| 779 |
+
|
| 780 |
+
if isinstance(project_obj, dict):
|
| 781 |
+
project_id = project_obj.get("id")
|
| 782 |
+
elif isinstance(project_obj, str):
|
| 783 |
+
project_id = project_obj
|
| 784 |
+
else:
|
| 785 |
+
project_id = None
|
| 786 |
+
|
| 787 |
+
if project_id:
|
| 788 |
+
log.info(f"[onboardUser] Successfully fetched project_id: {project_id}")
|
| 789 |
+
return project_id
|
| 790 |
+
else:
|
| 791 |
+
log.warning("[onboardUser] Operation completed but no project_id in response")
|
| 792 |
+
return None
|
| 793 |
+
else:
|
| 794 |
+
log.debug("[onboardUser] Operation still in progress, waiting 2 seconds...")
|
| 795 |
+
await asyncio.sleep(2)
|
| 796 |
+
else:
|
| 797 |
+
log.warning(f"[onboardUser] Failed: HTTP {response.status_code}")
|
| 798 |
+
log.warning(f"[onboardUser] Response body: {response.text[:500]}")
|
| 799 |
+
raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
|
| 800 |
+
|
| 801 |
+
log.error("[onboardUser] Timeout: Operation did not complete within 10 seconds")
|
| 802 |
+
return None
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
async def _get_onboard_tier(
|
| 806 |
+
api_base_url: str,
|
| 807 |
+
headers: dict
|
| 808 |
+
) -> Optional[str]:
|
| 809 |
+
"""
|
| 810 |
+
从 loadCodeAssist 响应中获取用户应该注册的 tier
|
| 811 |
+
|
| 812 |
+
Returns:
|
| 813 |
+
tier_id (如 "FREE", "STANDARD", "LEGACY") 或 None
|
| 814 |
+
"""
|
| 815 |
+
request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
|
| 816 |
+
request_body = {
|
| 817 |
+
"metadata": {
|
| 818 |
+
"ideType": "ANTIGRAVITY",
|
| 819 |
+
"platform": "PLATFORM_UNSPECIFIED",
|
| 820 |
+
"pluginType": "GEMINI"
|
| 821 |
+
}
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
log.debug(f"[_get_onboard_tier] Fetching tier info from: {request_url}")
|
| 825 |
+
|
| 826 |
+
response = await post_async(
|
| 827 |
+
request_url,
|
| 828 |
+
json=request_body,
|
| 829 |
+
headers=headers,
|
| 830 |
+
timeout=30.0,
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
if response.status_code == 200:
|
| 834 |
+
data = response.json()
|
| 835 |
+
log.debug(f"[_get_onboard_tier] Response data: {data}")
|
| 836 |
+
|
| 837 |
+
# 查找默认的 tier
|
| 838 |
+
allowed_tiers = data.get("allowedTiers", [])
|
| 839 |
+
for tier in allowed_tiers:
|
| 840 |
+
if tier.get("isDefault"):
|
| 841 |
+
tier_id = tier.get("id")
|
| 842 |
+
log.info(f"[_get_onboard_tier] Found default tier: {tier_id}")
|
| 843 |
+
return tier_id
|
| 844 |
+
|
| 845 |
+
# 如果没有默认 tier,使用 LEGACY 作为回退
|
| 846 |
+
log.warning("[_get_onboard_tier] No default tier found, using LEGACY")
|
| 847 |
+
return "LEGACY"
|
| 848 |
+
else:
|
| 849 |
+
log.error(f"[_get_onboard_tier] Failed to fetch tier info: HTTP {response.status_code}")
|
| 850 |
+
return None
|
| 851 |
+
|
| 852 |
+
|
src/httpx_client.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
通用的HTTP客户端模块
|
| 3 |
+
为所有需要使用httpx的模块提供统一的客户端配置和方法
|
| 4 |
+
保持通用性,不与特定业务逻辑耦合
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from typing import Any, AsyncGenerator, Dict, Optional
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
from config import get_proxy_config
|
| 13 |
+
from log import log
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class HttpxClientManager:
|
| 17 |
+
"""通用HTTP客户端管理器"""
|
| 18 |
+
|
| 19 |
+
async def get_client_kwargs(self, timeout: float = 30.0, **kwargs) -> Dict[str, Any]:
|
| 20 |
+
"""获取httpx客户端的通用配置参数"""
|
| 21 |
+
client_kwargs = {"timeout": timeout, **kwargs}
|
| 22 |
+
|
| 23 |
+
# 动态读取代理配置,支持热更新
|
| 24 |
+
current_proxy_config = await get_proxy_config()
|
| 25 |
+
if current_proxy_config:
|
| 26 |
+
client_kwargs["proxy"] = current_proxy_config
|
| 27 |
+
|
| 28 |
+
return client_kwargs
|
| 29 |
+
|
| 30 |
+
@asynccontextmanager
|
| 31 |
+
async def get_client(
|
| 32 |
+
self, timeout: float = 30.0, **kwargs
|
| 33 |
+
) -> AsyncGenerator[httpx.AsyncClient, None]:
|
| 34 |
+
"""获取配置好的异步HTTP客户端"""
|
| 35 |
+
client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs)
|
| 36 |
+
|
| 37 |
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
| 38 |
+
yield client
|
| 39 |
+
|
| 40 |
+
@asynccontextmanager
|
| 41 |
+
async def get_streaming_client(
|
| 42 |
+
self, timeout: float = None, **kwargs
|
| 43 |
+
) -> AsyncGenerator[httpx.AsyncClient, None]:
|
| 44 |
+
"""获取用于流式请求的HTTP客户端(无超时限制)"""
|
| 45 |
+
client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs)
|
| 46 |
+
|
| 47 |
+
# 创建独立的客户端实例用于流式处理
|
| 48 |
+
client = httpx.AsyncClient(**client_kwargs)
|
| 49 |
+
try:
|
| 50 |
+
yield client
|
| 51 |
+
finally:
|
| 52 |
+
# 确保无论发生什么都关闭客户端
|
| 53 |
+
try:
|
| 54 |
+
await client.aclose()
|
| 55 |
+
except Exception as e:
|
| 56 |
+
log.warning(f"Error closing streaming client: {e}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# 全局HTTP客户端管理器实例
|
| 60 |
+
http_client = HttpxClientManager()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# 通用的异步方法
|
| 64 |
+
async def get_async(
|
| 65 |
+
url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0, **kwargs
|
| 66 |
+
) -> httpx.Response:
|
| 67 |
+
"""通用异步GET请求"""
|
| 68 |
+
async with http_client.get_client(timeout=timeout, **kwargs) as client:
|
| 69 |
+
return await client.get(url, headers=headers)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def post_async(
|
| 73 |
+
url: str,
|
| 74 |
+
data: Any = None,
|
| 75 |
+
json: Any = None,
|
| 76 |
+
headers: Optional[Dict[str, str]] = None,
|
| 77 |
+
timeout: float = 900.0,
|
| 78 |
+
**kwargs,
|
| 79 |
+
) -> httpx.Response:
|
| 80 |
+
"""通用异步POST请求"""
|
| 81 |
+
async with http_client.get_client(timeout=timeout, **kwargs) as client:
|
| 82 |
+
return await client.post(url, data=data, json=json, headers=headers)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# 调试用:设为 True 时所有流式请求都返回 429
|
| 86 |
+
_MOCK_STREAM_429 = False
|
| 87 |
+
|
| 88 |
+
async def stream_post_async(
|
| 89 |
+
url: str,
|
| 90 |
+
body: Dict[str, Any],
|
| 91 |
+
native: bool = False,
|
| 92 |
+
headers: Optional[Dict[str, str]] = None,
|
| 93 |
+
**kwargs,
|
| 94 |
+
):
|
| 95 |
+
"""流式异步POST请求"""
|
| 96 |
+
if _MOCK_STREAM_429:
|
| 97 |
+
from fastapi import Response
|
| 98 |
+
import json
|
| 99 |
+
log.warning(f"[MOCK] stream_post_async: 返回模拟429错误")
|
| 100 |
+
yield Response(
|
| 101 |
+
content=json.dumps({"error": {"code": 429, "message": "mock rate limit", "status": "RESOURCE_EXHAUSTED"}}),
|
| 102 |
+
status_code=429,
|
| 103 |
+
)
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
async with http_client.get_streaming_client(**kwargs) as client:
|
| 107 |
+
async with client.stream("POST", url, json=body, headers=headers) as r:
|
| 108 |
+
# 错误直接返回
|
| 109 |
+
if r.status_code != 200:
|
| 110 |
+
from fastapi import Response
|
| 111 |
+
yield Response(await r.aread(), r.status_code, dict(r.headers))
|
| 112 |
+
return
|
| 113 |
+
|
| 114 |
+
# 如果native=True,直接返回bytes流
|
| 115 |
+
if native:
|
| 116 |
+
async for chunk in r.aiter_bytes():
|
| 117 |
+
yield chunk
|
| 118 |
+
else:
|
| 119 |
+
# 通过aiter_lines转化成str流返回
|
| 120 |
+
async for line in r.aiter_lines():
|
| 121 |
+
yield line
|
src/keeplive.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
保活服务模块
|
| 3 |
+
定期向配置的URL发送GET请求,保持服务在线
|
| 4 |
+
未配置保活URL时不启动任何任务,零资源占用
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from config import get_keepalive_interval, get_keepalive_url
|
| 11 |
+
from log import log
|
| 12 |
+
from src.httpx_client import get_async
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class KeepAliveService:
|
| 16 |
+
"""保活服务:定期向指定URL发送GET请求"""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self._task: Optional[asyncio.Task] = None
|
| 20 |
+
|
| 21 |
+
async def _run(self, url: str, interval: int):
|
| 22 |
+
"""保活循环,读取到有效URL才会被调用"""
|
| 23 |
+
log.info(f"[KeepAlive] 保活任务启动,URL={url},间隔={interval}s")
|
| 24 |
+
while True:
|
| 25 |
+
try:
|
| 26 |
+
response = await get_async(url, timeout=30.0)
|
| 27 |
+
log.info(f"[KeepAlive] GET {url} -> {response.status_code}")
|
| 28 |
+
except asyncio.CancelledError:
|
| 29 |
+
raise
|
| 30 |
+
except Exception as e:
|
| 31 |
+
log.warning(f"[KeepAlive] GET {url} 失败: {e}")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
await asyncio.sleep(interval)
|
| 35 |
+
except asyncio.CancelledError:
|
| 36 |
+
raise
|
| 37 |
+
|
| 38 |
+
async def start(self):
|
| 39 |
+
"""
|
| 40 |
+
启动保活服务。
|
| 41 |
+
仅当配置了有效的保活URL时才创建后台任务,否则零开销。
|
| 42 |
+
"""
|
| 43 |
+
if self._task and not self._task.done():
|
| 44 |
+
# 已有任务在运行,不重复启动
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
url = await get_keepalive_url()
|
| 48 |
+
interval = await get_keepalive_interval()
|
| 49 |
+
|
| 50 |
+
if not url or not url.strip():
|
| 51 |
+
log.debug("[KeepAlive] 未配置保活URL,保活服务不启动")
|
| 52 |
+
return
|
| 53 |
+
|
| 54 |
+
if interval <= 0:
|
| 55 |
+
log.warning(f"[KeepAlive] 保活间隔无效({interval}),保活服务不启动")
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
self._task = asyncio.create_task(
|
| 59 |
+
self._run(url.strip(), interval), name="keepalive_service"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
async def stop(self):
|
| 63 |
+
"""停止保活服务"""
|
| 64 |
+
if self._task and not self._task.done():
|
| 65 |
+
self._task.cancel()
|
| 66 |
+
try:
|
| 67 |
+
await self._task
|
| 68 |
+
except asyncio.CancelledError:
|
| 69 |
+
pass
|
| 70 |
+
log.info("[KeepAlive] 保活服务已停止")
|
| 71 |
+
self._task = None
|
| 72 |
+
|
| 73 |
+
async def restart(self):
|
| 74 |
+
"""
|
| 75 |
+
重启保活服务。
|
| 76 |
+
配置变更时调用,会停止旧任务并根据最新配置决定是否启动新任务。
|
| 77 |
+
"""
|
| 78 |
+
await self.stop()
|
| 79 |
+
await self.start()
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def is_running(self) -> bool:
|
| 83 |
+
"""当前保活任务是否在运行"""
|
| 84 |
+
return self._task is not None and not self._task.done()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# 全局保活服务实例
|
| 88 |
+
keepalive_service = KeepAliveService()
|
src/models.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional, Union
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# Pydantic v1/v2 兼容性辅助函数
|
| 7 |
+
def model_to_dict(model: BaseModel) -> Dict[str, Any]:
|
| 8 |
+
"""
|
| 9 |
+
兼容 Pydantic v1 和 v2 的模型转字典方法,排除 None 值
|
| 10 |
+
- v1: model.dict(exclude_none=True)
|
| 11 |
+
- v2: model.model_dump(exclude_none=True)
|
| 12 |
+
"""
|
| 13 |
+
if hasattr(model, 'model_dump'):
|
| 14 |
+
# Pydantic v2
|
| 15 |
+
return model.model_dump(exclude_none=True)
|
| 16 |
+
else:
|
| 17 |
+
# Pydantic v1
|
| 18 |
+
return model.dict(exclude_none=True)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Common Models
|
| 22 |
+
class Model(BaseModel):
|
| 23 |
+
id: str
|
| 24 |
+
object: str = "model"
|
| 25 |
+
created: Optional[int] = None
|
| 26 |
+
owned_by: Optional[str] = "google"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ModelList(BaseModel):
|
| 30 |
+
object: str = "list"
|
| 31 |
+
data: List[Model]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# OpenAI Models
|
| 35 |
+
class OpenAIToolFunction(BaseModel):
|
| 36 |
+
name: str
|
| 37 |
+
arguments: str # JSON string
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class OpenAIToolCall(BaseModel):
|
| 41 |
+
id: str
|
| 42 |
+
type: str = "function"
|
| 43 |
+
function: OpenAIToolFunction
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class OpenAITool(BaseModel):
|
| 47 |
+
type: str = "function"
|
| 48 |
+
function: Dict[str, Any]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class OpenAIChatMessage(BaseModel):
|
| 52 |
+
role: str
|
| 53 |
+
content: Union[str, List[Dict[str, Any]], None] = None
|
| 54 |
+
reasoning_content: Optional[str] = None
|
| 55 |
+
name: Optional[str] = None
|
| 56 |
+
tool_calls: Optional[List[OpenAIToolCall]] = None
|
| 57 |
+
tool_call_id: Optional[str] = None # for role="tool"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class OpenAIChatCompletionRequest(BaseModel):
|
| 61 |
+
model: str
|
| 62 |
+
messages: List[OpenAIChatMessage]
|
| 63 |
+
stream: bool = False
|
| 64 |
+
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
| 65 |
+
top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 66 |
+
max_tokens: Optional[int] = Field(None, ge=1)
|
| 67 |
+
stop: Optional[Union[str, List[str]]] = None
|
| 68 |
+
frequency_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
|
| 69 |
+
presence_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
|
| 70 |
+
n: Optional[int] = Field(1, ge=1, le=128)
|
| 71 |
+
seed: Optional[int] = None
|
| 72 |
+
response_format: Optional[Dict[str, Any]] = None
|
| 73 |
+
top_k: Optional[int] = Field(None, ge=1)
|
| 74 |
+
tools: Optional[List[OpenAITool]] = None
|
| 75 |
+
tool_choice: Optional[Union[str, Dict[str, Any]]] = None
|
| 76 |
+
|
| 77 |
+
class Config:
|
| 78 |
+
extra = "allow" # Allow additional fields not explicitly defined
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# 通用的聊天完成请求模型(兼容OpenAI和其他格式)
|
| 82 |
+
ChatCompletionRequest = OpenAIChatCompletionRequest
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class OpenAIChatCompletionChoice(BaseModel):
|
| 86 |
+
index: int
|
| 87 |
+
message: OpenAIChatMessage
|
| 88 |
+
finish_reason: Optional[str] = None
|
| 89 |
+
logprobs: Optional[Dict[str, Any]] = None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class OpenAIChatCompletionResponse(BaseModel):
|
| 93 |
+
id: str
|
| 94 |
+
object: str = "chat.completion"
|
| 95 |
+
created: int
|
| 96 |
+
model: str
|
| 97 |
+
choices: List[OpenAIChatCompletionChoice]
|
| 98 |
+
usage: Optional[Dict[str, int]] = None
|
| 99 |
+
system_fingerprint: Optional[str] = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class OpenAIDelta(BaseModel):
|
| 103 |
+
role: Optional[str] = None
|
| 104 |
+
content: Optional[str] = None
|
| 105 |
+
reasoning_content: Optional[str] = None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class OpenAIChatCompletionStreamChoice(BaseModel):
|
| 109 |
+
index: int
|
| 110 |
+
delta: OpenAIDelta
|
| 111 |
+
finish_reason: Optional[str] = None
|
| 112 |
+
logprobs: Optional[Dict[str, Any]] = None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class OpenAIChatCompletionStreamResponse(BaseModel):
|
| 116 |
+
id: str
|
| 117 |
+
object: str = "chat.completion.chunk"
|
| 118 |
+
created: int
|
| 119 |
+
model: str
|
| 120 |
+
choices: List[OpenAIChatCompletionStreamChoice]
|
| 121 |
+
system_fingerprint: Optional[str] = None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# Gemini Models
|
| 125 |
+
class GeminiPart(BaseModel):
|
| 126 |
+
text: Optional[str] = None
|
| 127 |
+
inlineData: Optional[Dict[str, Any]] = None
|
| 128 |
+
fileData: Optional[Dict[str, Any]] = None
|
| 129 |
+
thought: Optional[bool] = None # 改为 None,避免序列化时包含 False
|
| 130 |
+
|
| 131 |
+
class Config:
|
| 132 |
+
extra = "allow" # 允许额外字段(如 functionCall, functionResponse)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class GeminiContent(BaseModel):
|
| 136 |
+
role: str
|
| 137 |
+
parts: List[GeminiPart]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class GeminiSystemInstruction(BaseModel):
|
| 141 |
+
parts: List[GeminiPart]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class GeminiImageConfig(BaseModel):
|
| 145 |
+
"""图片生成配置"""
|
| 146 |
+
aspect_ratio: Optional[str] = None # "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"
|
| 147 |
+
image_size: Optional[str] = None # "1K", "2K", "4K"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class GeminiGenerationConfig(BaseModel):
|
| 151 |
+
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
| 152 |
+
topP: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 153 |
+
topK: Optional[int] = Field(None, ge=1)
|
| 154 |
+
maxOutputTokens: Optional[int] = Field(None, ge=1)
|
| 155 |
+
stopSequences: Optional[List[str]] = None
|
| 156 |
+
responseMimeType: Optional[str] = None
|
| 157 |
+
responseSchema: Optional[Dict[str, Any]] = None
|
| 158 |
+
candidateCount: Optional[int] = Field(None, ge=1, le=8)
|
| 159 |
+
seed: Optional[int] = None
|
| 160 |
+
frequencyPenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
|
| 161 |
+
presencePenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
|
| 162 |
+
thinkingConfig: Optional[Dict[str, Any]] = None
|
| 163 |
+
# 图片生成相关参数
|
| 164 |
+
response_modalities: Optional[List[str]] = None # ["TEXT", "IMAGE"]
|
| 165 |
+
image_config: Optional[GeminiImageConfig] = None
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class GeminiSafetySetting(BaseModel):
|
| 169 |
+
category: str
|
| 170 |
+
threshold: str
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class GeminiRequest(BaseModel):
|
| 174 |
+
contents: List[GeminiContent]
|
| 175 |
+
systemInstruction: Optional[GeminiSystemInstruction] = None
|
| 176 |
+
generationConfig: Optional[GeminiGenerationConfig] = None
|
| 177 |
+
safetySettings: Optional[List[GeminiSafetySetting]] = None
|
| 178 |
+
tools: Optional[List[Dict[str, Any]]] = None
|
| 179 |
+
toolConfig: Optional[Dict[str, Any]] = None
|
| 180 |
+
cachedContent: Optional[str] = None
|
| 181 |
+
|
| 182 |
+
class Config:
|
| 183 |
+
extra = "allow" # 允许透传未定义的字段
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class GeminiCandidate(BaseModel):
|
| 187 |
+
content: GeminiContent
|
| 188 |
+
finishReason: Optional[str] = None
|
| 189 |
+
index: int = 0
|
| 190 |
+
safetyRatings: Optional[List[Dict[str, Any]]] = None
|
| 191 |
+
citationMetadata: Optional[Dict[str, Any]] = None
|
| 192 |
+
tokenCount: Optional[int] = None
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class GeminiUsageMetadata(BaseModel):
|
| 196 |
+
promptTokenCount: Optional[int] = None
|
| 197 |
+
candidatesTokenCount: Optional[int] = None
|
| 198 |
+
totalTokenCount: Optional[int] = None
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class GeminiResponse(BaseModel):
|
| 202 |
+
candidates: List[GeminiCandidate]
|
| 203 |
+
usageMetadata: Optional[GeminiUsageMetadata] = None
|
| 204 |
+
modelVersion: Optional[str] = None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# Claude Models
|
| 208 |
+
class ClaudeContentBlock(BaseModel):
|
| 209 |
+
type: str # "text", "image", "tool_use", "tool_result"
|
| 210 |
+
text: Optional[str] = None
|
| 211 |
+
source: Optional[Dict[str, Any]] = None # for image type
|
| 212 |
+
id: Optional[str] = None # for tool_use
|
| 213 |
+
name: Optional[str] = None # for tool_use
|
| 214 |
+
input: Optional[Dict[str, Any]] = None # for tool_use
|
| 215 |
+
tool_use_id: Optional[str] = None # for tool_result
|
| 216 |
+
content: Optional[Union[str, List[Dict[str, Any]]]] = None # for tool_result
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class ClaudeMessage(BaseModel):
|
| 220 |
+
role: str # "user" or "assistant"
|
| 221 |
+
content: Union[str, List[ClaudeContentBlock]]
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class ClaudeTool(BaseModel):
|
| 225 |
+
name: str
|
| 226 |
+
description: Optional[str] = None
|
| 227 |
+
input_schema: Dict[str, Any]
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class ClaudeMetadata(BaseModel):
|
| 231 |
+
user_id: Optional[str] = None
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class ClaudeRequest(BaseModel):
|
| 235 |
+
model: str
|
| 236 |
+
messages: List[ClaudeMessage]
|
| 237 |
+
max_tokens: int = Field(..., ge=1)
|
| 238 |
+
system: Optional[Union[str, List[Dict[str, Any]]]] = None
|
| 239 |
+
temperature: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 240 |
+
top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 241 |
+
top_k: Optional[int] = Field(None, ge=1)
|
| 242 |
+
stop_sequences: Optional[List[str]] = None
|
| 243 |
+
stream: bool = False
|
| 244 |
+
metadata: Optional[ClaudeMetadata] = None
|
| 245 |
+
tools: Optional[List[ClaudeTool]] = None
|
| 246 |
+
tool_choice: Optional[Union[str, Dict[str, Any]]] = None
|
| 247 |
+
|
| 248 |
+
class Config:
|
| 249 |
+
extra = "allow"
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class ClaudeUsage(BaseModel):
|
| 253 |
+
input_tokens: int
|
| 254 |
+
output_tokens: int
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class ClaudeResponse(BaseModel):
|
| 258 |
+
id: str
|
| 259 |
+
type: str = "message"
|
| 260 |
+
role: str = "assistant"
|
| 261 |
+
content: List[ClaudeContentBlock]
|
| 262 |
+
model: str
|
| 263 |
+
stop_reason: Optional[str] = None
|
| 264 |
+
stop_sequence: Optional[str] = None
|
| 265 |
+
usage: ClaudeUsage
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class ClaudeStreamEvent(BaseModel):
|
| 269 |
+
type: str # "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"
|
| 270 |
+
message: Optional[ClaudeResponse] = None
|
| 271 |
+
index: Optional[int] = None
|
| 272 |
+
content_block: Optional[ClaudeContentBlock] = None
|
| 273 |
+
delta: Optional[Dict[str, Any]] = None
|
| 274 |
+
usage: Optional[ClaudeUsage] = None
|
| 275 |
+
|
| 276 |
+
class Config:
|
| 277 |
+
extra = "allow"
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# Error Models
|
| 281 |
+
class APIError(BaseModel):
|
| 282 |
+
message: str
|
| 283 |
+
type: str = "api_error"
|
| 284 |
+
code: Optional[int] = None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
class ErrorResponse(BaseModel):
|
| 288 |
+
error: APIError
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
# Control Panel Models
|
| 292 |
+
class SystemStatus(BaseModel):
|
| 293 |
+
status: str
|
| 294 |
+
timestamp: str
|
| 295 |
+
credentials: Dict[str, int]
|
| 296 |
+
config: Dict[str, Any]
|
| 297 |
+
current_credential: str
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
class CredentialInfo(BaseModel):
|
| 301 |
+
filename: str
|
| 302 |
+
project_id: Optional[str] = None
|
| 303 |
+
status: Dict[str, Any]
|
| 304 |
+
size: Optional[int] = None
|
| 305 |
+
modified_time: Optional[str] = None
|
| 306 |
+
error: Optional[str] = None
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
class LogEntry(BaseModel):
|
| 310 |
+
timestamp: str
|
| 311 |
+
level: str
|
| 312 |
+
message: str
|
| 313 |
+
module: Optional[str] = None
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ConfigValue(BaseModel):
|
| 317 |
+
key: str
|
| 318 |
+
value: Any
|
| 319 |
+
env_locked: bool = False
|
| 320 |
+
description: Optional[str] = None
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# Authentication Models
|
| 324 |
+
class AuthRequest(BaseModel):
|
| 325 |
+
project_id: Optional[str] = None
|
| 326 |
+
user_session: Optional[str] = None
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
class AuthResponse(BaseModel):
|
| 330 |
+
success: bool
|
| 331 |
+
auth_url: Optional[str] = None
|
| 332 |
+
state: Optional[str] = None
|
| 333 |
+
error: Optional[str] = None
|
| 334 |
+
credentials: Optional[Dict[str, Any]] = None
|
| 335 |
+
file_path: Optional[str] = None
|
| 336 |
+
requires_manual_project_id: Optional[bool] = None
|
| 337 |
+
requires_project_selection: Optional[bool] = None
|
| 338 |
+
available_projects: Optional[List[Dict[str, str]]] = None
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
class CredentialStatus(BaseModel):
|
| 342 |
+
disabled: bool = False
|
| 343 |
+
error_codes: List[int] = []
|
| 344 |
+
last_success: Optional[str] = None
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# Web Routes Models
|
| 348 |
+
class LoginRequest(BaseModel):
|
| 349 |
+
password: str
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class AuthStartRequest(BaseModel):
|
| 353 |
+
project_id: Optional[str] = None # 现在是可选的
|
| 354 |
+
mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class AuthCallbackRequest(BaseModel):
|
| 358 |
+
project_id: Optional[str] = None # 现在是可选的
|
| 359 |
+
mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
class AuthCallbackUrlRequest(BaseModel):
|
| 363 |
+
callback_url: str # OAuth回调完整URL
|
| 364 |
+
project_id: Optional[str] = None # 可选的项目ID
|
| 365 |
+
mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
class CredFileActionRequest(BaseModel):
|
| 369 |
+
filename: str
|
| 370 |
+
action: str # enable, disable, delete
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
class CredFileBatchActionRequest(BaseModel):
|
| 374 |
+
action: str # "enable", "disable", "delete"
|
| 375 |
+
filenames: List[str] # 批量操作的文件名列表
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
class ConfigSaveRequest(BaseModel):
|
| 379 |
+
config: dict
|
src/panel/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Panel模块 - 整合所有控制面板路由
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
from . import auth, creds, config_routes, logs, version, root
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def create_router() -> APIRouter:
|
| 11 |
+
"""创建并返回整合所有子路由的主路由器"""
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
# 包含所有子路由
|
| 15 |
+
router.include_router(root.router)
|
| 16 |
+
router.include_router(auth.router)
|
| 17 |
+
router.include_router(creds.router)
|
| 18 |
+
router.include_router(config_routes.router)
|
| 19 |
+
router.include_router(logs.router)
|
| 20 |
+
router.include_router(version.router)
|
| 21 |
+
|
| 22 |
+
return router
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# 导出主路由器
|
| 26 |
+
router = create_router()
|
| 27 |
+
|
| 28 |
+
# 导出常用工具
|
| 29 |
+
from .utils import ConnectionManager, is_mobile_user_agent, validate_mode, get_env_locked_keys
|
| 30 |
+
|
| 31 |
+
__all__ = [
|
| 32 |
+
"router",
|
| 33 |
+
"ConnectionManager",
|
| 34 |
+
"is_mobile_user_agent",
|
| 35 |
+
"validate_mode",
|
| 36 |
+
"get_env_locked_keys",
|
| 37 |
+
]
|
src/panel/auth.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
认证路由模块 - 处理 /auth/* 相关的HTTP请求
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
|
| 8 |
+
from log import log
|
| 9 |
+
from src.auth import (
|
| 10 |
+
asyncio_complete_auth_flow,
|
| 11 |
+
complete_auth_flow_from_callback_url,
|
| 12 |
+
create_auth_url,
|
| 13 |
+
get_auth_status,
|
| 14 |
+
verify_password,
|
| 15 |
+
)
|
| 16 |
+
from src.models import (
|
| 17 |
+
LoginRequest,
|
| 18 |
+
AuthStartRequest,
|
| 19 |
+
AuthCallbackRequest,
|
| 20 |
+
AuthCallbackUrlRequest,
|
| 21 |
+
)
|
| 22 |
+
from src.utils import verify_panel_token
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# 创建路由器
|
| 26 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post("/login")
|
| 30 |
+
async def login(request: LoginRequest):
|
| 31 |
+
"""用户登录(简化版:直接返回密码作为token)"""
|
| 32 |
+
try:
|
| 33 |
+
if await verify_password(request.password):
|
| 34 |
+
# 直接使用密码作为token,简化认证流程
|
| 35 |
+
return JSONResponse(content={"token": request.password, "message": "登录成功"})
|
| 36 |
+
else:
|
| 37 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
| 38 |
+
except HTTPException:
|
| 39 |
+
raise
|
| 40 |
+
except Exception as e:
|
| 41 |
+
log.error(f"登录失败: {e}")
|
| 42 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post("/start")
|
| 46 |
+
async def start_auth(request: AuthStartRequest, token: str = Depends(verify_panel_token)):
|
| 47 |
+
"""开始认证流程,支持自动检测项目ID"""
|
| 48 |
+
try:
|
| 49 |
+
# 如果没有提供项目ID,尝试自动检测
|
| 50 |
+
project_id = request.project_id
|
| 51 |
+
if not project_id:
|
| 52 |
+
log.info("用户未提供项目ID,后续将使用自动检测...")
|
| 53 |
+
|
| 54 |
+
# 使用认证令牌作为用户会话标识
|
| 55 |
+
user_session = token if token else None
|
| 56 |
+
result = await create_auth_url(
|
| 57 |
+
project_id, user_session, mode=request.mode
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
if result["success"]:
|
| 61 |
+
return JSONResponse(
|
| 62 |
+
content={
|
| 63 |
+
"auth_url": result["auth_url"],
|
| 64 |
+
"state": result["state"],
|
| 65 |
+
"auto_project_detection": result.get("auto_project_detection", False),
|
| 66 |
+
"detected_project_id": result.get("detected_project_id"),
|
| 67 |
+
}
|
| 68 |
+
)
|
| 69 |
+
else:
|
| 70 |
+
raise HTTPException(status_code=500, detail=result["error"])
|
| 71 |
+
|
| 72 |
+
except HTTPException:
|
| 73 |
+
raise
|
| 74 |
+
except Exception as e:
|
| 75 |
+
log.error(f"开始认证流程失败: {e}")
|
| 76 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.post("/callback")
|
| 80 |
+
async def auth_callback(request: AuthCallbackRequest, token: str = Depends(verify_panel_token)):
|
| 81 |
+
"""处理认证回调,支持自动检测项目ID"""
|
| 82 |
+
try:
|
| 83 |
+
# 项目ID现在是可选的,在回调处理中进行自动检测
|
| 84 |
+
project_id = request.project_id
|
| 85 |
+
|
| 86 |
+
# 使用认证令牌作为用户会话标识
|
| 87 |
+
user_session = token if token else None
|
| 88 |
+
# 异步等待OAuth回调完成
|
| 89 |
+
result = await asyncio_complete_auth_flow(
|
| 90 |
+
project_id, user_session, mode=request.mode
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if result["success"]:
|
| 94 |
+
# 单项目认证成功
|
| 95 |
+
return JSONResponse(
|
| 96 |
+
content={
|
| 97 |
+
"credentials": result["credentials"],
|
| 98 |
+
"file_path": result["file_path"],
|
| 99 |
+
"message": "认证成功,凭证已保存",
|
| 100 |
+
"auto_detected_project": result.get("auto_detected_project", False),
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
else:
|
| 104 |
+
# 如果需要手动项目ID或项目选择,在响应中标明
|
| 105 |
+
if result.get("requires_manual_project_id"):
|
| 106 |
+
# 使用JSON响应
|
| 107 |
+
return JSONResponse(
|
| 108 |
+
status_code=400,
|
| 109 |
+
content={"error": result["error"], "requires_manual_project_id": True},
|
| 110 |
+
)
|
| 111 |
+
elif result.get("requires_project_selection"):
|
| 112 |
+
# 返回项目列表供用户选择
|
| 113 |
+
return JSONResponse(
|
| 114 |
+
status_code=400,
|
| 115 |
+
content={
|
| 116 |
+
"error": result["error"],
|
| 117 |
+
"requires_project_selection": True,
|
| 118 |
+
"available_projects": result["available_projects"],
|
| 119 |
+
},
|
| 120 |
+
)
|
| 121 |
+
else:
|
| 122 |
+
raise HTTPException(status_code=400, detail=result["error"])
|
| 123 |
+
|
| 124 |
+
except HTTPException:
|
| 125 |
+
raise
|
| 126 |
+
except Exception as e:
|
| 127 |
+
log.error(f"处理认证回调失败: {e}")
|
| 128 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@router.post("/callback-url")
|
| 132 |
+
async def auth_callback_url(request: AuthCallbackUrlRequest, token: str = Depends(verify_panel_token)):
|
| 133 |
+
"""从回调URL直接完成认证"""
|
| 134 |
+
try:
|
| 135 |
+
# 验证URL格式
|
| 136 |
+
if not request.callback_url or not request.callback_url.startswith(("http://", "https://")):
|
| 137 |
+
raise HTTPException(status_code=400, detail="请提供有效的回调URL")
|
| 138 |
+
|
| 139 |
+
# 从回调URL完成认证
|
| 140 |
+
result = await complete_auth_flow_from_callback_url(
|
| 141 |
+
request.callback_url, request.project_id, mode=request.mode
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
if result["success"]:
|
| 145 |
+
# 单项目认证成功
|
| 146 |
+
return JSONResponse(
|
| 147 |
+
content={
|
| 148 |
+
"credentials": result["credentials"],
|
| 149 |
+
"file_path": result["file_path"],
|
| 150 |
+
"message": "从回调URL认证成功,凭证已保存",
|
| 151 |
+
"auto_detected_project": result.get("auto_detected_project", False),
|
| 152 |
+
}
|
| 153 |
+
)
|
| 154 |
+
else:
|
| 155 |
+
# 处理各种错误情况
|
| 156 |
+
if result.get("requires_manual_project_id"):
|
| 157 |
+
return JSONResponse(
|
| 158 |
+
status_code=400,
|
| 159 |
+
content={"error": result["error"], "requires_manual_project_id": True},
|
| 160 |
+
)
|
| 161 |
+
elif result.get("requires_project_selection"):
|
| 162 |
+
return JSONResponse(
|
| 163 |
+
status_code=400,
|
| 164 |
+
content={
|
| 165 |
+
"error": result["error"],
|
| 166 |
+
"requires_project_selection": True,
|
| 167 |
+
"available_projects": result["available_projects"],
|
| 168 |
+
},
|
| 169 |
+
)
|
| 170 |
+
else:
|
| 171 |
+
raise HTTPException(status_code=400, detail=result["error"])
|
| 172 |
+
|
| 173 |
+
except HTTPException:
|
| 174 |
+
raise
|
| 175 |
+
except Exception as e:
|
| 176 |
+
log.error(f"从回调URL处理认证失败: {e}")
|
| 177 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.get("/status/{project_id}")
|
| 181 |
+
async def check_auth_status(project_id: str, token: str = Depends(verify_panel_token)):
|
| 182 |
+
"""检查认证状态"""
|
| 183 |
+
try:
|
| 184 |
+
if not project_id:
|
| 185 |
+
raise HTTPException(status_code=400, detail="Project ID 不能为空")
|
| 186 |
+
|
| 187 |
+
status = get_auth_status(project_id)
|
| 188 |
+
return JSONResponse(content=status)
|
| 189 |
+
|
| 190 |
+
except Exception as e:
|
| 191 |
+
log.error(f"检查认证状态失败: {e}")
|
| 192 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/panel/config_routes.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
配置路由模块 - 处理 /config/* 相关的HTTP请求
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
|
| 8 |
+
import config
|
| 9 |
+
from log import log
|
| 10 |
+
from src.keeplive import keepalive_service
|
| 11 |
+
from src.models import ConfigSaveRequest
|
| 12 |
+
from src.storage_adapter import get_storage_adapter
|
| 13 |
+
from src.utils import verify_panel_token
|
| 14 |
+
from .utils import get_env_locked_keys
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# 创建路由器
|
| 18 |
+
router = APIRouter(prefix="/config", tags=["config"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("/get")
|
| 22 |
+
async def get_config(token: str = Depends(verify_panel_token)):
|
| 23 |
+
"""获取当前配置"""
|
| 24 |
+
try:
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# 读取当前配置(包括环境变量和TOML文件中的配置)
|
| 28 |
+
current_config = {}
|
| 29 |
+
|
| 30 |
+
# 基础配置
|
| 31 |
+
current_config["code_assist_endpoint"] = await config.get_code_assist_endpoint()
|
| 32 |
+
current_config["credentials_dir"] = await config.get_credentials_dir()
|
| 33 |
+
current_config["proxy"] = await config.get_proxy_config() or ""
|
| 34 |
+
|
| 35 |
+
# 代理端点配置
|
| 36 |
+
current_config["oauth_proxy_url"] = await config.get_oauth_proxy_url()
|
| 37 |
+
current_config["googleapis_proxy_url"] = await config.get_googleapis_proxy_url()
|
| 38 |
+
current_config["resource_manager_api_url"] = await config.get_resource_manager_api_url()
|
| 39 |
+
current_config["service_usage_api_url"] = await config.get_service_usage_api_url()
|
| 40 |
+
|
| 41 |
+
# 自动封禁配置
|
| 42 |
+
current_config["auto_ban_enabled"] = await config.get_auto_ban_enabled()
|
| 43 |
+
current_config["auto_ban_error_codes"] = await config.get_auto_ban_error_codes()
|
| 44 |
+
|
| 45 |
+
# 429重试配置
|
| 46 |
+
current_config["retry_429_max_retries"] = await config.get_retry_429_max_retries()
|
| 47 |
+
current_config["retry_429_enabled"] = await config.get_retry_429_enabled()
|
| 48 |
+
current_config["retry_429_interval"] = await config.get_retry_429_interval()
|
| 49 |
+
# 抗截断配置
|
| 50 |
+
current_config["anti_truncation_max_attempts"] = await config.get_anti_truncation_max_attempts()
|
| 51 |
+
|
| 52 |
+
# 兼容性配置
|
| 53 |
+
current_config["compatibility_mode_enabled"] = await config.get_compatibility_mode_enabled()
|
| 54 |
+
|
| 55 |
+
# 思维链返回配置
|
| 56 |
+
current_config["return_thoughts_to_frontend"] = await config.get_return_thoughts_to_frontend()
|
| 57 |
+
|
| 58 |
+
# Antigravity流式转非流式配置
|
| 59 |
+
current_config["antigravity_stream2nostream"] = await config.get_antigravity_stream2nostream()
|
| 60 |
+
|
| 61 |
+
# 保活配置
|
| 62 |
+
current_config["keepalive_url"] = await config.get_keepalive_url()
|
| 63 |
+
current_config["keepalive_interval"] = await config.get_keepalive_interval()
|
| 64 |
+
|
| 65 |
+
# 服务器配置
|
| 66 |
+
current_config["host"] = await config.get_server_host()
|
| 67 |
+
current_config["port"] = await config.get_server_port()
|
| 68 |
+
current_config["api_password"] = await config.get_api_password()
|
| 69 |
+
current_config["panel_password"] = await config.get_panel_password()
|
| 70 |
+
current_config["password"] = await config.get_server_password()
|
| 71 |
+
|
| 72 |
+
# 从存储系统读取配置
|
| 73 |
+
storage_adapter = await get_storage_adapter()
|
| 74 |
+
storage_config = await storage_adapter.get_all_config()
|
| 75 |
+
|
| 76 |
+
# 获取环境变量锁定的配置键
|
| 77 |
+
env_locked_keys = get_env_locked_keys()
|
| 78 |
+
|
| 79 |
+
# 合并存储系统配置(不覆盖环境变量)
|
| 80 |
+
for key, value in storage_config.items():
|
| 81 |
+
if key not in env_locked_keys:
|
| 82 |
+
current_config[key] = value
|
| 83 |
+
|
| 84 |
+
return JSONResponse(content={"config": current_config, "env_locked": list(env_locked_keys)})
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
log.error(f"获取配置失败: {e}")
|
| 88 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post("/save")
|
| 92 |
+
async def save_config(request: ConfigSaveRequest, token: str = Depends(verify_panel_token)):
|
| 93 |
+
"""保存配置"""
|
| 94 |
+
try:
|
| 95 |
+
|
| 96 |
+
new_config = request.config
|
| 97 |
+
|
| 98 |
+
log.debug(f"收到的配置数据: {list(new_config.keys())}")
|
| 99 |
+
log.debug(f"收到的password值: {new_config.get('password', 'NOT_FOUND')}")
|
| 100 |
+
|
| 101 |
+
# 验证配置项
|
| 102 |
+
if "retry_429_max_retries" in new_config:
|
| 103 |
+
if (
|
| 104 |
+
not isinstance(new_config["retry_429_max_retries"], int)
|
| 105 |
+
or new_config["retry_429_max_retries"] < 0
|
| 106 |
+
):
|
| 107 |
+
raise HTTPException(status_code=400, detail="最大429重试次数必须是大于等于0的整数")
|
| 108 |
+
|
| 109 |
+
if "retry_429_enabled" in new_config:
|
| 110 |
+
if not isinstance(new_config["retry_429_enabled"], bool):
|
| 111 |
+
raise HTTPException(status_code=400, detail="429重试开关必须是布尔值")
|
| 112 |
+
|
| 113 |
+
# 验证新的配置项
|
| 114 |
+
if "retry_429_interval" in new_config:
|
| 115 |
+
try:
|
| 116 |
+
interval = float(new_config["retry_429_interval"])
|
| 117 |
+
if interval < 0.01 or interval > 10:
|
| 118 |
+
raise HTTPException(status_code=400, detail="429重试间隔必须在0.01-10秒之间")
|
| 119 |
+
except (ValueError, TypeError):
|
| 120 |
+
raise HTTPException(status_code=400, detail="429重试间隔必须是有效的数字")
|
| 121 |
+
|
| 122 |
+
if "anti_truncation_max_attempts" in new_config:
|
| 123 |
+
if (
|
| 124 |
+
not isinstance(new_config["anti_truncation_max_attempts"], int)
|
| 125 |
+
or new_config["anti_truncation_max_attempts"] < 1
|
| 126 |
+
or new_config["anti_truncation_max_attempts"] > 10
|
| 127 |
+
):
|
| 128 |
+
raise HTTPException(
|
| 129 |
+
status_code=400, detail="抗截断最大重试次数必须是1-10之间的整数"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
if "compatibility_mode_enabled" in new_config:
|
| 133 |
+
if not isinstance(new_config["compatibility_mode_enabled"], bool):
|
| 134 |
+
raise HTTPException(status_code=400, detail="兼容性模式开关必须是布尔值")
|
| 135 |
+
|
| 136 |
+
if "return_thoughts_to_frontend" in new_config:
|
| 137 |
+
if not isinstance(new_config["return_thoughts_to_frontend"], bool):
|
| 138 |
+
raise HTTPException(status_code=400, detail="思维链返回开关必须是布尔值")
|
| 139 |
+
|
| 140 |
+
if "antigravity_stream2nostream" in new_config:
|
| 141 |
+
if not isinstance(new_config["antigravity_stream2nostream"], bool):
|
| 142 |
+
raise HTTPException(status_code=400, detail="Antigravity流式转非流式开关必须是布尔值")
|
| 143 |
+
|
| 144 |
+
# 验证保活配置
|
| 145 |
+
if "keepalive_url" in new_config:
|
| 146 |
+
if not isinstance(new_config["keepalive_url"], str):
|
| 147 |
+
raise HTTPException(status_code=400, detail="保活URL必须是字符串")
|
| 148 |
+
|
| 149 |
+
if "keepalive_interval" in new_config:
|
| 150 |
+
try:
|
| 151 |
+
interval = int(new_config["keepalive_interval"])
|
| 152 |
+
if interval < 5 or interval > 86400:
|
| 153 |
+
raise HTTPException(status_code=400, detail="保活间隔必须在 5-86400 秒之间")
|
| 154 |
+
new_config["keepalive_interval"] = interval
|
| 155 |
+
except (ValueError, TypeError):
|
| 156 |
+
raise HTTPException(status_code=400, detail="保活间隔必须是有效整数")
|
| 157 |
+
# 验证服务器配置
|
| 158 |
+
if "host" in new_config:
|
| 159 |
+
if not isinstance(new_config["host"], str) or not new_config["host"].strip():
|
| 160 |
+
raise HTTPException(status_code=400, detail="服务器主机地址不能为空")
|
| 161 |
+
|
| 162 |
+
if "port" in new_config:
|
| 163 |
+
if (
|
| 164 |
+
not isinstance(new_config["port"], int)
|
| 165 |
+
or new_config["port"] < 1
|
| 166 |
+
or new_config["port"] > 65535
|
| 167 |
+
):
|
| 168 |
+
raise HTTPException(status_code=400, detail="端口号必须是1-65535之间的整数")
|
| 169 |
+
|
| 170 |
+
if "api_password" in new_config:
|
| 171 |
+
if not isinstance(new_config["api_password"], str):
|
| 172 |
+
raise HTTPException(status_code=400, detail="API访问密码必须是字符串")
|
| 173 |
+
|
| 174 |
+
if "panel_password" in new_config:
|
| 175 |
+
if not isinstance(new_config["panel_password"], str):
|
| 176 |
+
raise HTTPException(status_code=400, detail="控制面板密码必须是字符串")
|
| 177 |
+
|
| 178 |
+
if "password" in new_config:
|
| 179 |
+
if not isinstance(new_config["password"], str):
|
| 180 |
+
raise HTTPException(status_code=400, detail="访问密码必须是字符串")
|
| 181 |
+
|
| 182 |
+
# 获取环境变量锁定的配置键
|
| 183 |
+
env_locked_keys = get_env_locked_keys()
|
| 184 |
+
|
| 185 |
+
# 直接使用存储适配器保存配置
|
| 186 |
+
storage_adapter = await get_storage_adapter()
|
| 187 |
+
for key, value in new_config.items():
|
| 188 |
+
if key not in env_locked_keys:
|
| 189 |
+
await storage_adapter.set_config(key, value)
|
| 190 |
+
if key in ("password", "api_password", "panel_password"):
|
| 191 |
+
log.debug(f"设置{key}字段为: {value}")
|
| 192 |
+
|
| 193 |
+
# 重新加载配置缓存(关键!)
|
| 194 |
+
await config.reload_config()
|
| 195 |
+
|
| 196 |
+
# 如果保活相关配置发生变化,立即重启保活服务
|
| 197 |
+
keepalive_keys = {"keepalive_url", "keepalive_interval"}
|
| 198 |
+
if keepalive_keys & set(new_config.keys()):
|
| 199 |
+
try:
|
| 200 |
+
await keepalive_service.restart()
|
| 201 |
+
except Exception as e:
|
| 202 |
+
log.warning(f"重启保活服务失败: {e}")
|
| 203 |
+
|
| 204 |
+
# 验证保存后的结果
|
| 205 |
+
test_api_password = await config.get_api_password()
|
| 206 |
+
test_panel_password = await config.get_panel_password()
|
| 207 |
+
test_password = await config.get_server_password()
|
| 208 |
+
log.debug(f"保存后立即读取的API密码: {test_api_password}")
|
| 209 |
+
log.debug(f"保存后立即读取的面板密码: {test_panel_password}")
|
| 210 |
+
log.debug(f"保存后立即读取的通用密码: {test_password}")
|
| 211 |
+
|
| 212 |
+
# 构建响应消息
|
| 213 |
+
response_data = {
|
| 214 |
+
"message": "配置保存成功",
|
| 215 |
+
"saved_config": {k: v for k, v in new_config.items() if k not in env_locked_keys},
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return JSONResponse(content=response_data)
|
| 219 |
+
|
| 220 |
+
except HTTPException:
|
| 221 |
+
raise
|
| 222 |
+
except Exception as e:
|
| 223 |
+
log.error(f"保存配置失败: {e}")
|
| 224 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/panel/creds.py
ADDED
|
@@ -0,0 +1,1585 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
凭证管理路由模块 - 处理 /creds/* 相关的HTTP请求
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import zipfile
|
| 11 |
+
from typing import Any, List
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
from log import log
|
| 17 |
+
from src.credential_manager import credential_manager
|
| 18 |
+
from src.models import (
|
| 19 |
+
CredFileActionRequest,
|
| 20 |
+
CredFileBatchActionRequest
|
| 21 |
+
)
|
| 22 |
+
from src.storage_adapter import get_storage_adapter
|
| 23 |
+
from src.utils import verify_panel_token, GEMINICLI_USER_AGENT, ANTIGRAVITY_USER_AGENT
|
| 24 |
+
from src.api.antigravity import fetch_quota_info
|
| 25 |
+
from src.google_oauth_api import Credentials, fetch_project_id_and_tier
|
| 26 |
+
from config import get_code_assist_endpoint
|
| 27 |
+
from .utils import validate_mode
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# 创建路由器
|
| 31 |
+
router = APIRouter(prefix="/creds", tags=["credentials"])
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# =============================================================================
|
| 35 |
+
# 工具函数 (Helper Functions)
|
| 36 |
+
# =============================================================================
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def extract_json_files_from_zip(zip_file: UploadFile) -> List[dict]:
|
| 40 |
+
"""从ZIP文件中提取JSON文件"""
|
| 41 |
+
try:
|
| 42 |
+
# 读取ZIP文件内容
|
| 43 |
+
zip_content = await zip_file.read()
|
| 44 |
+
|
| 45 |
+
# 不限制ZIP文件大小,只在处理时控制文件数量
|
| 46 |
+
|
| 47 |
+
files_data = []
|
| 48 |
+
|
| 49 |
+
with zipfile.ZipFile(io.BytesIO(zip_content), "r") as zip_ref:
|
| 50 |
+
# 获取ZIP中的所有文件
|
| 51 |
+
file_list = zip_ref.namelist()
|
| 52 |
+
json_files = [
|
| 53 |
+
f for f in file_list if f.endswith(".json") and not f.startswith("__MACOSX/")
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
if not json_files:
|
| 57 |
+
raise HTTPException(status_code=400, detail="ZIP文件中没有找到JSON文件")
|
| 58 |
+
|
| 59 |
+
log.info(f"从ZIP文件 {zip_file.filename} 中找到 {len(json_files)} 个JSON文件")
|
| 60 |
+
|
| 61 |
+
for json_filename in json_files:
|
| 62 |
+
try:
|
| 63 |
+
# 读取JSON文件内容
|
| 64 |
+
with zip_ref.open(json_filename) as json_file:
|
| 65 |
+
content = json_file.read()
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
content_str = content.decode("utf-8")
|
| 69 |
+
except UnicodeDecodeError:
|
| 70 |
+
log.warning(f"跳过编码错误的文件: {json_filename}")
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
# 使用原始文件名(去掉路径)
|
| 74 |
+
filename = os.path.basename(json_filename)
|
| 75 |
+
files_data.append({"filename": filename, "content": content_str})
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
log.warning(f"处理ZIP中的文件 {json_filename} 时出错: {e}")
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
log.info(f"成功从ZIP文件中提取 {len(files_data)} 个有效的JSON文件")
|
| 82 |
+
return files_data
|
| 83 |
+
|
| 84 |
+
except zipfile.BadZipFile:
|
| 85 |
+
raise HTTPException(status_code=400, detail="无效的ZIP文件格式")
|
| 86 |
+
except Exception as e:
|
| 87 |
+
log.error(f"处理ZIP文件失败: {e}")
|
| 88 |
+
raise HTTPException(status_code=500, detail=f"处理ZIP文件失败: {str(e)}")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def clear_all_model_cooldowns_for_credential(
|
| 92 |
+
storage_adapter: Any,
|
| 93 |
+
filename: str,
|
| 94 |
+
mode: str,
|
| 95 |
+
) -> None:
|
| 96 |
+
"""清空指定凭证的所有模型冷却(后端支持时执行)。"""
|
| 97 |
+
try:
|
| 98 |
+
cleared = await storage_adapter._backend.clear_all_model_cooldowns(filename, mode=mode)
|
| 99 |
+
if not cleared:
|
| 100 |
+
log.warning(f"清空模型CD失败或凭证不存在: {filename} (mode={mode})")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
log.warning(f"清空模型CD时出错: {filename} (mode={mode}), error={e}")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
async def upload_credentials_common(
|
| 106 |
+
files: List[UploadFile], mode: str = "geminicli"
|
| 107 |
+
) -> JSONResponse:
|
| 108 |
+
"""批量上传凭证文件的通用函数"""
|
| 109 |
+
mode = validate_mode(mode)
|
| 110 |
+
|
| 111 |
+
if not files:
|
| 112 |
+
raise HTTPException(status_code=400, detail="请选择要上传的文件")
|
| 113 |
+
|
| 114 |
+
# 检查文件数量限制
|
| 115 |
+
if len(files) > 100:
|
| 116 |
+
raise HTTPException(
|
| 117 |
+
status_code=400, detail=f"文件数量过多,最多支持100个文件,当前:{len(files)}个"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
files_data = []
|
| 121 |
+
for file in files:
|
| 122 |
+
# 检查文件类型:支持JSON和ZIP
|
| 123 |
+
if file.filename.endswith(".zip"):
|
| 124 |
+
zip_files_data = await extract_json_files_from_zip(file)
|
| 125 |
+
files_data.extend(zip_files_data)
|
| 126 |
+
log.info(f"从ZIP文件 {file.filename} 中提取了 {len(zip_files_data)} 个JSON文件")
|
| 127 |
+
|
| 128 |
+
elif file.filename.endswith(".json"):
|
| 129 |
+
# 处理单个JSON文件 - 流式读取
|
| 130 |
+
content_chunks = []
|
| 131 |
+
while True:
|
| 132 |
+
chunk = await file.read(8192)
|
| 133 |
+
if not chunk:
|
| 134 |
+
break
|
| 135 |
+
content_chunks.append(chunk)
|
| 136 |
+
|
| 137 |
+
content = b"".join(content_chunks)
|
| 138 |
+
try:
|
| 139 |
+
content_str = content.decode("utf-8")
|
| 140 |
+
except UnicodeDecodeError:
|
| 141 |
+
raise HTTPException(
|
| 142 |
+
status_code=400, detail=f"文件 {file.filename} 编码格式不支持"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
files_data.append({"filename": file.filename, "content": content_str})
|
| 146 |
+
else:
|
| 147 |
+
raise HTTPException(
|
| 148 |
+
status_code=400, detail=f"文件 {file.filename} 格式不支持,只支持JSON和ZIP文件"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
batch_size = 1000
|
| 154 |
+
all_results = []
|
| 155 |
+
total_success = 0
|
| 156 |
+
|
| 157 |
+
for i in range(0, len(files_data), batch_size):
|
| 158 |
+
batch_files = files_data[i : i + batch_size]
|
| 159 |
+
|
| 160 |
+
async def process_single_file(file_data):
|
| 161 |
+
try:
|
| 162 |
+
filename = file_data["filename"]
|
| 163 |
+
# 确保文件名只保存basename,避免路径问题
|
| 164 |
+
filename = os.path.basename(filename)
|
| 165 |
+
content_str = file_data["content"]
|
| 166 |
+
credential_data = json.loads(content_str)
|
| 167 |
+
|
| 168 |
+
# 根据凭证类型调用不同的添加方法
|
| 169 |
+
if mode == "antigravity":
|
| 170 |
+
await credential_manager.add_antigravity_credential(filename, credential_data)
|
| 171 |
+
else:
|
| 172 |
+
await credential_manager.add_credential(filename, credential_data)
|
| 173 |
+
|
| 174 |
+
log.debug(f"成功上传 {mode} 凭证文件: {filename}")
|
| 175 |
+
return {"filename": filename, "status": "success", "message": "上传成功"}
|
| 176 |
+
|
| 177 |
+
except json.JSONDecodeError as e:
|
| 178 |
+
return {
|
| 179 |
+
"filename": file_data["filename"],
|
| 180 |
+
"status": "error",
|
| 181 |
+
"message": f"JSON格式错误: {str(e)}",
|
| 182 |
+
}
|
| 183 |
+
except Exception as e:
|
| 184 |
+
return {
|
| 185 |
+
"filename": file_data["filename"],
|
| 186 |
+
"status": "error",
|
| 187 |
+
"message": f"处理失败: {str(e)}",
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
log.info(f"开始并发处理 {len(batch_files)} 个 {mode} 文件...")
|
| 191 |
+
concurrent_tasks = [process_single_file(file_data) for file_data in batch_files]
|
| 192 |
+
batch_results = await asyncio.gather(*concurrent_tasks, return_exceptions=True)
|
| 193 |
+
|
| 194 |
+
processed_results = []
|
| 195 |
+
batch_uploaded_count = 0
|
| 196 |
+
for result in batch_results:
|
| 197 |
+
if isinstance(result, Exception):
|
| 198 |
+
processed_results.append(
|
| 199 |
+
{
|
| 200 |
+
"filename": "unknown",
|
| 201 |
+
"status": "error",
|
| 202 |
+
"message": f"处理异常: {str(result)}",
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
else:
|
| 206 |
+
processed_results.append(result)
|
| 207 |
+
if result["status"] == "success":
|
| 208 |
+
batch_uploaded_count += 1
|
| 209 |
+
|
| 210 |
+
all_results.extend(processed_results)
|
| 211 |
+
total_success += batch_uploaded_count
|
| 212 |
+
|
| 213 |
+
batch_num = (i // batch_size) + 1
|
| 214 |
+
total_batches = (len(files_data) + batch_size - 1) // batch_size
|
| 215 |
+
log.info(
|
| 216 |
+
f"批次 {batch_num}/{total_batches} 完成: 成功 "
|
| 217 |
+
f"{batch_uploaded_count}/{len(batch_files)} 个 {mode} 文件"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
if total_success > 0:
|
| 221 |
+
return JSONResponse(
|
| 222 |
+
content={
|
| 223 |
+
"uploaded_count": total_success,
|
| 224 |
+
"total_count": len(files_data),
|
| 225 |
+
"results": all_results,
|
| 226 |
+
"message": f"批量上传完成: 成功 {total_success}/{len(files_data)} 个 {mode} 文件",
|
| 227 |
+
}
|
| 228 |
+
)
|
| 229 |
+
else:
|
| 230 |
+
raise HTTPException(status_code=400, detail=f"没有 {mode} 文件上传成功")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def get_creds_status_common(
|
| 234 |
+
offset: int, limit: int, status_filter: str, mode: str = "geminicli",
|
| 235 |
+
error_code_filter: str = None, cooldown_filter: str = None, preview_filter: str = None, tier_filter: str = None
|
| 236 |
+
) -> JSONResponse:
|
| 237 |
+
"""获取凭证文件状态的通用函数"""
|
| 238 |
+
mode = validate_mode(mode)
|
| 239 |
+
# 验证分页参数
|
| 240 |
+
if offset < 0:
|
| 241 |
+
raise HTTPException(status_code=400, detail="offset 必须大于等于 0")
|
| 242 |
+
if limit not in [20, 50, 100, 200, 500, 1000]:
|
| 243 |
+
raise HTTPException(status_code=400, detail="limit 只能是 20、50、100、200、500 或 1000")
|
| 244 |
+
if status_filter not in ["all", "enabled", "disabled"]:
|
| 245 |
+
raise HTTPException(status_code=400, detail="status_filter 只能是 all、enabled 或 disabled")
|
| 246 |
+
if cooldown_filter and cooldown_filter not in ["all", "in_cooldown", "no_cooldown"]:
|
| 247 |
+
raise HTTPException(status_code=400, detail="cooldown_filter 只能是 all、in_cooldown 或 no_cooldown")
|
| 248 |
+
if preview_filter and preview_filter not in ["all", "preview", "no_preview"]:
|
| 249 |
+
raise HTTPException(status_code=400, detail="preview_filter 只能是 all、preview 或 no_preview")
|
| 250 |
+
if tier_filter and tier_filter not in ["all", "free", "pro", "ultra"]:
|
| 251 |
+
raise HTTPException(status_code=400, detail="tier_filter 只能是 all、free、pro 或 ultra")
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
storage_adapter = await get_storage_adapter()
|
| 256 |
+
backend_info = await storage_adapter.get_backend_info()
|
| 257 |
+
backend_type = backend_info.get("backend_type", "unknown")
|
| 258 |
+
|
| 259 |
+
# 使用高性能的分页摘要查询
|
| 260 |
+
result = await storage_adapter._backend.get_credentials_summary(
|
| 261 |
+
offset=offset,
|
| 262 |
+
limit=limit,
|
| 263 |
+
status_filter=status_filter,
|
| 264 |
+
mode=mode,
|
| 265 |
+
error_code_filter=error_code_filter if error_code_filter and error_code_filter != "all" else None,
|
| 266 |
+
cooldown_filter=cooldown_filter if cooldown_filter and cooldown_filter != "all" else None,
|
| 267 |
+
preview_filter=preview_filter if preview_filter and preview_filter != "all" else None,
|
| 268 |
+
tier_filter=tier_filter if tier_filter and tier_filter != "all" else None
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
creds_list = []
|
| 272 |
+
for summary in result["items"]:
|
| 273 |
+
cred_info = {
|
| 274 |
+
"filename": os.path.basename(summary["filename"]),
|
| 275 |
+
"user_email": summary["user_email"],
|
| 276 |
+
"disabled": summary["disabled"],
|
| 277 |
+
"error_codes": summary["error_codes"],
|
| 278 |
+
"last_success": summary["last_success"],
|
| 279 |
+
"backend_type": backend_type,
|
| 280 |
+
"model_cooldowns": summary.get("model_cooldowns", {}),
|
| 281 |
+
"tier": summary.get("tier", "pro"),
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
if mode == "geminicli":
|
| 285 |
+
cred_info["preview"] = summary.get("preview", True)
|
| 286 |
+
else:
|
| 287 |
+
cred_info["enable_credit"] = summary.get("enable_credit", False)
|
| 288 |
+
|
| 289 |
+
creds_list.append(cred_info)
|
| 290 |
+
|
| 291 |
+
return JSONResponse(content={
|
| 292 |
+
"items": creds_list,
|
| 293 |
+
"total": result["total"],
|
| 294 |
+
"offset": offset,
|
| 295 |
+
"limit": limit,
|
| 296 |
+
"has_more": (offset + limit) < result["total"],
|
| 297 |
+
"stats": result.get("stats", {"total": 0, "normal": 0, "disabled": 0}),
|
| 298 |
+
})
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
async def download_all_creds_common(mode: str = "geminicli") -> Response:
|
| 302 |
+
"""打包下载所有凭证文件的通用函数"""
|
| 303 |
+
mode = validate_mode(mode)
|
| 304 |
+
zip_filename = "antigravity_credentials.zip" if mode == "antigravity" else "credentials.zip"
|
| 305 |
+
|
| 306 |
+
storage_adapter = await get_storage_adapter()
|
| 307 |
+
credential_filenames = await storage_adapter.list_credentials(mode=mode)
|
| 308 |
+
|
| 309 |
+
if not credential_filenames:
|
| 310 |
+
raise HTTPException(status_code=404, detail=f"没有找到 {mode} 凭证文件")
|
| 311 |
+
|
| 312 |
+
log.info(f"开始打包 {len(credential_filenames)} 个 {mode} 凭证文件...")
|
| 313 |
+
|
| 314 |
+
zip_buffer = io.BytesIO()
|
| 315 |
+
|
| 316 |
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
| 317 |
+
success_count = 0
|
| 318 |
+
for idx, filename in enumerate(credential_filenames, 1):
|
| 319 |
+
try:
|
| 320 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 321 |
+
if credential_data:
|
| 322 |
+
content = json.dumps(credential_data, ensure_ascii=False, indent=2)
|
| 323 |
+
zip_file.writestr(os.path.basename(filename), content)
|
| 324 |
+
success_count += 1
|
| 325 |
+
|
| 326 |
+
if idx % 10 == 0:
|
| 327 |
+
log.debug(f"打包进度: {idx}/{len(credential_filenames)}")
|
| 328 |
+
|
| 329 |
+
except Exception as e:
|
| 330 |
+
log.warning(f"处理 {mode} 凭证文件 {filename} 时出错: {e}")
|
| 331 |
+
continue
|
| 332 |
+
|
| 333 |
+
log.info(f"打包完成: 成功 {success_count}/{len(credential_filenames)} 个文件")
|
| 334 |
+
|
| 335 |
+
zip_buffer.seek(0)
|
| 336 |
+
return Response(
|
| 337 |
+
content=zip_buffer.getvalue(),
|
| 338 |
+
media_type="application/zip",
|
| 339 |
+
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
async def fetch_user_email_common(filename: str, mode: str = "geminicli") -> JSONResponse:
|
| 344 |
+
"""获取指定凭证文件用户邮箱的通用函数"""
|
| 345 |
+
mode = validate_mode(mode)
|
| 346 |
+
|
| 347 |
+
filename_only = os.path.basename(filename)
|
| 348 |
+
if not filename_only.endswith(".json"):
|
| 349 |
+
raise HTTPException(status_code=404, detail="无效的文件名")
|
| 350 |
+
|
| 351 |
+
storage_adapter = await get_storage_adapter()
|
| 352 |
+
credential_data = await storage_adapter.get_credential(filename_only, mode=mode)
|
| 353 |
+
if not credential_data:
|
| 354 |
+
raise HTTPException(status_code=404, detail="凭证文件不存在")
|
| 355 |
+
|
| 356 |
+
email = await credential_manager.get_or_fetch_user_email(filename_only, mode=mode)
|
| 357 |
+
|
| 358 |
+
if email:
|
| 359 |
+
return JSONResponse(
|
| 360 |
+
content={
|
| 361 |
+
"filename": filename_only,
|
| 362 |
+
"user_email": email,
|
| 363 |
+
"message": "成功获取用户邮箱",
|
| 364 |
+
}
|
| 365 |
+
)
|
| 366 |
+
else:
|
| 367 |
+
return JSONResponse(
|
| 368 |
+
content={
|
| 369 |
+
"filename": filename_only,
|
| 370 |
+
"user_email": None,
|
| 371 |
+
"message": "无法获取用户邮箱,可能凭证已过期或权限不足",
|
| 372 |
+
},
|
| 373 |
+
status_code=400,
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
async def refresh_all_user_emails_common(mode: str = "geminicli") -> JSONResponse:
|
| 378 |
+
"""刷新所有凭证文件用户邮箱的通用函数 - 只为没有邮箱的凭证获取
|
| 379 |
+
|
| 380 |
+
利用 get_all_credential_states 批量获取状态
|
| 381 |
+
"""
|
| 382 |
+
mode = validate_mode(mode)
|
| 383 |
+
|
| 384 |
+
storage_adapter = await get_storage_adapter()
|
| 385 |
+
|
| 386 |
+
# 一次性批量获取所有凭证的状态
|
| 387 |
+
all_states = await storage_adapter.get_all_credential_states(mode=mode)
|
| 388 |
+
|
| 389 |
+
results = []
|
| 390 |
+
success_count = 0
|
| 391 |
+
skipped_count = 0
|
| 392 |
+
|
| 393 |
+
# 在内存中筛选出需要获取邮箱的凭证
|
| 394 |
+
for filename, state in all_states.items():
|
| 395 |
+
try:
|
| 396 |
+
cached_email = state.get("user_email")
|
| 397 |
+
|
| 398 |
+
if cached_email:
|
| 399 |
+
# 已有邮箱,跳过获取
|
| 400 |
+
skipped_count += 1
|
| 401 |
+
results.append({
|
| 402 |
+
"filename": os.path.basename(filename),
|
| 403 |
+
"user_email": cached_email,
|
| 404 |
+
"success": True,
|
| 405 |
+
"skipped": True,
|
| 406 |
+
})
|
| 407 |
+
continue
|
| 408 |
+
|
| 409 |
+
# 没有邮箱,尝试获取
|
| 410 |
+
email = await credential_manager.get_or_fetch_user_email(filename, mode=mode)
|
| 411 |
+
if email:
|
| 412 |
+
success_count += 1
|
| 413 |
+
results.append({
|
| 414 |
+
"filename": os.path.basename(filename),
|
| 415 |
+
"user_email": email,
|
| 416 |
+
"success": True,
|
| 417 |
+
})
|
| 418 |
+
else:
|
| 419 |
+
results.append({
|
| 420 |
+
"filename": os.path.basename(filename),
|
| 421 |
+
"user_email": None,
|
| 422 |
+
"success": False,
|
| 423 |
+
"error": "无法获取邮箱",
|
| 424 |
+
})
|
| 425 |
+
except Exception as e:
|
| 426 |
+
results.append({
|
| 427 |
+
"filename": os.path.basename(filename),
|
| 428 |
+
"user_email": None,
|
| 429 |
+
"success": False,
|
| 430 |
+
"error": str(e),
|
| 431 |
+
})
|
| 432 |
+
|
| 433 |
+
total_count = len(all_states)
|
| 434 |
+
return JSONResponse(
|
| 435 |
+
content={
|
| 436 |
+
"success_count": success_count,
|
| 437 |
+
"total_count": total_count,
|
| 438 |
+
"skipped_count": skipped_count,
|
| 439 |
+
"results": results,
|
| 440 |
+
"message": f"成功获取 {success_count}/{total_count} 个邮箱地址,跳过 {skipped_count} 个已有邮箱的凭证",
|
| 441 |
+
}
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
async def deduplicate_credentials_by_email_common(mode: str = "geminicli") -> JSONResponse:
|
| 446 |
+
"""批量去重凭证文件的通用函数 - 删除邮箱相同的凭证(只保留一个)"""
|
| 447 |
+
mode = validate_mode(mode)
|
| 448 |
+
storage_adapter = await get_storage_adapter()
|
| 449 |
+
|
| 450 |
+
try:
|
| 451 |
+
duplicate_info = await storage_adapter._backend.get_duplicate_credentials_by_email(
|
| 452 |
+
mode=mode
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
duplicate_groups = duplicate_info.get("duplicate_groups", [])
|
| 456 |
+
no_email_files = duplicate_info.get("no_email_files", [])
|
| 457 |
+
total_count = duplicate_info.get("total_count", 0)
|
| 458 |
+
|
| 459 |
+
if not duplicate_groups:
|
| 460 |
+
return JSONResponse(
|
| 461 |
+
content={
|
| 462 |
+
"deleted_count": 0,
|
| 463 |
+
"kept_count": total_count,
|
| 464 |
+
"total_count": total_count,
|
| 465 |
+
"unique_emails_count": duplicate_info.get("unique_email_count", 0),
|
| 466 |
+
"no_email_count": len(no_email_files),
|
| 467 |
+
"duplicate_groups": [],
|
| 468 |
+
"delete_errors": [],
|
| 469 |
+
"message": "没有发现重复的凭证(相同邮箱)",
|
| 470 |
+
}
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
# 执行删除操作
|
| 474 |
+
deleted_count = 0
|
| 475 |
+
delete_errors = []
|
| 476 |
+
result_duplicate_groups = []
|
| 477 |
+
|
| 478 |
+
for group in duplicate_groups:
|
| 479 |
+
email = group["email"]
|
| 480 |
+
kept_file = group["kept_file"]
|
| 481 |
+
duplicate_files = group["duplicate_files"]
|
| 482 |
+
|
| 483 |
+
deleted_files_in_group = []
|
| 484 |
+
for filename in duplicate_files:
|
| 485 |
+
try:
|
| 486 |
+
success = await credential_manager.remove_credential(filename, mode=mode)
|
| 487 |
+
if success:
|
| 488 |
+
deleted_count += 1
|
| 489 |
+
deleted_files_in_group.append(os.path.basename(filename))
|
| 490 |
+
log.info(f"去重删除凭证: {filename} (邮箱: {email}) (mode={mode})")
|
| 491 |
+
else:
|
| 492 |
+
delete_errors.append(f"{os.path.basename(filename)}: 删除失败")
|
| 493 |
+
except Exception as e:
|
| 494 |
+
delete_errors.append(f"{os.path.basename(filename)}: {str(e)}")
|
| 495 |
+
log.error(f"去重删除凭证 {filename} 时出错: {e}")
|
| 496 |
+
|
| 497 |
+
result_duplicate_groups.append({
|
| 498 |
+
"email": email,
|
| 499 |
+
"kept_file": os.path.basename(kept_file),
|
| 500 |
+
"deleted_files": deleted_files_in_group,
|
| 501 |
+
"duplicate_count": len(deleted_files_in_group),
|
| 502 |
+
})
|
| 503 |
+
|
| 504 |
+
kept_count = total_count - deleted_count
|
| 505 |
+
|
| 506 |
+
return JSONResponse(
|
| 507 |
+
content={
|
| 508 |
+
"deleted_count": deleted_count,
|
| 509 |
+
"kept_count": kept_count,
|
| 510 |
+
"total_count": total_count,
|
| 511 |
+
"unique_emails_count": duplicate_info.get("unique_email_count", 0),
|
| 512 |
+
"no_email_count": len(no_email_files),
|
| 513 |
+
"duplicate_groups": result_duplicate_groups,
|
| 514 |
+
"delete_errors": delete_errors,
|
| 515 |
+
"message": f"去重完成:删除 {deleted_count} 个重复凭证,保留 {kept_count} 个凭证({duplicate_info.get('unique_email_count', 0)} 个唯一邮箱)",
|
| 516 |
+
}
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
except Exception as e:
|
| 520 |
+
log.error(f"批量去重凭证时出错: {e}")
|
| 521 |
+
return JSONResponse(
|
| 522 |
+
status_code=500,
|
| 523 |
+
content={
|
| 524 |
+
"deleted_count": 0,
|
| 525 |
+
"kept_count": 0,
|
| 526 |
+
"total_count": 0,
|
| 527 |
+
"message": f"去重操作失败: {str(e)}",
|
| 528 |
+
}
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
async def verify_credential_project_common(filename: str, mode: str = "geminicli") -> JSONResponse:
|
| 533 |
+
"""验证并重新获取凭证的project id的通用函数"""
|
| 534 |
+
mode = validate_mode(mode)
|
| 535 |
+
|
| 536 |
+
# 验证文件名
|
| 537 |
+
if not filename.endswith(".json"):
|
| 538 |
+
raise HTTPException(status_code=400, detail="无效的文件名")
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
storage_adapter = await get_storage_adapter()
|
| 542 |
+
|
| 543 |
+
# 获取凭证数据
|
| 544 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 545 |
+
if not credential_data:
|
| 546 |
+
raise HTTPException(status_code=404, detail="凭证不存在")
|
| 547 |
+
|
| 548 |
+
# 创建凭证对象
|
| 549 |
+
credentials = Credentials.from_dict(credential_data)
|
| 550 |
+
|
| 551 |
+
# 确保token有效(自动刷新)
|
| 552 |
+
token_refreshed = await credentials.refresh_if_needed()
|
| 553 |
+
|
| 554 |
+
# 如果token被刷新了,更新存储
|
| 555 |
+
if token_refreshed:
|
| 556 |
+
log.info(f"Token已自动刷新: {filename} (mode={mode})")
|
| 557 |
+
credential_data = credentials.to_dict()
|
| 558 |
+
await storage_adapter.store_credential(filename, credential_data, mode=mode)
|
| 559 |
+
|
| 560 |
+
# 获取API端点和对应的User-Agent
|
| 561 |
+
if mode == "antigravity":
|
| 562 |
+
api_base_url = await get_code_assist_endpoint()
|
| 563 |
+
user_agent = ANTIGRAVITY_USER_AGENT
|
| 564 |
+
else:
|
| 565 |
+
api_base_url = await get_code_assist_endpoint()
|
| 566 |
+
user_agent = GEMINICLI_USER_AGENT
|
| 567 |
+
|
| 568 |
+
# 重新获取project id(仅 antigravity 模式请求积分)
|
| 569 |
+
if mode == "antigravity":
|
| 570 |
+
project_id, subscription_tier, credit_amount = await fetch_project_id_and_tier(
|
| 571 |
+
access_token=credentials.access_token,
|
| 572 |
+
user_agent=user_agent,
|
| 573 |
+
api_base_url=api_base_url,
|
| 574 |
+
include_credits=True,
|
| 575 |
+
)
|
| 576 |
+
else:
|
| 577 |
+
project_id, subscription_tier = await fetch_project_id_and_tier(
|
| 578 |
+
access_token=credentials.access_token,
|
| 579 |
+
user_agent=user_agent,
|
| 580 |
+
api_base_url=api_base_url,
|
| 581 |
+
)
|
| 582 |
+
credit_amount = None
|
| 583 |
+
|
| 584 |
+
if project_id:
|
| 585 |
+
credential_data["project_id"] = project_id
|
| 586 |
+
|
| 587 |
+
if project_id or subscription_tier:
|
| 588 |
+
await storage_adapter.store_credential(filename, credential_data, mode=mode)
|
| 589 |
+
|
| 590 |
+
# 检验成功后自动解除禁用状态并清除错误码
|
| 591 |
+
state_update = {
|
| 592 |
+
"disabled": False,
|
| 593 |
+
"error_codes": []
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
# 同步更新状态表中的 tier 字段
|
| 597 |
+
state_update["tier"] = subscription_tier
|
| 598 |
+
|
| 599 |
+
# 如果是 geminicli 模式,直接设置 preview=True
|
| 600 |
+
if mode == "geminicli":
|
| 601 |
+
state_update["preview"] = True
|
| 602 |
+
|
| 603 |
+
await storage_adapter.update_credential_state(filename, state_update, mode=mode)
|
| 604 |
+
|
| 605 |
+
log.info(f"检验 {mode} 凭证成功: {filename} - Project ID: {project_id}, Tier: {subscription_tier} - 已解除禁用并清除错误码")
|
| 606 |
+
|
| 607 |
+
response_data = {
|
| 608 |
+
"success": True,
|
| 609 |
+
"filename": filename,
|
| 610 |
+
"project_id": project_id,
|
| 611 |
+
"subscription_tier": subscription_tier,
|
| 612 |
+
"message": "检验成功!Project ID已更新,已解除禁用状态并清除错误码,403错误应该已恢复"
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
if mode == "antigravity" and credit_amount is not None:
|
| 616 |
+
response_data["credit_amount"] = credit_amount
|
| 617 |
+
|
| 618 |
+
return JSONResponse(content=response_data)
|
| 619 |
+
else:
|
| 620 |
+
return JSONResponse(
|
| 621 |
+
status_code=400,
|
| 622 |
+
content={
|
| 623 |
+
"success": False,
|
| 624 |
+
"filename": filename,
|
| 625 |
+
"message": "检验失败:无法获取Project ID,请检查凭证是否有效"
|
| 626 |
+
}
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
# =============================================================================
|
| 631 |
+
# 路由处理函数 (Route Handlers)
|
| 632 |
+
# =============================================================================
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
@router.post("/upload")
|
| 636 |
+
async def upload_credentials(
|
| 637 |
+
files: List[UploadFile] = File(...),
|
| 638 |
+
token: str = Depends(verify_panel_token),
|
| 639 |
+
mode: str = "geminicli"
|
| 640 |
+
):
|
| 641 |
+
"""批量上传凭证文件"""
|
| 642 |
+
try:
|
| 643 |
+
mode = validate_mode(mode)
|
| 644 |
+
return await upload_credentials_common(files, mode=mode)
|
| 645 |
+
except HTTPException:
|
| 646 |
+
raise
|
| 647 |
+
except Exception as e:
|
| 648 |
+
log.error(f"批量上传失败: {e}")
|
| 649 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
@router.get("/status")
|
| 653 |
+
async def get_creds_status(
|
| 654 |
+
token: str = Depends(verify_panel_token),
|
| 655 |
+
offset: int = 0,
|
| 656 |
+
limit: int = 50,
|
| 657 |
+
status_filter: str = "all",
|
| 658 |
+
error_code_filter: str = "all",
|
| 659 |
+
cooldown_filter: str = "all",
|
| 660 |
+
preview_filter: str = "all",
|
| 661 |
+
tier_filter: str = "all",
|
| 662 |
+
mode: str = "geminicli"
|
| 663 |
+
):
|
| 664 |
+
"""
|
| 665 |
+
获取凭证文件的状态(轻量级摘要,不包含完整凭证数据,支持分页和状态筛选)
|
| 666 |
+
|
| 667 |
+
Args:
|
| 668 |
+
offset: 跳过的记录数(默认0)
|
| 669 |
+
limit: 每页返回的记录数(默认50,可选:20, 50, 100, 200, 500, 1000)
|
| 670 |
+
status_filter: 状态筛选(all=全部, enabled=仅启用, disabled=仅禁用)
|
| 671 |
+
error_code_filter: 错误码筛选(all=全部, 或具体错误码如"400", "403")
|
| 672 |
+
cooldown_filter: 冷却状态筛选(all=全部, in_cooldown=冷却中, no_cooldown=未冷却)
|
| 673 |
+
preview_filter: Preview筛选(all=全部, preview=支持preview, no_preview=不支持preview,仅geminicli模式有效)
|
| 674 |
+
tier_filter: tier筛选(all=全部, free/pro/ultra)
|
| 675 |
+
mode: 凭证模式(geminicli 或 antigravity)
|
| 676 |
+
|
| 677 |
+
Returns:
|
| 678 |
+
包含凭证列表、总数、分页信息的响应
|
| 679 |
+
"""
|
| 680 |
+
try:
|
| 681 |
+
mode = validate_mode(mode)
|
| 682 |
+
return await get_creds_status_common(
|
| 683 |
+
offset, limit, status_filter, mode=mode,
|
| 684 |
+
error_code_filter=error_code_filter,
|
| 685 |
+
cooldown_filter=cooldown_filter,
|
| 686 |
+
preview_filter=preview_filter,
|
| 687 |
+
tier_filter=tier_filter
|
| 688 |
+
)
|
| 689 |
+
except HTTPException:
|
| 690 |
+
raise
|
| 691 |
+
except Exception as e:
|
| 692 |
+
log.error(f"获取凭证状态失败: {e}")
|
| 693 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
@router.get("/detail/{filename}")
|
| 697 |
+
async def get_cred_detail(
|
| 698 |
+
filename: str,
|
| 699 |
+
token: str = Depends(verify_panel_token),
|
| 700 |
+
mode: str = "geminicli"
|
| 701 |
+
):
|
| 702 |
+
"""
|
| 703 |
+
按需获取单个凭证的详细数据(包含完整凭证内容)
|
| 704 |
+
用于用户查看/编辑凭证详情
|
| 705 |
+
"""
|
| 706 |
+
try:
|
| 707 |
+
mode = validate_mode(mode)
|
| 708 |
+
# 验证文件名
|
| 709 |
+
if not filename.endswith(".json"):
|
| 710 |
+
raise HTTPException(status_code=400, detail="无效的文件名")
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
storage_adapter = await get_storage_adapter()
|
| 715 |
+
backend_info = await storage_adapter.get_backend_info()
|
| 716 |
+
backend_type = backend_info.get("backend_type", "unknown")
|
| 717 |
+
|
| 718 |
+
# 获取凭证数据
|
| 719 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 720 |
+
if not credential_data:
|
| 721 |
+
raise HTTPException(status_code=404, detail="凭证不存在")
|
| 722 |
+
|
| 723 |
+
# 获取状态信息
|
| 724 |
+
file_status = await storage_adapter.get_credential_state(filename, mode=mode)
|
| 725 |
+
if not file_status:
|
| 726 |
+
file_status = {
|
| 727 |
+
"error_codes": [],
|
| 728 |
+
"disabled": False,
|
| 729 |
+
"last_success": time.time(),
|
| 730 |
+
"user_email": None,
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
result = {
|
| 734 |
+
"status": file_status,
|
| 735 |
+
"content": credential_data,
|
| 736 |
+
"filename": os.path.basename(filename),
|
| 737 |
+
"backend_type": backend_type,
|
| 738 |
+
"user_email": file_status.get("user_email"),
|
| 739 |
+
"model_cooldowns": file_status.get("model_cooldowns", {}),
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
if mode == "geminicli":
|
| 743 |
+
result["preview"] = file_status.get("preview", True)
|
| 744 |
+
else:
|
| 745 |
+
result["enable_credit"] = file_status.get("enable_credit", False)
|
| 746 |
+
|
| 747 |
+
if backend_type == "file" and os.path.exists(filename):
|
| 748 |
+
result.update({
|
| 749 |
+
"size": os.path.getsize(filename),
|
| 750 |
+
"modified_time": os.path.getmtime(filename),
|
| 751 |
+
})
|
| 752 |
+
|
| 753 |
+
return JSONResponse(content=result)
|
| 754 |
+
|
| 755 |
+
except HTTPException:
|
| 756 |
+
raise
|
| 757 |
+
except Exception as e:
|
| 758 |
+
log.error(f"获取凭证详情失败 {filename}: {e}")
|
| 759 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
@router.post("/action")
|
| 763 |
+
async def creds_action(
|
| 764 |
+
request: CredFileActionRequest,
|
| 765 |
+
token: str = Depends(verify_panel_token),
|
| 766 |
+
mode: str = "geminicli"
|
| 767 |
+
):
|
| 768 |
+
"""对凭证文件执行操作(启用/禁用/删除/enable_credit开关)"""
|
| 769 |
+
try:
|
| 770 |
+
mode = validate_mode(mode)
|
| 771 |
+
|
| 772 |
+
log.info(f"Received request: {request}")
|
| 773 |
+
|
| 774 |
+
filename = request.filename
|
| 775 |
+
action = request.action
|
| 776 |
+
|
| 777 |
+
log.info(f"Performing action '{action}' on file: {filename} (mode={mode})")
|
| 778 |
+
|
| 779 |
+
# 验证文件名
|
| 780 |
+
if not filename.endswith(".json"):
|
| 781 |
+
log.error(f"无效的文件名: {filename}(不是.json文件)")
|
| 782 |
+
raise HTTPException(status_code=400, detail=f"无效的文件名: {filename}")
|
| 783 |
+
|
| 784 |
+
# 获取存储适配器
|
| 785 |
+
storage_adapter = await get_storage_adapter()
|
| 786 |
+
|
| 787 |
+
# 对于删除操作,不需要检查凭证数据是否完整,只需检查条目是否存在
|
| 788 |
+
# 对于其他操作,需要确保凭证数据存在且完整
|
| 789 |
+
if action != "delete":
|
| 790 |
+
# 检查凭证数据是否存在
|
| 791 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 792 |
+
if not credential_data:
|
| 793 |
+
log.error(f"凭证未找到: {filename} (mode={mode})")
|
| 794 |
+
raise HTTPException(status_code=404, detail="凭证文件不存在")
|
| 795 |
+
|
| 796 |
+
if action == "enable":
|
| 797 |
+
log.info(f"Web请求: 启用文件 {filename} (mode={mode})")
|
| 798 |
+
result = await credential_manager.set_cred_disabled(filename, False, mode=mode)
|
| 799 |
+
log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}")
|
| 800 |
+
if result:
|
| 801 |
+
log.info(f"Web请求: 文件 {filename} 已成功启用 (mode={mode})")
|
| 802 |
+
return JSONResponse(content={"message": f"已启用凭证文件 {os.path.basename(filename)}"})
|
| 803 |
+
else:
|
| 804 |
+
log.error(f"Web请求: 文件 {filename} 启用失败 (mode={mode})")
|
| 805 |
+
raise HTTPException(status_code=500, detail="启用凭证失败,可能凭证不存在")
|
| 806 |
+
|
| 807 |
+
elif action == "disable":
|
| 808 |
+
log.info(f"Web请求: 禁用文件 {filename} (mode={mode})")
|
| 809 |
+
result = await credential_manager.set_cred_disabled(filename, True, mode=mode)
|
| 810 |
+
log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}")
|
| 811 |
+
if result:
|
| 812 |
+
log.info(f"Web请求: 文件 {filename} 已成功禁用 (mode={mode})")
|
| 813 |
+
return JSONResponse(content={"message": f"已禁用凭证文件 {os.path.basename(filename)}"})
|
| 814 |
+
else:
|
| 815 |
+
log.error(f"Web请求: 文件 {filename} 禁用失败 (mode={mode})")
|
| 816 |
+
raise HTTPException(status_code=500, detail="禁用凭证失败,可能凭证不存在")
|
| 817 |
+
|
| 818 |
+
elif action == "delete":
|
| 819 |
+
try:
|
| 820 |
+
# 使用 CredentialManager 删除凭证(包含队列/状态同步)
|
| 821 |
+
success = await credential_manager.remove_credential(filename, mode=mode)
|
| 822 |
+
if success:
|
| 823 |
+
log.info(f"通过管理器成功删除凭证: {filename} (mode={mode})")
|
| 824 |
+
return JSONResponse(
|
| 825 |
+
content={"message": f"已删除凭证文件 {os.path.basename(filename)}"}
|
| 826 |
+
)
|
| 827 |
+
else:
|
| 828 |
+
raise HTTPException(status_code=500, detail="删除凭证失败")
|
| 829 |
+
except Exception as e:
|
| 830 |
+
log.error(f"删除凭证 {filename} 时出错: {e}")
|
| 831 |
+
raise HTTPException(status_code=500, detail=f"删除文件失败: {str(e)}")
|
| 832 |
+
|
| 833 |
+
elif action == "enable_credit":
|
| 834 |
+
if mode != "antigravity":
|
| 835 |
+
raise HTTPException(status_code=400, detail="enable_credit 仅支持 antigravity 模式")
|
| 836 |
+
updated = await storage_adapter.update_credential_state(
|
| 837 |
+
filename, {"enable_credit": True}, mode=mode
|
| 838 |
+
)
|
| 839 |
+
if updated:
|
| 840 |
+
await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
|
| 841 |
+
return JSONResponse(content={"message": f"已开启凭证信用额度模式 {os.path.basename(filename)}"})
|
| 842 |
+
raise HTTPException(status_code=500, detail="开启信用额度模式失败,可能凭证不存在")
|
| 843 |
+
|
| 844 |
+
elif action == "disable_credit":
|
| 845 |
+
if mode != "antigravity":
|
| 846 |
+
raise HTTPException(status_code=400, detail="disable_credit 仅支持 antigravity 模式")
|
| 847 |
+
updated = await storage_adapter.update_credential_state(
|
| 848 |
+
filename, {"enable_credit": False}, mode=mode
|
| 849 |
+
)
|
| 850 |
+
if updated:
|
| 851 |
+
await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
|
| 852 |
+
return JSONResponse(content={"message": f"已关闭凭证信用额度模式 {os.path.basename(filename)}"})
|
| 853 |
+
raise HTTPException(status_code=500, detail="关闭信用额度模式失败,可能凭证不存在")
|
| 854 |
+
|
| 855 |
+
else:
|
| 856 |
+
raise HTTPException(status_code=400, detail="无效的操作类型")
|
| 857 |
+
|
| 858 |
+
except HTTPException:
|
| 859 |
+
raise
|
| 860 |
+
except Exception as e:
|
| 861 |
+
log.error(f"凭证文件操作失败: {e}")
|
| 862 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
@router.post("/batch-action")
|
| 866 |
+
async def creds_batch_action(
|
| 867 |
+
request: CredFileBatchActionRequest,
|
| 868 |
+
token: str = Depends(verify_panel_token),
|
| 869 |
+
mode: str = "geminicli"
|
| 870 |
+
):
|
| 871 |
+
"""批量对凭证文件执行操作(启用/禁用/删除/enable_credit开关)"""
|
| 872 |
+
try:
|
| 873 |
+
mode = validate_mode(mode)
|
| 874 |
+
|
| 875 |
+
action = request.action
|
| 876 |
+
filenames = request.filenames
|
| 877 |
+
|
| 878 |
+
if not filenames:
|
| 879 |
+
raise HTTPException(status_code=400, detail="文件名列表不能为空")
|
| 880 |
+
|
| 881 |
+
log.info(f"对 {len(filenames)} 个文件执行批量操作 '{action}'")
|
| 882 |
+
|
| 883 |
+
success_count = 0
|
| 884 |
+
errors = []
|
| 885 |
+
|
| 886 |
+
storage_adapter = await get_storage_adapter()
|
| 887 |
+
|
| 888 |
+
for filename in filenames:
|
| 889 |
+
try:
|
| 890 |
+
# 验证文件名安全性
|
| 891 |
+
if not filename.endswith(".json"):
|
| 892 |
+
errors.append(f"{filename}: 无效的文件类型")
|
| 893 |
+
continue
|
| 894 |
+
|
| 895 |
+
# 对于删除操作,不���要检查凭证数据完整性
|
| 896 |
+
# 对于其他操作,需要确保凭证数据存在
|
| 897 |
+
if action != "delete":
|
| 898 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 899 |
+
if not credential_data:
|
| 900 |
+
errors.append(f"{filename}: 凭证不存在")
|
| 901 |
+
continue
|
| 902 |
+
|
| 903 |
+
# 执行相应操作
|
| 904 |
+
if action == "enable":
|
| 905 |
+
await credential_manager.set_cred_disabled(filename, False, mode=mode)
|
| 906 |
+
success_count += 1
|
| 907 |
+
|
| 908 |
+
elif action == "disable":
|
| 909 |
+
await credential_manager.set_cred_disabled(filename, True, mode=mode)
|
| 910 |
+
success_count += 1
|
| 911 |
+
|
| 912 |
+
elif action == "delete":
|
| 913 |
+
try:
|
| 914 |
+
delete_success = await credential_manager.remove_credential(filename, mode=mode)
|
| 915 |
+
if delete_success:
|
| 916 |
+
success_count += 1
|
| 917 |
+
log.info(f"成功删除批量中的凭证: {filename}")
|
| 918 |
+
else:
|
| 919 |
+
errors.append(f"{filename}: 删除失败")
|
| 920 |
+
continue
|
| 921 |
+
except Exception as e:
|
| 922 |
+
errors.append(f"{filename}: 删除文件失败 - {str(e)}")
|
| 923 |
+
continue
|
| 924 |
+
elif action == "enable_credit":
|
| 925 |
+
if mode != "antigravity":
|
| 926 |
+
errors.append(f"{filename}: enable_credit 仅支持 antigravity 模式")
|
| 927 |
+
continue
|
| 928 |
+
updated = await storage_adapter.update_credential_state(
|
| 929 |
+
filename, {"enable_credit": True}, mode=mode
|
| 930 |
+
)
|
| 931 |
+
if updated:
|
| 932 |
+
await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
|
| 933 |
+
success_count += 1
|
| 934 |
+
else:
|
| 935 |
+
errors.append(f"{filename}: 开启信用额度模式失败")
|
| 936 |
+
continue
|
| 937 |
+
elif action == "disable_credit":
|
| 938 |
+
if mode != "antigravity":
|
| 939 |
+
errors.append(f"{filename}: disable_credit 仅支持 antigravity 模式")
|
| 940 |
+
continue
|
| 941 |
+
updated = await storage_adapter.update_credential_state(
|
| 942 |
+
filename, {"enable_credit": False}, mode=mode
|
| 943 |
+
)
|
| 944 |
+
if updated:
|
| 945 |
+
await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
|
| 946 |
+
success_count += 1
|
| 947 |
+
else:
|
| 948 |
+
errors.append(f"{filename}: 关闭信用额度模式失败")
|
| 949 |
+
continue
|
| 950 |
+
else:
|
| 951 |
+
errors.append(f"{filename}: 无效的操作类型")
|
| 952 |
+
continue
|
| 953 |
+
|
| 954 |
+
except Exception as e:
|
| 955 |
+
log.error(f"处理 {filename} 时出错: {e}")
|
| 956 |
+
errors.append(f"{filename}: 处理失败 - {str(e)}")
|
| 957 |
+
continue
|
| 958 |
+
|
| 959 |
+
# 构建返回消息
|
| 960 |
+
result_message = f"批量操作完成:成功处理 {success_count}/{len(filenames)} 个文件"
|
| 961 |
+
if errors:
|
| 962 |
+
result_message += "\n错误详情:\n" + "\n".join(errors)
|
| 963 |
+
|
| 964 |
+
response_data = {
|
| 965 |
+
"success_count": success_count,
|
| 966 |
+
"total_count": len(filenames),
|
| 967 |
+
"errors": errors,
|
| 968 |
+
"message": result_message,
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
return JSONResponse(content=response_data)
|
| 972 |
+
|
| 973 |
+
except HTTPException:
|
| 974 |
+
raise
|
| 975 |
+
except Exception as e:
|
| 976 |
+
log.error(f"批量凭证文件操作失败: {e}")
|
| 977 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
@router.get("/download/{filename}")
|
| 981 |
+
async def download_cred_file(
|
| 982 |
+
filename: str,
|
| 983 |
+
token: str = Depends(verify_panel_token),
|
| 984 |
+
mode: str = "geminicli"
|
| 985 |
+
):
|
| 986 |
+
"""下载单个凭证文件"""
|
| 987 |
+
try:
|
| 988 |
+
mode = validate_mode(mode)
|
| 989 |
+
# 验证文件名安全性
|
| 990 |
+
if not filename.endswith(".json"):
|
| 991 |
+
raise HTTPException(status_code=404, detail="无效的文件名")
|
| 992 |
+
|
| 993 |
+
# 获取存储适配器
|
| 994 |
+
storage_adapter = await get_storage_adapter()
|
| 995 |
+
|
| 996 |
+
# 从存储系统获取凭证数据
|
| 997 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 998 |
+
if not credential_data:
|
| 999 |
+
raise HTTPException(status_code=404, detail="文件不存在")
|
| 1000 |
+
|
| 1001 |
+
# 转换为JSON字符串
|
| 1002 |
+
content = json.dumps(credential_data, ensure_ascii=False, indent=2)
|
| 1003 |
+
|
| 1004 |
+
from fastapi.responses import Response
|
| 1005 |
+
|
| 1006 |
+
return Response(
|
| 1007 |
+
content=content,
|
| 1008 |
+
media_type="application/json",
|
| 1009 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
| 1010 |
+
)
|
| 1011 |
+
|
| 1012 |
+
except HTTPException:
|
| 1013 |
+
raise
|
| 1014 |
+
except Exception as e:
|
| 1015 |
+
log.error(f"下载凭证文件失败: {e}")
|
| 1016 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1017 |
+
|
| 1018 |
+
|
| 1019 |
+
@router.post("/fetch-email/{filename}")
|
| 1020 |
+
async def fetch_user_email(
|
| 1021 |
+
filename: str,
|
| 1022 |
+
token: str = Depends(verify_panel_token),
|
| 1023 |
+
mode: str = "geminicli"
|
| 1024 |
+
):
|
| 1025 |
+
"""获取指定凭证文件的用户邮箱地址"""
|
| 1026 |
+
try:
|
| 1027 |
+
mode = validate_mode(mode)
|
| 1028 |
+
return await fetch_user_email_common(filename, mode=mode)
|
| 1029 |
+
except HTTPException:
|
| 1030 |
+
raise
|
| 1031 |
+
except Exception as e:
|
| 1032 |
+
log.error(f"获取用户邮箱失败: {e}")
|
| 1033 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
@router.post("/refresh-all-emails")
|
| 1037 |
+
async def refresh_all_user_emails(
|
| 1038 |
+
token: str = Depends(verify_panel_token),
|
| 1039 |
+
mode: str = "geminicli"
|
| 1040 |
+
):
|
| 1041 |
+
"""刷新所有凭证文件的用户邮箱地址"""
|
| 1042 |
+
try:
|
| 1043 |
+
mode = validate_mode(mode)
|
| 1044 |
+
return await refresh_all_user_emails_common(mode=mode)
|
| 1045 |
+
except Exception as e:
|
| 1046 |
+
log.error(f"批量获取用户邮箱失败: {e}")
|
| 1047 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
+
@router.post("/deduplicate-by-email")
|
| 1051 |
+
async def deduplicate_credentials_by_email(
|
| 1052 |
+
token: str = Depends(verify_panel_token),
|
| 1053 |
+
mode: str = "geminicli"
|
| 1054 |
+
):
|
| 1055 |
+
"""批量去重凭证文件 - 删除邮箱相同的凭证(只保留一个)"""
|
| 1056 |
+
try:
|
| 1057 |
+
mode = validate_mode(mode)
|
| 1058 |
+
return await deduplicate_credentials_by_email_common(mode=mode)
|
| 1059 |
+
except Exception as e:
|
| 1060 |
+
log.error(f"批量去重凭证失败: {e}")
|
| 1061 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
+
@router.get("/download-all")
|
| 1065 |
+
async def download_all_creds(
|
| 1066 |
+
token: str = Depends(verify_panel_token),
|
| 1067 |
+
mode: str = "geminicli"
|
| 1068 |
+
):
|
| 1069 |
+
"""
|
| 1070 |
+
打包下载所有凭证文件(流式处理,按需加载每个凭证数据)
|
| 1071 |
+
只在实际下载时才加载完整凭证内容,最大化性能
|
| 1072 |
+
"""
|
| 1073 |
+
try:
|
| 1074 |
+
mode = validate_mode(mode)
|
| 1075 |
+
return await download_all_creds_common(mode=mode)
|
| 1076 |
+
except HTTPException:
|
| 1077 |
+
raise
|
| 1078 |
+
except Exception as e:
|
| 1079 |
+
log.error(f"打包下载失败: {e}")
|
| 1080 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1081 |
+
|
| 1082 |
+
|
| 1083 |
+
@router.post("/verify-project/{filename}")
|
| 1084 |
+
async def verify_credential_project(
|
| 1085 |
+
filename: str,
|
| 1086 |
+
token: str = Depends(verify_panel_token),
|
| 1087 |
+
mode: str = "geminicli"
|
| 1088 |
+
):
|
| 1089 |
+
"""
|
| 1090 |
+
检验凭证的project id,重新获取project id
|
| 1091 |
+
检验成功可以使403错误恢复
|
| 1092 |
+
"""
|
| 1093 |
+
try:
|
| 1094 |
+
mode = validate_mode(mode)
|
| 1095 |
+
return await verify_credential_project_common(filename, mode=mode)
|
| 1096 |
+
except HTTPException:
|
| 1097 |
+
raise
|
| 1098 |
+
except Exception as e:
|
| 1099 |
+
log.error(f"检验凭证Project ID失败 {filename}: {e}")
|
| 1100 |
+
raise HTTPException(status_code=500, detail=f"检验失败: {str(e)}")
|
| 1101 |
+
|
| 1102 |
+
|
| 1103 |
+
@router.get("/errors/{filename}")
|
| 1104 |
+
async def get_credential_errors(
|
| 1105 |
+
filename: str,
|
| 1106 |
+
token: str = Depends(verify_panel_token),
|
| 1107 |
+
mode: str = "geminicli"
|
| 1108 |
+
):
|
| 1109 |
+
"""
|
| 1110 |
+
获取指定凭证的错误信息(包含 error_codes 和 error_messages)
|
| 1111 |
+
|
| 1112 |
+
Args:
|
| 1113 |
+
filename: 凭证文件名
|
| 1114 |
+
mode: 凭证模式(geminicli 或 antigravity)
|
| 1115 |
+
|
| 1116 |
+
Returns:
|
| 1117 |
+
包含 error_codes 和 error_messages 的 JSON 响应
|
| 1118 |
+
"""
|
| 1119 |
+
try:
|
| 1120 |
+
mode = validate_mode(mode)
|
| 1121 |
+
|
| 1122 |
+
# 验证文件名
|
| 1123 |
+
if not filename.endswith(".json"):
|
| 1124 |
+
raise HTTPException(status_code=400, detail="无效的文件名")
|
| 1125 |
+
|
| 1126 |
+
storage_adapter = await get_storage_adapter()
|
| 1127 |
+
|
| 1128 |
+
# 检查后端是否支持 get_credential_errors 方法
|
| 1129 |
+
if not hasattr(storage_adapter._backend, 'get_credential_errors'):
|
| 1130 |
+
raise HTTPException(
|
| 1131 |
+
status_code=501,
|
| 1132 |
+
detail="当前存储后端不支持获取错误信息"
|
| 1133 |
+
)
|
| 1134 |
+
|
| 1135 |
+
# 获取错误信息
|
| 1136 |
+
error_info = await storage_adapter._backend.get_credential_errors(filename, mode=mode)
|
| 1137 |
+
|
| 1138 |
+
return JSONResponse(content=error_info)
|
| 1139 |
+
|
| 1140 |
+
except HTTPException:
|
| 1141 |
+
raise
|
| 1142 |
+
except Exception as e:
|
| 1143 |
+
log.error(f"获取凭证错误信息失败 {filename}: {e}")
|
| 1144 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1145 |
+
|
| 1146 |
+
|
| 1147 |
+
@router.get("/quota/{filename}")
|
| 1148 |
+
async def get_credential_quota(
|
| 1149 |
+
filename: str,
|
| 1150 |
+
token: str = Depends(verify_panel_token),
|
| 1151 |
+
mode: str = "antigravity"
|
| 1152 |
+
):
|
| 1153 |
+
"""
|
| 1154 |
+
获取指定凭证的额度信息(仅支持 antigravity 模式)
|
| 1155 |
+
"""
|
| 1156 |
+
try:
|
| 1157 |
+
mode = validate_mode(mode)
|
| 1158 |
+
# 验证文件名
|
| 1159 |
+
if not filename.endswith(".json"):
|
| 1160 |
+
raise HTTPException(status_code=400, detail="无效的文件名")
|
| 1161 |
+
|
| 1162 |
+
|
| 1163 |
+
storage_adapter = await get_storage_adapter()
|
| 1164 |
+
|
| 1165 |
+
# 获取凭证数据
|
| 1166 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 1167 |
+
if not credential_data:
|
| 1168 |
+
raise HTTPException(status_code=404, detail="凭证不存在")
|
| 1169 |
+
|
| 1170 |
+
# 使用 Credentials 对象自动处理 token 刷新
|
| 1171 |
+
from src.google_oauth_api import Credentials
|
| 1172 |
+
|
| 1173 |
+
creds = Credentials.from_dict(credential_data)
|
| 1174 |
+
|
| 1175 |
+
# 自动刷新 token(如果需要)
|
| 1176 |
+
await creds.refresh_if_needed()
|
| 1177 |
+
|
| 1178 |
+
# 如果 token 被刷新了,更新存储
|
| 1179 |
+
updated_data = creds.to_dict()
|
| 1180 |
+
if updated_data != credential_data:
|
| 1181 |
+
log.info(f"Token已自动刷新: {filename}")
|
| 1182 |
+
await storage_adapter.store_credential(filename, updated_data, mode=mode)
|
| 1183 |
+
credential_data = updated_data
|
| 1184 |
+
|
| 1185 |
+
# 获取访问令牌
|
| 1186 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 1187 |
+
if not access_token:
|
| 1188 |
+
raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
|
| 1189 |
+
|
| 1190 |
+
# 获取额度信息
|
| 1191 |
+
quota_info = await fetch_quota_info(access_token)
|
| 1192 |
+
|
| 1193 |
+
if quota_info.get("success"):
|
| 1194 |
+
return JSONResponse(content={
|
| 1195 |
+
"success": True,
|
| 1196 |
+
"filename": filename,
|
| 1197 |
+
"models": quota_info.get("models", {})
|
| 1198 |
+
})
|
| 1199 |
+
else:
|
| 1200 |
+
return JSONResponse(
|
| 1201 |
+
status_code=400,
|
| 1202 |
+
content={
|
| 1203 |
+
"success": False,
|
| 1204 |
+
"filename": filename,
|
| 1205 |
+
"error": quota_info.get("error", "未知错误")
|
| 1206 |
+
}
|
| 1207 |
+
)
|
| 1208 |
+
|
| 1209 |
+
except HTTPException:
|
| 1210 |
+
raise
|
| 1211 |
+
except Exception as e:
|
| 1212 |
+
log.error(f"获取凭证额度失败 {filename}: {e}")
|
| 1213 |
+
raise HTTPException(status_code=500, detail=f"获取额度失败: {str(e)}")
|
| 1214 |
+
|
| 1215 |
+
|
| 1216 |
+
@router.post("/configure-preview/{filename}")
|
| 1217 |
+
async def configure_preview_channel(
|
| 1218 |
+
filename: str,
|
| 1219 |
+
token: str = Depends(verify_panel_token),
|
| 1220 |
+
mode: str = "geminicli"
|
| 1221 |
+
):
|
| 1222 |
+
"""
|
| 1223 |
+
为 geminicli 凭证配置 preview 通道
|
| 1224 |
+
|
| 1225 |
+
通过调用 Google Cloud API 设置 release_channel 为 EXPERIMENTAL
|
| 1226 |
+
|
| 1227 |
+
Args:
|
| 1228 |
+
filename: 凭证文件名
|
| 1229 |
+
mode: 凭证模式(仅支持 geminicli)
|
| 1230 |
+
|
| 1231 |
+
Returns:
|
| 1232 |
+
配置结果信息
|
| 1233 |
+
"""
|
| 1234 |
+
try:
|
| 1235 |
+
mode = validate_mode(mode)
|
| 1236 |
+
|
| 1237 |
+
# 只支持 geminicli 模式
|
| 1238 |
+
if mode != "geminicli":
|
| 1239 |
+
raise HTTPException(
|
| 1240 |
+
status_code=400,
|
| 1241 |
+
detail="配置 preview 通道仅支持 geminicli 模式"
|
| 1242 |
+
)
|
| 1243 |
+
|
| 1244 |
+
# 验证文件名
|
| 1245 |
+
if not filename.endswith(".json"):
|
| 1246 |
+
raise HTTPException(status_code=400, detail="无效的文件名")
|
| 1247 |
+
|
| 1248 |
+
storage_adapter = await get_storage_adapter()
|
| 1249 |
+
|
| 1250 |
+
# 获取凭证数据
|
| 1251 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 1252 |
+
if not credential_data:
|
| 1253 |
+
raise HTTPException(status_code=404, detail="凭证不存在")
|
| 1254 |
+
|
| 1255 |
+
# 创建凭证对象并刷新 token(如果需要)
|
| 1256 |
+
credentials = Credentials.from_dict(credential_data)
|
| 1257 |
+
token_refreshed = await credentials.refresh_if_needed()
|
| 1258 |
+
|
| 1259 |
+
if token_refreshed:
|
| 1260 |
+
log.info(f"Token已自动刷新: {filename}")
|
| 1261 |
+
credential_data = credentials.to_dict()
|
| 1262 |
+
await storage_adapter.store_credential(filename, credential_data, mode=mode)
|
| 1263 |
+
|
| 1264 |
+
# 获取 access_token 和 project_id
|
| 1265 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 1266 |
+
project_id = credential_data.get("project_id", "")
|
| 1267 |
+
|
| 1268 |
+
if not access_token:
|
| 1269 |
+
raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
|
| 1270 |
+
if not project_id:
|
| 1271 |
+
raise HTTPException(status_code=400, detail="凭证中没有项目ID")
|
| 1272 |
+
|
| 1273 |
+
# 调用 Google Cloud API 配置 preview 通道
|
| 1274 |
+
# 根据文档,需要两个步骤:
|
| 1275 |
+
# 1. 创建 Release Channel Setting (EXPERIMENTAL)
|
| 1276 |
+
# 2. 创建 Setting Binding (绑定到目标项目)
|
| 1277 |
+
from src.httpx_client import post_async
|
| 1278 |
+
import uuid
|
| 1279 |
+
|
| 1280 |
+
# 生成唯一的 ID
|
| 1281 |
+
setting_id = f"preview-setting-{uuid.uuid4().hex[:8]}"
|
| 1282 |
+
binding_id = f"preview-binding-{uuid.uuid4().hex[:8]}"
|
| 1283 |
+
|
| 1284 |
+
base_url = f"https://cloudaicompanion.googleapis.com/v1/projects/{project_id}/locations/global"
|
| 1285 |
+
headers = {
|
| 1286 |
+
"Authorization": f"Bearer {access_token}",
|
| 1287 |
+
"Content-Type": "application/json"
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
log.info(f"开始配置 preview 通道: {filename} (project_id={project_id})")
|
| 1291 |
+
|
| 1292 |
+
# 步骤 1: 创建 Release Channel Setting
|
| 1293 |
+
setting_url = f"{base_url}/releaseChannelSettings"
|
| 1294 |
+
setting_response = await post_async(
|
| 1295 |
+
url=setting_url,
|
| 1296 |
+
json={"release_channel": "EXPERIMENTAL"},
|
| 1297 |
+
headers=headers,
|
| 1298 |
+
params={"release_channel_setting_id": setting_id},
|
| 1299 |
+
timeout=30.0
|
| 1300 |
+
)
|
| 1301 |
+
|
| 1302 |
+
setting_status = setting_response.status_code
|
| 1303 |
+
|
| 1304 |
+
if setting_status == 200 or setting_status == 201:
|
| 1305 |
+
log.info(f"步骤 1/2: Release Channel Setting 创建成功 (setting_id={setting_id})")
|
| 1306 |
+
elif setting_status == 409:
|
| 1307 |
+
# Setting 已存在,继续下一步
|
| 1308 |
+
log.info(f"步骤 1/2: Release Channel Setting 已存在")
|
| 1309 |
+
else:
|
| 1310 |
+
# 步骤 1 失败
|
| 1311 |
+
error_text = setting_response.text if hasattr(setting_response, 'text') else ""
|
| 1312 |
+
log.error(f"步骤 1/2 失败: {filename} - Status: {setting_status}, Error: {error_text}")
|
| 1313 |
+
|
| 1314 |
+
return JSONResponse(
|
| 1315 |
+
status_code=setting_status,
|
| 1316 |
+
content={
|
| 1317 |
+
"success": False,
|
| 1318 |
+
"filename": filename,
|
| 1319 |
+
"preview": False,
|
| 1320 |
+
"message": f"创建 Release Channel Setting 失败: HTTP {setting_status}",
|
| 1321 |
+
"error": error_text,
|
| 1322 |
+
"step": "create_setting"
|
| 1323 |
+
}
|
| 1324 |
+
)
|
| 1325 |
+
|
| 1326 |
+
# 步骤 2: 创建 Setting Binding (绑定到当前项目)
|
| 1327 |
+
binding_url = f"{base_url}/releaseChannelSettings/{setting_id}/settingBindings"
|
| 1328 |
+
binding_response = await post_async(
|
| 1329 |
+
url=binding_url,
|
| 1330 |
+
json={
|
| 1331 |
+
"target": f"projects/{project_id}",
|
| 1332 |
+
"product": "GEMINI_CODE_ASSIST"
|
| 1333 |
+
},
|
| 1334 |
+
headers=headers,
|
| 1335 |
+
params={"setting_binding_id": binding_id},
|
| 1336 |
+
timeout=30.0
|
| 1337 |
+
)
|
| 1338 |
+
|
| 1339 |
+
binding_status = binding_response.status_code
|
| 1340 |
+
|
| 1341 |
+
if binding_status == 200 or binding_status == 201:
|
| 1342 |
+
await storage_adapter.update_credential_state(filename, {
|
| 1343 |
+
"preview": True
|
| 1344 |
+
}, mode=mode)
|
| 1345 |
+
|
| 1346 |
+
log.info(f"步骤 2/2: Setting Binding 创建成功 - Preview 通道配置完成: {filename}")
|
| 1347 |
+
|
| 1348 |
+
return JSONResponse(content={
|
| 1349 |
+
"success": True,
|
| 1350 |
+
"filename": filename,
|
| 1351 |
+
"preview": True,
|
| 1352 |
+
"message": "Preview 通道配置成功,已将 preview 属性设置为 true",
|
| 1353 |
+
"setting_id": setting_id,
|
| 1354 |
+
"binding_id": binding_id
|
| 1355 |
+
})
|
| 1356 |
+
elif binding_status == 409:
|
| 1357 |
+
# Binding 已存在,说明已经配置过了
|
| 1358 |
+
await storage_adapter.update_credential_state(filename, {
|
| 1359 |
+
"preview": True
|
| 1360 |
+
}, mode=mode)
|
| 1361 |
+
|
| 1362 |
+
log.info(f"步骤 2/2: Setting Binding 已存在 - Preview 通道已配置: {filename}")
|
| 1363 |
+
|
| 1364 |
+
return JSONResponse(content={
|
| 1365 |
+
"success": True,
|
| 1366 |
+
"filename": filename,
|
| 1367 |
+
"preview": True,
|
| 1368 |
+
"message": "Preview 通道配置已存在,已将 preview 属性设置为 true"
|
| 1369 |
+
})
|
| 1370 |
+
else:
|
| 1371 |
+
# 步骤 2 失败
|
| 1372 |
+
error_text = binding_response.text if hasattr(binding_response, 'text') else ""
|
| 1373 |
+
log.error(f"步骤 2/2 失败: {filename} - Status: {binding_status}, Error: {error_text}")
|
| 1374 |
+
|
| 1375 |
+
return JSONResponse(
|
| 1376 |
+
status_code=binding_status,
|
| 1377 |
+
content={
|
| 1378 |
+
"success": False,
|
| 1379 |
+
"filename": filename,
|
| 1380 |
+
"preview": False,
|
| 1381 |
+
"message": f"创建 Setting Binding 失败: HTTP {binding_status}",
|
| 1382 |
+
"error": error_text,
|
| 1383 |
+
"step": "create_binding"
|
| 1384 |
+
}
|
| 1385 |
+
)
|
| 1386 |
+
|
| 1387 |
+
except HTTPException:
|
| 1388 |
+
raise
|
| 1389 |
+
except Exception as e:
|
| 1390 |
+
log.error(f"配置 preview 通道失败 {filename}: {e}")
|
| 1391 |
+
raise HTTPException(status_code=500, detail=f"配置失败: {str(e)}")
|
| 1392 |
+
|
| 1393 |
+
|
| 1394 |
+
@router.post("/test/{filename}")
|
| 1395 |
+
async def test_credential(
|
| 1396 |
+
filename: str,
|
| 1397 |
+
mode: str = "geminicli",
|
| 1398 |
+
_token: str = Depends(verify_panel_token)
|
| 1399 |
+
):
|
| 1400 |
+
"""
|
| 1401 |
+
测试指定凭证是否可用
|
| 1402 |
+
|
| 1403 |
+
Args:
|
| 1404 |
+
filename: 凭证文件名
|
| 1405 |
+
mode: 凭证模式(geminicli 或 antigravity)
|
| 1406 |
+
|
| 1407 |
+
Returns:
|
| 1408 |
+
返回状态码:
|
| 1409 |
+
- 200: 凭证可用
|
| 1410 |
+
- 429: 凭证被限流但有效
|
| 1411 |
+
- 其他: 凭证失败(返回实际错误码)
|
| 1412 |
+
"""
|
| 1413 |
+
try:
|
| 1414 |
+
mode = validate_mode(mode)
|
| 1415 |
+
|
| 1416 |
+
# 验证文件名
|
| 1417 |
+
if not filename.endswith(".json"):
|
| 1418 |
+
raise HTTPException(status_code=400, detail="无效的文件名")
|
| 1419 |
+
|
| 1420 |
+
storage_adapter = await get_storage_adapter()
|
| 1421 |
+
|
| 1422 |
+
# 获取凭证数据
|
| 1423 |
+
credential_data = await storage_adapter.get_credential(filename, mode=mode)
|
| 1424 |
+
if not credential_data:
|
| 1425 |
+
raise HTTPException(status_code=404, detail="凭证不存在")
|
| 1426 |
+
|
| 1427 |
+
# 创建凭证对象并尝试刷新 token(如果需要)
|
| 1428 |
+
credentials = Credentials.from_dict(credential_data)
|
| 1429 |
+
token_refreshed = await credentials.refresh_if_needed()
|
| 1430 |
+
|
| 1431 |
+
# 如果 token 被刷新了,更新存储
|
| 1432 |
+
if token_refreshed:
|
| 1433 |
+
log.info(f"Token已自动刷新: {filename} (mode={mode})")
|
| 1434 |
+
credential_data = credentials.to_dict()
|
| 1435 |
+
await storage_adapter.store_credential(filename, credential_data, mode=mode)
|
| 1436 |
+
|
| 1437 |
+
# 获取访问令牌
|
| 1438 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 1439 |
+
if not access_token:
|
| 1440 |
+
raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
|
| 1441 |
+
|
| 1442 |
+
# 根据模式构造测试请求
|
| 1443 |
+
from src.httpx_client import post_async
|
| 1444 |
+
|
| 1445 |
+
# 获取 project_id
|
| 1446 |
+
project_id = credential_data.get("project_id", "")
|
| 1447 |
+
if not project_id:
|
| 1448 |
+
raise HTTPException(status_code=400, detail="凭证中没有项目ID")
|
| 1449 |
+
|
| 1450 |
+
# 根据模式选择 API 端点和请求头
|
| 1451 |
+
# 对于 geminicli 模式,使用两次测试:gemini-2.5-flash 和 gemini-3-flash-preview
|
| 1452 |
+
# 对于 antigravity 模式,只使用 gemini-2.5-flash
|
| 1453 |
+
test_model = "gemini-2.5-flash"
|
| 1454 |
+
|
| 1455 |
+
if mode == "antigravity":
|
| 1456 |
+
api_base_url = await get_code_assist_endpoint()
|
| 1457 |
+
from src.api.antigravity import build_antigravity_headers
|
| 1458 |
+
headers = build_antigravity_headers(access_token, test_model)
|
| 1459 |
+
else:
|
| 1460 |
+
api_base_url = await get_code_assist_endpoint()
|
| 1461 |
+
headers = {
|
| 1462 |
+
"Authorization": f"Bearer {access_token}",
|
| 1463 |
+
"Content-Type": "application/json",
|
| 1464 |
+
"User-Agent": GEMINICLI_USER_AGENT,
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
# 第一次测试:使用 gemini-2.5-flash
|
| 1468 |
+
response = await post_async(
|
| 1469 |
+
url=f"{api_base_url}/v1internal:generateContent",
|
| 1470 |
+
json={
|
| 1471 |
+
"model": test_model,
|
| 1472 |
+
"project": project_id,
|
| 1473 |
+
"request": {
|
| 1474 |
+
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
|
| 1475 |
+
"generationConfig": {"maxOutputTokens": 1}
|
| 1476 |
+
}
|
| 1477 |
+
},
|
| 1478 |
+
headers=headers,
|
| 1479 |
+
timeout=30.0
|
| 1480 |
+
)
|
| 1481 |
+
|
| 1482 |
+
# 返回实际的状态码和详细信息
|
| 1483 |
+
status_code = response.status_code
|
| 1484 |
+
|
| 1485 |
+
if status_code == 200 or status_code == 429:
|
| 1486 |
+
log.info(f"凭证测试成功: {filename} (mode={mode}, model={test_model}, status={status_code})")
|
| 1487 |
+
# 测试成功时清除错误状态
|
| 1488 |
+
if status_code == 200:
|
| 1489 |
+
await storage_adapter.update_credential_state(filename, {
|
| 1490 |
+
"error_codes": [],
|
| 1491 |
+
"error_messages": {}
|
| 1492 |
+
}, mode=mode)
|
| 1493 |
+
|
| 1494 |
+
# 如果是 geminicli 模式且第一次测试成功,继续测试 gemini-3-flash-preview
|
| 1495 |
+
if mode == "geminicli":
|
| 1496 |
+
preview_model = "gemini-3-flash-preview"
|
| 1497 |
+
log.info(f"开始测试 preview 模型: {filename} (model={preview_model})")
|
| 1498 |
+
|
| 1499 |
+
try:
|
| 1500 |
+
preview_response = await post_async(
|
| 1501 |
+
url=f"{api_base_url}/v1internal:generateContent",
|
| 1502 |
+
json={
|
| 1503 |
+
"model": preview_model,
|
| 1504 |
+
"project": project_id,
|
| 1505 |
+
"request": {
|
| 1506 |
+
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
|
| 1507 |
+
"generationConfig": {"maxOutputTokens": 1}
|
| 1508 |
+
}
|
| 1509 |
+
},
|
| 1510 |
+
headers=headers,
|
| 1511 |
+
timeout=30.0
|
| 1512 |
+
)
|
| 1513 |
+
|
| 1514 |
+
preview_status = preview_response.status_code
|
| 1515 |
+
|
| 1516 |
+
if preview_status == 200 or preview_status == 429:
|
| 1517 |
+
# preview 模型测试成功,设置 preview=True
|
| 1518 |
+
log.info(f"Preview 模型测试成功: {filename} (status={preview_status})")
|
| 1519 |
+
await storage_adapter.update_credential_state(filename, {
|
| 1520 |
+
"preview": True
|
| 1521 |
+
}, mode=mode)
|
| 1522 |
+
elif preview_status == 404:
|
| 1523 |
+
# preview 模型返回 404,说明不支持,设置 preview=False
|
| 1524 |
+
log.warning(f"Preview 模型不支持: {filename} (status=404)")
|
| 1525 |
+
await storage_adapter.update_credential_state(filename, {
|
| 1526 |
+
"preview": False
|
| 1527 |
+
}, mode=mode)
|
| 1528 |
+
else:
|
| 1529 |
+
# 其他错误,保持默认 preview 状态
|
| 1530 |
+
log.warning(f"Preview 模型测试失败: {filename} (status={preview_status})")
|
| 1531 |
+
except Exception as e:
|
| 1532 |
+
log.error(f"Preview 模型测试异常: {filename} - {e}")
|
| 1533 |
+
|
| 1534 |
+
# 返回成功响应
|
| 1535 |
+
return JSONResponse(
|
| 1536 |
+
status_code=status_code,
|
| 1537 |
+
content={
|
| 1538 |
+
"success": True,
|
| 1539 |
+
"status_code": status_code,
|
| 1540 |
+
"message": "测试成功",
|
| 1541 |
+
"filename": filename
|
| 1542 |
+
}
|
| 1543 |
+
)
|
| 1544 |
+
else:
|
| 1545 |
+
log.warning(f"凭证测试失败: {filename} (mode={mode}, status={status_code})")
|
| 1546 |
+
# 测试失败时保存错误码和错误消息(覆盖模式,只保存最新的一个错误)
|
| 1547 |
+
try:
|
| 1548 |
+
error_text = response.text if hasattr(response, 'text') else ""
|
| 1549 |
+
|
| 1550 |
+
# 打印详细错误内容到日志
|
| 1551 |
+
log.error(f"凭证测试错误详情 - 文件: {filename}, 模式: {mode}, 状态码: {status_code}, 错误内容: {error_text}")
|
| 1552 |
+
|
| 1553 |
+
# 使用覆盖模式保存错误(与 credential_manager 保持一致)
|
| 1554 |
+
error_codes = [status_code]
|
| 1555 |
+
error_messages = {str(status_code): error_text if error_text else f"HTTP {status_code}"}
|
| 1556 |
+
|
| 1557 |
+
# 更新状态
|
| 1558 |
+
await storage_adapter.update_credential_state(filename, {
|
| 1559 |
+
"error_codes": error_codes,
|
| 1560 |
+
"error_messages": error_messages
|
| 1561 |
+
}, mode=mode)
|
| 1562 |
+
|
| 1563 |
+
log.info(f"已保存测试错误信息: {filename} - 错误码 {status_code}")
|
| 1564 |
+
except Exception as e:
|
| 1565 |
+
log.error(f"保存测试错误信息失败: {e}")
|
| 1566 |
+
|
| 1567 |
+
# 返回错误响应,包含完整的错误信息
|
| 1568 |
+
error_text = response.text if hasattr(response, 'text') else ""
|
| 1569 |
+
|
| 1570 |
+
return JSONResponse(
|
| 1571 |
+
status_code=status_code,
|
| 1572 |
+
content={
|
| 1573 |
+
"success": False,
|
| 1574 |
+
"status_code": status_code,
|
| 1575 |
+
"message": f"测试失败: HTTP {status_code}",
|
| 1576 |
+
"error": error_text,
|
| 1577 |
+
"filename": filename
|
| 1578 |
+
}
|
| 1579 |
+
)
|
| 1580 |
+
|
| 1581 |
+
except HTTPException:
|
| 1582 |
+
raise
|
| 1583 |
+
except Exception as e:
|
| 1584 |
+
log.error(f"测试凭证失败 {filename}: {e}")
|
| 1585 |
+
raise HTTPException(status_code=500, detail=f"测试失败: {str(e)}")
|
src/panel/logs.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
日志路由模块 - 处理 /logs/* 相关的HTTP请求和WebSocket连接
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import datetime
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
| 10 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 11 |
+
from starlette.websockets import WebSocketState
|
| 12 |
+
|
| 13 |
+
import config
|
| 14 |
+
from log import log
|
| 15 |
+
from src.utils import verify_panel_token
|
| 16 |
+
from .utils import ConnectionManager
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# 创建路由器
|
| 20 |
+
router = APIRouter(prefix="/logs", tags=["logs"])
|
| 21 |
+
|
| 22 |
+
# WebSocket连接管理器
|
| 23 |
+
manager = ConnectionManager()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.post("/clear")
|
| 27 |
+
async def clear_logs(token: str = Depends(verify_panel_token)):
|
| 28 |
+
"""清空日志文件"""
|
| 29 |
+
try:
|
| 30 |
+
# 直接使用环境变量获取日志文件路径
|
| 31 |
+
log_file_path = os.getenv("LOG_FILE", "log.txt")
|
| 32 |
+
|
| 33 |
+
# 检查日志文件是否存在
|
| 34 |
+
if os.path.exists(log_file_path):
|
| 35 |
+
try:
|
| 36 |
+
# 清空文件内容(保留文件),确保以UTF-8编码写入
|
| 37 |
+
# 使用 with 确保文件正确关闭
|
| 38 |
+
with open(log_file_path, "w", encoding="utf-8") as f:
|
| 39 |
+
f.write("")
|
| 40 |
+
f.flush() # 强制刷新到磁盘
|
| 41 |
+
# with 退出时会自动关闭文件
|
| 42 |
+
log.info(f"日志文件已清空: {log_file_path}")
|
| 43 |
+
|
| 44 |
+
# 通知所有WebSocket连接日志已清空
|
| 45 |
+
await manager.broadcast("--- 日志文件已清空 ---")
|
| 46 |
+
|
| 47 |
+
return JSONResponse(
|
| 48 |
+
content={"message": f"日志文件已清空: {os.path.basename(log_file_path)}"}
|
| 49 |
+
)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
log.error(f"清空日志文件失败: {e}")
|
| 52 |
+
raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}")
|
| 53 |
+
else:
|
| 54 |
+
return JSONResponse(content={"message": "日志文件不存在"})
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
log.error(f"清空日志文件失败: {e}")
|
| 58 |
+
raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/download")
|
| 62 |
+
async def download_logs(token: str = Depends(verify_panel_token)):
|
| 63 |
+
"""下载日志文件"""
|
| 64 |
+
try:
|
| 65 |
+
# 直接使用环境变量获取日志文件路径
|
| 66 |
+
log_file_path = os.getenv("LOG_FILE", "log.txt")
|
| 67 |
+
|
| 68 |
+
# 检查日志文件是否存在
|
| 69 |
+
if not os.path.exists(log_file_path):
|
| 70 |
+
raise HTTPException(status_code=404, detail="日志文件不存在")
|
| 71 |
+
|
| 72 |
+
# 检查文件是否为空
|
| 73 |
+
file_size = os.path.getsize(log_file_path)
|
| 74 |
+
if file_size == 0:
|
| 75 |
+
raise HTTPException(status_code=404, detail="日志文件为空")
|
| 76 |
+
|
| 77 |
+
# 生成文件名(包含时间戳)
|
| 78 |
+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 79 |
+
filename = f"gcli2api_logs_{timestamp}.txt"
|
| 80 |
+
|
| 81 |
+
log.info(f"下载日志文件: {log_file_path}")
|
| 82 |
+
|
| 83 |
+
return FileResponse(
|
| 84 |
+
path=log_file_path,
|
| 85 |
+
filename=filename,
|
| 86 |
+
media_type="text/plain",
|
| 87 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
except HTTPException:
|
| 91 |
+
raise
|
| 92 |
+
except Exception as e:
|
| 93 |
+
log.error(f"下载日志文件失败: {e}")
|
| 94 |
+
raise HTTPException(status_code=500, detail=f"下载日志文件失败: {str(e)}")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.websocket("/stream")
|
| 98 |
+
async def websocket_logs(websocket: WebSocket):
|
| 99 |
+
"""WebSocket端点,用于实时日志流"""
|
| 100 |
+
# WebSocket 认证: 从查询参数获取 token
|
| 101 |
+
token = websocket.query_params.get("token")
|
| 102 |
+
|
| 103 |
+
if not token:
|
| 104 |
+
await websocket.close(code=403, reason="Missing authentication token")
|
| 105 |
+
log.warning("WebSocket连接被拒绝: 缺少认证token")
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
# 验证 token
|
| 109 |
+
try:
|
| 110 |
+
panel_password = await config.get_panel_password()
|
| 111 |
+
if token != panel_password:
|
| 112 |
+
await websocket.close(code=403, reason="Invalid authentication token")
|
| 113 |
+
log.warning("WebSocket连接被拒绝: token验证失败")
|
| 114 |
+
return
|
| 115 |
+
except Exception as e:
|
| 116 |
+
await websocket.close(code=1011, reason="Authentication error")
|
| 117 |
+
log.error(f"WebSocket认证过程出错: {e}")
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
# 检查连接数限制
|
| 121 |
+
if not await manager.connect(websocket):
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
# 直接使用环境变量获取日志文件路径
|
| 126 |
+
log_file_path = os.getenv("LOG_FILE", "log.txt")
|
| 127 |
+
|
| 128 |
+
# 发送初始日志(限制为最后50行,减少内存占用)
|
| 129 |
+
if os.path.exists(log_file_path):
|
| 130 |
+
try:
|
| 131 |
+
# 使用 with 确保文件正确关闭
|
| 132 |
+
with open(log_file_path, "r", encoding="utf-8") as f:
|
| 133 |
+
lines = f.readlines()
|
| 134 |
+
# 只发送最后50行,减少初始内存消耗
|
| 135 |
+
for line in lines[-50:]:
|
| 136 |
+
if line.strip():
|
| 137 |
+
await websocket.send_text(line.strip())
|
| 138 |
+
except Exception as e:
|
| 139 |
+
await websocket.send_text(f"Error reading log file: {e}")
|
| 140 |
+
log.error(f"WebSocket初始日志读取错误: {e}")
|
| 141 |
+
|
| 142 |
+
# 监控日志文件变化
|
| 143 |
+
last_size = os.path.getsize(log_file_path) if os.path.exists(log_file_path) else 0
|
| 144 |
+
max_read_size = 8192 # 限制单次读取大小为8KB,防止大量日志造成内存激增
|
| 145 |
+
check_interval = 2 # 增加检查间隔,减少CPU和I/O开销
|
| 146 |
+
|
| 147 |
+
# 创建后台任务监听客户端断开
|
| 148 |
+
# 即使没有日志更新,receive_text() 也能即时感知断开
|
| 149 |
+
async def listen_for_disconnect():
|
| 150 |
+
try:
|
| 151 |
+
while True:
|
| 152 |
+
await websocket.receive_text()
|
| 153 |
+
except Exception:
|
| 154 |
+
pass
|
| 155 |
+
|
| 156 |
+
listener_task = asyncio.create_task(listen_for_disconnect())
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
while websocket.client_state == WebSocketState.CONNECTED:
|
| 160 |
+
# 使用 asyncio.wait 同时等待定时器和断开信号
|
| 161 |
+
# timeout=check_interval 替代了 asyncio.sleep
|
| 162 |
+
done, pending = await asyncio.wait(
|
| 163 |
+
[listener_task],
|
| 164 |
+
timeout=check_interval,
|
| 165 |
+
return_when=asyncio.FIRST_COMPLETED
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# 如果监听任务结束(通常是因为连接断开),则退出循环
|
| 169 |
+
if listener_task in done:
|
| 170 |
+
break
|
| 171 |
+
|
| 172 |
+
if os.path.exists(log_file_path):
|
| 173 |
+
current_size = os.path.getsize(log_file_path)
|
| 174 |
+
if current_size > last_size:
|
| 175 |
+
# 限制读取大小,防止单次读取过多内容
|
| 176 |
+
read_size = min(current_size - last_size, max_read_size)
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
# 使用 with 确保文件正确关闭,即使发生异常
|
| 180 |
+
with open(log_file_path, "r", encoding="utf-8", errors="replace") as f:
|
| 181 |
+
f.seek(last_size)
|
| 182 |
+
new_content = f.read(read_size)
|
| 183 |
+
# with 退出时自动关闭文件句柄
|
| 184 |
+
|
| 185 |
+
# 处理编码错误的情况
|
| 186 |
+
if not new_content:
|
| 187 |
+
last_size = current_size
|
| 188 |
+
continue
|
| 189 |
+
|
| 190 |
+
# 分行发送,避免发送不完整的行
|
| 191 |
+
lines = new_content.splitlines(keepends=True)
|
| 192 |
+
if lines:
|
| 193 |
+
# 如果最后一行没有换行符,保留到下次处理
|
| 194 |
+
if not lines[-1].endswith("\n") and len(lines) > 1:
|
| 195 |
+
# 除了最后一行,其他都发送
|
| 196 |
+
for line in lines[:-1]:
|
| 197 |
+
if line.strip():
|
| 198 |
+
await websocket.send_text(line.rstrip())
|
| 199 |
+
# 更新位置,但要退回最后一行的字节数
|
| 200 |
+
last_size += len(new_content.encode("utf-8")) - len(
|
| 201 |
+
lines[-1].encode("utf-8")
|
| 202 |
+
)
|
| 203 |
+
else:
|
| 204 |
+
# 所有行都发送
|
| 205 |
+
for line in lines:
|
| 206 |
+
if line.strip():
|
| 207 |
+
await websocket.send_text(line.rstrip())
|
| 208 |
+
last_size += len(new_content.encode("utf-8"))
|
| 209 |
+
except UnicodeDecodeError as e:
|
| 210 |
+
# 遇到编码错误时,跳过这部分内容
|
| 211 |
+
log.warning(f"WebSocket日志读取编码错误: {e}, 跳过部分内容")
|
| 212 |
+
last_size = current_size
|
| 213 |
+
except Exception as e:
|
| 214 |
+
await websocket.send_text(f"Error reading new content: {e}")
|
| 215 |
+
# 发生其他错误时,重置文件位置
|
| 216 |
+
last_size = current_size
|
| 217 |
+
|
| 218 |
+
# 如果文件被截断(如清空日志),重置位置
|
| 219 |
+
elif current_size < last_size:
|
| 220 |
+
last_size = 0
|
| 221 |
+
await websocket.send_text("--- 日志已清空 ---")
|
| 222 |
+
|
| 223 |
+
finally:
|
| 224 |
+
# 确保清理监听任务
|
| 225 |
+
if not listener_task.done():
|
| 226 |
+
listener_task.cancel()
|
| 227 |
+
try:
|
| 228 |
+
await listener_task
|
| 229 |
+
except asyncio.CancelledError:
|
| 230 |
+
pass
|
| 231 |
+
|
| 232 |
+
except WebSocketDisconnect:
|
| 233 |
+
pass
|
| 234 |
+
except Exception as e:
|
| 235 |
+
log.error(f"WebSocket logs error: {e}")
|
| 236 |
+
finally:
|
| 237 |
+
manager.disconnect(websocket)
|
src/panel/root.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
根路由模块 - 处理控制面板主页
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 6 |
+
from fastapi.responses import HTMLResponse
|
| 7 |
+
|
| 8 |
+
from log import log
|
| 9 |
+
from .utils import is_mobile_user_agent
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# 创建路由器
|
| 13 |
+
router = APIRouter(tags=["root"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/", response_class=HTMLResponse)
|
| 17 |
+
async def serve_control_panel(request: Request):
|
| 18 |
+
"""提供统一控制面板"""
|
| 19 |
+
try:
|
| 20 |
+
user_agent = request.headers.get("user-agent", "")
|
| 21 |
+
is_mobile = is_mobile_user_agent(user_agent)
|
| 22 |
+
|
| 23 |
+
if is_mobile:
|
| 24 |
+
html_file_path = "front/control_panel_mobile.html"
|
| 25 |
+
else:
|
| 26 |
+
html_file_path = "front/control_panel.html"
|
| 27 |
+
|
| 28 |
+
with open(html_file_path, "r", encoding="utf-8") as f:
|
| 29 |
+
html_content = f.read()
|
| 30 |
+
return HTMLResponse(content=html_content)
|
| 31 |
+
|
| 32 |
+
except Exception as e:
|
| 33 |
+
log.error(f"加载控制面板页面失败: {e}")
|
| 34 |
+
raise HTTPException(status_code=500, detail="服务器内部错误")
|
src/panel/utils.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
共享工具模块 - 包含WebSocket连接管理、工具函数等
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from collections import deque
|
| 8 |
+
from typing import Set
|
| 9 |
+
|
| 10 |
+
from fastapi import HTTPException, WebSocket
|
| 11 |
+
from starlette.websockets import WebSocketState
|
| 12 |
+
|
| 13 |
+
import config
|
| 14 |
+
from log import log
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# =============================================================================
|
| 18 |
+
# WebSocket连接管理
|
| 19 |
+
# =============================================================================
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ConnectionManager:
|
| 23 |
+
def __init__(self, max_connections: int = 3): # 进一步降低最大连接数
|
| 24 |
+
# 使用双端队列严格限制内存使用
|
| 25 |
+
self.active_connections: deque = deque(maxlen=max_connections)
|
| 26 |
+
self.max_connections = max_connections
|
| 27 |
+
self._last_cleanup = 0
|
| 28 |
+
self._cleanup_interval = 120 # 120秒清理一次死连接
|
| 29 |
+
|
| 30 |
+
async def connect(self, websocket: WebSocket):
|
| 31 |
+
# 自动清理死连接
|
| 32 |
+
self._auto_cleanup()
|
| 33 |
+
|
| 34 |
+
# 限制最大连接数,防止内存无限增长
|
| 35 |
+
if len(self.active_connections) >= self.max_connections:
|
| 36 |
+
await websocket.close(code=1008, reason="Too many connections")
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
await websocket.accept()
|
| 40 |
+
self.active_connections.append(websocket)
|
| 41 |
+
log.debug(f"WebSocket连接建立,当前连接数: {len(self.active_connections)}")
|
| 42 |
+
return True
|
| 43 |
+
|
| 44 |
+
def disconnect(self, websocket: WebSocket):
|
| 45 |
+
# 使用更高效的方式移除连接
|
| 46 |
+
try:
|
| 47 |
+
self.active_connections.remove(websocket)
|
| 48 |
+
except ValueError:
|
| 49 |
+
pass # 连接已不存在
|
| 50 |
+
log.debug(f"WebSocket连接断开,当前连接数: {len(self.active_connections)}")
|
| 51 |
+
|
| 52 |
+
async def send_personal_message(self, message: str, websocket: WebSocket):
|
| 53 |
+
try:
|
| 54 |
+
await websocket.send_text(message)
|
| 55 |
+
except Exception:
|
| 56 |
+
self.disconnect(websocket)
|
| 57 |
+
|
| 58 |
+
async def broadcast(self, message: str):
|
| 59 |
+
# 使用更高效的方式处理广播,避免索引操作
|
| 60 |
+
dead_connections = []
|
| 61 |
+
for conn in self.active_connections:
|
| 62 |
+
try:
|
| 63 |
+
await conn.send_text(message)
|
| 64 |
+
except Exception:
|
| 65 |
+
dead_connections.append(conn)
|
| 66 |
+
|
| 67 |
+
# 批量移除死连接
|
| 68 |
+
for dead_conn in dead_connections:
|
| 69 |
+
self.disconnect(dead_conn)
|
| 70 |
+
|
| 71 |
+
def _auto_cleanup(self):
|
| 72 |
+
"""自动清理死连接"""
|
| 73 |
+
current_time = time.time()
|
| 74 |
+
if current_time - self._last_cleanup > self._cleanup_interval:
|
| 75 |
+
self.cleanup_dead_connections()
|
| 76 |
+
self._last_cleanup = current_time
|
| 77 |
+
|
| 78 |
+
def cleanup_dead_connections(self):
|
| 79 |
+
"""清理已断开的连接"""
|
| 80 |
+
original_count = len(self.active_connections)
|
| 81 |
+
# 使用列表推导式过滤活跃连接,更高效
|
| 82 |
+
alive_connections = deque(
|
| 83 |
+
[
|
| 84 |
+
conn
|
| 85 |
+
for conn in self.active_connections
|
| 86 |
+
if hasattr(conn, "client_state")
|
| 87 |
+
and conn.client_state != WebSocketState.DISCONNECTED
|
| 88 |
+
],
|
| 89 |
+
maxlen=self.max_connections,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
self.active_connections = alive_connections
|
| 93 |
+
cleaned = original_count - len(self.active_connections)
|
| 94 |
+
if cleaned > 0:
|
| 95 |
+
log.debug(f"清理了 {cleaned} 个死连接,剩余连接数: {len(self.active_connections)}")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# =============================================================================
|
| 99 |
+
# 工具函数
|
| 100 |
+
# =============================================================================
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def is_mobile_user_agent(user_agent: str) -> bool:
|
| 104 |
+
"""检测是否为移动设备用户代理"""
|
| 105 |
+
if not user_agent:
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
user_agent_lower = user_agent.lower()
|
| 109 |
+
mobile_keywords = [
|
| 110 |
+
"mobile",
|
| 111 |
+
"android",
|
| 112 |
+
"iphone",
|
| 113 |
+
"ipad",
|
| 114 |
+
"ipod",
|
| 115 |
+
"blackberry",
|
| 116 |
+
"windows phone",
|
| 117 |
+
"samsung",
|
| 118 |
+
"htc",
|
| 119 |
+
"motorola",
|
| 120 |
+
"nokia",
|
| 121 |
+
"palm",
|
| 122 |
+
"webos",
|
| 123 |
+
"opera mini",
|
| 124 |
+
"opera mobi",
|
| 125 |
+
"fennec",
|
| 126 |
+
"minimo",
|
| 127 |
+
"symbian",
|
| 128 |
+
"psp",
|
| 129 |
+
"nintendo",
|
| 130 |
+
"tablet",
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
return any(keyword in user_agent_lower for keyword in mobile_keywords)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def validate_mode(mode: str = "geminicli") -> str:
|
| 137 |
+
"""
|
| 138 |
+
验证 mode 参数
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
mode: 模式字符串 ("geminicli" 或 "antigravity")
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
str: 验证后的 mode 字符串
|
| 145 |
+
|
| 146 |
+
Raises:
|
| 147 |
+
HTTPException: 如果 mode 参数无效
|
| 148 |
+
"""
|
| 149 |
+
if mode not in ["geminicli", "antigravity"]:
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=400,
|
| 152 |
+
detail=f"无效的 mode 参数: {mode},只支持 'geminicli' 或 'antigravity'"
|
| 153 |
+
)
|
| 154 |
+
return mode
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def get_env_locked_keys() -> Set:
|
| 158 |
+
"""获取被环境变量锁定的配置键集合"""
|
| 159 |
+
env_locked_keys = set()
|
| 160 |
+
|
| 161 |
+
# 使用 config.py 中统一维护的映射表
|
| 162 |
+
for env_key, config_key in config.ENV_MAPPINGS.items():
|
| 163 |
+
if os.getenv(env_key):
|
| 164 |
+
env_locked_keys.add(config_key)
|
| 165 |
+
|
| 166 |
+
return env_locked_keys
|
src/panel/version.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
版本信息路由模块 - 处理 /version/* 相关的HTTP请求
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
|
| 10 |
+
from log import log
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# 创建路由器
|
| 14 |
+
router = APIRouter(prefix="/version", tags=["version"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get("/info")
|
| 18 |
+
async def get_version_info(check_update: bool = False):
|
| 19 |
+
"""
|
| 20 |
+
获取当前版本信息 - 从version.txt读取
|
| 21 |
+
可选参数 check_update: 是否检查GitHub上的最新版本
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
# 获取项目根目录
|
| 25 |
+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 26 |
+
version_file = os.path.join(project_root, "version.txt")
|
| 27 |
+
|
| 28 |
+
# 读取version.txt
|
| 29 |
+
if not os.path.exists(version_file):
|
| 30 |
+
return JSONResponse({
|
| 31 |
+
"success": False,
|
| 32 |
+
"error": "version.txt文件不存在"
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
version_data = {}
|
| 36 |
+
with open(version_file, 'r', encoding='utf-8') as f:
|
| 37 |
+
for line in f:
|
| 38 |
+
line = line.strip()
|
| 39 |
+
if '=' in line:
|
| 40 |
+
key, value = line.split('=', 1)
|
| 41 |
+
version_data[key] = value
|
| 42 |
+
|
| 43 |
+
# 检查必要字段
|
| 44 |
+
if 'short_hash' not in version_data:
|
| 45 |
+
return JSONResponse({
|
| 46 |
+
"success": False,
|
| 47 |
+
"error": "version.txt格式错误"
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
response_data = {
|
| 51 |
+
"success": True,
|
| 52 |
+
"version": version_data.get('short_hash', 'unknown'),
|
| 53 |
+
"full_hash": version_data.get('full_hash', ''),
|
| 54 |
+
"message": version_data.get('message', ''),
|
| 55 |
+
"date": version_data.get('date', '')
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# 如果需要检查更新
|
| 59 |
+
if check_update:
|
| 60 |
+
try:
|
| 61 |
+
from src.httpx_client import get_async
|
| 62 |
+
|
| 63 |
+
# 直接获取GitHub上的version.txt文件
|
| 64 |
+
github_version_url = "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/version.txt"
|
| 65 |
+
|
| 66 |
+
# 使用统一的httpx客户端
|
| 67 |
+
resp = await get_async(github_version_url, timeout=10.0)
|
| 68 |
+
|
| 69 |
+
if resp.status_code == 200:
|
| 70 |
+
# 解析远程version.txt
|
| 71 |
+
remote_version_data = {}
|
| 72 |
+
for line in resp.text.strip().split('\n'):
|
| 73 |
+
line = line.strip()
|
| 74 |
+
if '=' in line:
|
| 75 |
+
key, value = line.split('=', 1)
|
| 76 |
+
remote_version_data[key] = value
|
| 77 |
+
|
| 78 |
+
latest_hash = remote_version_data.get('full_hash', '')
|
| 79 |
+
latest_short_hash = remote_version_data.get('short_hash', '')
|
| 80 |
+
current_hash = version_data.get('full_hash', '')
|
| 81 |
+
|
| 82 |
+
has_update = (current_hash != latest_hash) if current_hash and latest_hash else None
|
| 83 |
+
|
| 84 |
+
response_data['check_update'] = True
|
| 85 |
+
response_data['has_update'] = has_update
|
| 86 |
+
response_data['latest_version'] = latest_short_hash
|
| 87 |
+
response_data['latest_hash'] = latest_hash
|
| 88 |
+
response_data['latest_message'] = remote_version_data.get('message', '')
|
| 89 |
+
response_data['latest_date'] = remote_version_data.get('date', '')
|
| 90 |
+
else:
|
| 91 |
+
# GitHub获取失败,但不影响基本版本信息
|
| 92 |
+
response_data['check_update'] = False
|
| 93 |
+
response_data['update_error'] = f"GitHub返回错误: {resp.status_code}"
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
log.debug(f"检查更新失败: {e}")
|
| 97 |
+
response_data['check_update'] = False
|
| 98 |
+
response_data['update_error'] = str(e)
|
| 99 |
+
|
| 100 |
+
return JSONResponse(response_data)
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
log.error(f"获取版本信息失败: {e}")
|
| 104 |
+
return JSONResponse({
|
| 105 |
+
"success": False,
|
| 106 |
+
"error": str(e)
|
| 107 |
+
})
|